From 00a2d544204b169c5a1a6e221a265153d6687326 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 7 May 2025 10:36:31 -0500 Subject: [PATCH 001/245] gtk: only allow one config error dialog at a time This fixes a problem introduced by #7241 that would cause multiple error dialogs to be shown. --- src/apprt/gtk/App.zig | 3 ++ src/apprt/gtk/ConfigErrorsDialog.zig | 58 ++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c9a973611..06cc41b9d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -74,6 +74,9 @@ cursor_none: ?*gdk.Cursor, /// The clipboard confirmation window, if it is currently open. clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, +/// The config errors dialog, if it is currently open. +config_errors_dialog: ?ConfigErrorsDialog = null, + /// The window containing the quick terminal. /// Null when never initialized. quick_terminal: ?*Window = null, diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig index a1a2a61af..c2de2f1dc 100644 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ b/src/apprt/gtk/ConfigErrorsDialog.zig @@ -29,15 +29,39 @@ error_message: *gtk.TextBuffer, pub fn maybePresent(app: *App, window: ?*Window) void { if (app.config._diagnostics.empty()) return; - var builder = switch (DialogType) { - adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5), - adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2), - else => unreachable, - }; - defer builder.deinit(); + const config_errors_dialog = config_errors_dialog: { + if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog; - const dialog = builder.getObject(DialogType, "config_errors_dialog").?; - const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; + var builder = switch (DialogType) { + adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5), + adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2), + else => unreachable, + }; + // defer builder.deinit(); + + const dialog = builder.getObject(DialogType, "config_errors_dialog").?; + const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; + + _ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{}); + + app.config_errors_dialog = .{ + .builder = builder, + .dialog = dialog, + .error_message = error_message, + }; + + break :config_errors_dialog app.config_errors_dialog.?; + }; + + { + var start = std.mem.zeroes(gtk.TextIter); + config_errors_dialog.error_message.getStartIter(&start); + + var end = std.mem.zeroes(gtk.TextIter); + config_errors_dialog.error_message.getEndIter(&end); + + config_errors_dialog.error_message.delete(&start, &end); + } var msg_buf: [4095:0]u8 = undefined; var fbs = std.io.fixedBufferStream(&msg_buf); @@ -52,22 +76,24 @@ pub fn maybePresent(app: *App, window: ?*Window) void { continue; }; - error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos)); - error_message.insertAtCursor("\n", 1); + config_errors_dialog.error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos)); + config_errors_dialog.error_message.insertAtCursor("\n", 1); } - _ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{}); - - const parent = if (window) |w| w.window.as(gtk.Widget) else null; - switch (DialogType) { - adw.AlertDialog => dialog.as(adw.Dialog).present(parent), - adw.MessageDialog => dialog.as(gtk.Window).present(), + adw.AlertDialog => { + const parent = if (window) |w| w.window.as(gtk.Widget) else null; + config_errors_dialog.dialog.as(adw.Dialog).present(parent); + }, + adw.MessageDialog => config_errors_dialog.dialog.as(gtk.Window).present(), else => unreachable, } } fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.c) void { + if (app.config_errors_dialog) |config_errors_dialog| config_errors_dialog.builder.deinit(); + app.config_errors_dialog = null; + if (std.mem.orderZ(u8, response, "reload") == .eq) { app.reloadConfig(.app, .{}) catch |err| { log.warn("error reloading config error={}", .{err}); From 5d81a31a49cb3971c3e886d4a400b5db80476e23 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 7 May 2025 11:12:07 -0500 Subject: [PATCH 002/245] gtk: remove dead code --- src/apprt/gtk/ConfigErrorsDialog.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig index c2de2f1dc..ccc5599ad 100644 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ b/src/apprt/gtk/ConfigErrorsDialog.zig @@ -37,7 +37,6 @@ pub fn maybePresent(app: *App, window: ?*Window) void { adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2), else => unreachable, }; - // defer builder.deinit(); const dialog = builder.getObject(DialogType, "config_errors_dialog").?; const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; From 69a744b52153237dd31ad377d95cc2b645d41a85 Mon Sep 17 00:00:00 2001 From: tangowithfoxtrot <5676771+tangowithfoxtrot@users.noreply.github.com> Date: Wed, 7 May 2025 11:46:36 -0700 Subject: [PATCH 003/245] docs: fix minor grammatical error --- 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 95dcf3420..7850fd068 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2004,7 +2004,7 @@ keybind: Keybinds = .{}, /// macOS doesn't have a distinct "alt" key and instead has the "option" /// key which behaves slightly differently. On macOS by default, the -/// option key plus a character will sometimes produces a Unicode character. +/// option key plus a character will sometimes produce a Unicode character. /// For example, on US standard layouts option-b produces "∫". This may be /// undesirable if you want to use "option" as an "alt" key for keybindings /// in terminal programs or shells. From 9c70f8aee17ab68e4b9bd5a3bd5449dd4b2981ee Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 16 Jan 2025 16:08:35 -0600 Subject: [PATCH 004/245] core: add context menu key --- include/ghostty.h | 3 +++ src/input/key.zig | 4 +++- src/input/keycodes.zig | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 18c547910..9409fa7c6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -240,6 +240,9 @@ typedef enum { GHOSTTY_KEY_KP_DELETE, GHOSTTY_KEY_KP_BEGIN, + // special keys + GHOSTTY_KEY_CONTEXT_MENU, + // modifiers GHOSTTY_KEY_LEFT_SHIFT, GHOSTTY_KEY_LEFT_CONTROL, diff --git a/src/input/key.zig b/src/input/key.zig index ec65170f2..c0f80e294 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -401,7 +401,8 @@ pub const Key = enum(c_int) { kp_delete, kp_begin, - // TODO: media keys + // special keys + context_menu, // modifiers left_shift, @@ -579,6 +580,7 @@ pub const Key = enum(c_int) { .backspace => cimgui.c.ImGuiKey_Backspace, .print_screen => cimgui.c.ImGuiKey_PrintScreen, .pause => cimgui.c.ImGuiKey_Pause, + .context_menu => cimgui.c.ImGuiKey_Menu, .f1 => cimgui.c.ImGuiKey_F1, .f2 => cimgui.c.ImGuiKey_F2, diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index 67ce46daf..e9adbc156 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -153,6 +153,7 @@ const code_to_key = code_to_key: { .{ "Numpad0", .kp_0 }, .{ "NumpadDecimal", .kp_decimal }, .{ "NumpadEqual", .kp_equal }, + .{ "ContextMenu", .context_menu }, .{ "ControlLeft", .left_control }, .{ "ShiftLeft", .left_shift }, .{ "AltLeft", .left_alt }, From a8b450f03dccf2d7beb5165db04243bbade6531b Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Wed, 7 May 2025 23:23:00 +0800 Subject: [PATCH 005/245] macOS: use file parent dir for `openTerminal` service cwd (#7286) --- .../Features/Services/ServiceProvider.swift | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index bb95cb55a..d5a8c7d45 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -47,14 +47,29 @@ class ServiceProvider: NSObject { let terminalManager = delegate.terminalManager for path in paths { - // We only open in directories. - var isDirectory = ObjCBool(true) + // Check if the path exists and determine if it's a directory + var isDirectory = ObjCBool(false) guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { continue } - guard isDirectory.boolValue else { continue } + + let targetDirectoryPath: String + + if isDirectory.boolValue { + // Path is already a directory, use it directly + targetDirectoryPath = path + } else { + // Path is a file, get its parent directory + let parentDirectoryPath = (path as NSString).deletingLastPathComponent + var isParentPathDirectory = ObjCBool(true) + guard FileManager.default.fileExists(atPath: parentDirectoryPath, isDirectory: &isParentPathDirectory), + isParentPathDirectory.boolValue else { + continue + } + targetDirectoryPath = parentDirectoryPath + } // Build our config var config = Ghostty.SurfaceConfiguration() - config.workingDirectory = path + config.workingDirectory = targetDirectoryPath switch (target) { case .window: From 3043012c1b39fe36968dfa1cbe99fbaddeed469f Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Thu, 8 May 2025 08:33:24 +0800 Subject: [PATCH 006/245] macOS: simplify path handling in `openTerminal` --- .../Features/Services/ServiceProvider.swift | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index d5a8c7d45..7e45a7b99 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -47,29 +47,19 @@ class ServiceProvider: NSObject { let terminalManager = delegate.terminalManager for path in paths { - // Check if the path exists and determine if it's a directory - var isDirectory = ObjCBool(false) + // We only open in directories. + var isDirectory = ObjCBool(true) guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { continue } - let targetDirectoryPath: String - - if isDirectory.boolValue { - // Path is already a directory, use it directly - targetDirectoryPath = path - } else { - // Path is a file, get its parent directory - let parentDirectoryPath = (path as NSString).deletingLastPathComponent - var isParentPathDirectory = ObjCBool(true) - guard FileManager.default.fileExists(atPath: parentDirectoryPath, isDirectory: &isParentPathDirectory), - isParentPathDirectory.boolValue else { - continue - } - targetDirectoryPath = parentDirectoryPath + var workingDirectory = path + if !isDirectory.boolValue { + workingDirectory = (path as NSString).deletingLastPathComponent + guard FileManager.default.fileExists(atPath: workingDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { continue } } // Build our config var config = Ghostty.SurfaceConfiguration() - config.workingDirectory = targetDirectoryPath + config.workingDirectory = workingDirectory switch (target) { case .window: From 800054874e9ee6012f84b2714ad9275161e8c555 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Fri, 9 May 2025 10:48:58 +0800 Subject: [PATCH 007/245] macOS: switch to using URL instead of String --- .../Features/Services/ServiceProvider.swift | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index 7e45a7b99..a06e7d151 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -36,30 +36,27 @@ class ServiceProvider: NSObject { error.pointee = Self.errorNoString return } - let filePaths = objs.map { $0.path }.compactMap { $0 } + let urlObjects = objs.map { $0 as URL } - openTerminal(filePaths, target: target) + openTerminal(urlObjects, target: target) } - private func openTerminal(_ paths: [String], target: OpenTarget) { + private func openTerminal(_ urls: [URL], target: OpenTarget) { guard let delegateRaw = NSApp.delegate else { return } guard let delegate = delegateRaw as? AppDelegate else { return } let terminalManager = delegate.terminalManager - for path in paths { - // We only open in directories. - var isDirectory = ObjCBool(true) - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { continue } - - var workingDirectory = path - if !isDirectory.boolValue { - workingDirectory = (path as NSString).deletingLastPathComponent - guard FileManager.default.fileExists(atPath: workingDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { continue } + let uniqueCwds: Set = Set( + urls.map { url -> URL in + // We only open in directories. + url.hasDirectoryPath ? url : url.deletingLastPathComponent() } + ) + for cwd in uniqueCwds { // Build our config var config = Ghostty.SurfaceConfiguration() - config.workingDirectory = workingDirectory + config.workingDirectory = cwd.path(percentEncoded: false) switch (target) { case .window: From 201ea050bd2812748353c8b43bcf9e37c62b3767 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 May 2025 08:27:11 -0700 Subject: [PATCH 008/245] update PACKAGING.md to be explicit about source vs. git Related to #7316 --- PACKAGING.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/PACKAGING.md b/PACKAGING.md index 234a86770..d85f55de7 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -4,13 +4,12 @@ Ghostty relies on downstream package maintainers to distribute Ghostty to end-users. This document provides guidance to package maintainers on how to package Ghostty for distribution. -> [!NOTE] +> [!IMPORTANT] > -> While Ghostty went through an extensive private beta testing period, -> packaging Ghostty is immature and may require additional build script -> tweaks and documentation improvement. I'm extremely motivated to work with -> package maintainers to improve the packaging process. Please open issues -> to discuss any packaging issues you encounter. +> This document is only accurate for the Ghostty source alongside it. +> **Do not use this document for older or newer versions of Ghostty!** If +> you are reading this document in a different version of Ghostty, please +> find the `PACKAGING.md` file alongside that version. ## Source Tarballs @@ -37,6 +36,19 @@ Use the `ghostty-source.tar.gz` asset and _not the GitHub auto-generated source tarball_. These tarballs are generated for every commit to the `main` branch and are not associated with a specific version. +> [!WARNING] +> +> Source tarballs are _not the same_ as a Git checkout. Source tarballs +> contain some preprocessed files that allow building Ghostty with less +> dependencies. If you are building Ghostty from a Git checkout, the +> steps below are the same but they may require additional dependencies +> not listed here. See the `README.md` for more information on building +> from a Git checkout. +> +> For everyone except Ghostty developers, please use the source tarballs. +> We generate tip source tarballs for users following the development +> branch. + ## Zig Version [Zig](https://ziglang.org) is required to build Ghostty. Prior to Zig 1.0, @@ -81,13 +93,6 @@ for system packages which separate a build and install step, since the install step can then be done with a `mv` or `cp` command (from `/tmp/ghostty` to wherever the package manager expects it). -> [!NOTE] -> -> **Version 1.1.1 and 1.1.2 are missing `fetch-zig-cache.sh`.** This was -> an oversight on the release process. You can use the script from version -> 1.1.0 to fetch the Zig cache for these versions. Future versions will -> restore the script. - ### Build Options Ghostty uses the Zig build system. You can see all available build options by From 91d15c89bc2f8533d1f852b388c96988368b09e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 08:21:31 -0700 Subject: [PATCH 009/245] input: key enum is now aligned with W3C keyboard codes --- src/input/key.zig | 376 +++++++++++++++++++++++++++++----------------- 1 file changed, 239 insertions(+), 137 deletions(-) diff --git a/src/input/key.zig b/src/input/key.zig index c0f80e294..a082134a7 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -255,95 +255,144 @@ pub const Action = enum(c_int) { repeat, }; -/// The set of keys that can map to keybindings. These have no fixed enum -/// values because we map platform-specific keys to this set. Note that -/// this only needs to accommodate what maps to a key. If a key is not bound -/// to anything and the key can be mapped to a printable character, then that -/// unicode character is sent directly to the pty. +/// The set of key codes that Ghostty is aware of. These represent +/// physical keys on the keyboard. The logical key (or key string) +/// is the string that is generated by the key event and that is up +/// to the apprt to provide. /// -/// This is backed by a c_int so we can use this as-is for our embedding API. +/// Note that these are layout-independent. For example, the "a" +/// key on a US keyboard is the same as the "ф" key on a Russian +/// keyboard, but both will report the "a" enum value in the key +/// event. These values are based on the W3C standard. See: +/// https://www.w3.org/TR/uievents-code +/// +/// Layout-dependent strings are provided in the KeyEvent struct as +/// UTF-8 and are produced by the associated apprt. Ghostty core has +/// no mechanism to map input events to strings without the apprt. /// /// IMPORTANT: Any changes here update include/ghostty.h pub const Key = enum(c_int) { - invalid, + unidentified, - // a-z - a, - b, - c, - d, - e, - f, - g, - h, - i, - j, - k, - l, - m, - n, - o, - p, - q, - r, - s, - t, - u, - v, - w, - x, - y, - z, - - // numbers - zero, - one, - two, - three, - four, - five, - six, - seven, - eight, - nine, - - // punctuation - semicolon, - space, - apostrophe, + // "Writing System Keys" § 3.1.1 + backquote, + backslash, + bracket_left, + bracket_right, comma, - grave_accent, // ` - period, - slash, - minus, - plus, + digit_0, + digit_1, + digit_2, + digit_3, + digit_4, + digit_5, + digit_6, + digit_7, + digit_8, + digit_9, equal, - left_bracket, // [ - right_bracket, // ] - backslash, // \ + intl_backslash, + intl_ro, + intl_yen, + key_a, + key_b, + key_c, + key_d, + key_e, + key_f, + key_g, + key_h, + key_i, + key_j, + key_k, + key_l, + key_m, + key_n, + key_o, + key_p, + key_q, + key_r, + key_s, + key_t, + key_u, + key_v, + key_w, + key_x, + key_y, + key_z, + minus, + period, + quote, + semicolon, + slash, - // control - up, - down, - right, - left, - home, - end, - insert, - delete, - caps_lock, - scroll_lock, - num_lock, - page_up, - page_down, - escape, - enter, - tab, + // "Functional Keys" § 3.1.2 + alt_left, + alt_right, backspace, - print_screen, - pause, + caps_lock, + context_menu, + control_left, + control_right, + enter, + meta_left, + meta_right, + shift_left, + shift_right, + space, + tab, + convert, + kana_mode, + non_convert, - // function keys + // "Control Pad Section" § 3.2 + delete, + end, + help, + home, + insert, + page_down, + page_up, + + // "Arrow Pad Section" § 3.3 + arrow_down, + arrow_left, + arrow_right, + arrow_up, + + // "Numpad Section" § 3.4 + num_lock, + numpad_0, + numpad_1, + numpad_2, + numpad_3, + numpad_4, + numpad_5, + numpad_6, + numpad_7, + numpad_8, + numpad_9, + numpad_add, + numpad_backspace, + numpad_clear, + numpad_clear_entry, + numpad_comma, + numpad_decimal, + numpad_divide, + numpad_enter, + numpad_equal, + numpad_memory_add, + numpad_memory_clear, + numpad_memory_recall, + numpad_memory_store, + numpad_memory_subtract, + numpad_multiply, + numpad_paren_left, + numpad_paren_right, + numpad_subtract, + + // "Function Section" § 3.5 + escape, f1, f2, f3, @@ -356,66 +405,119 @@ pub const Key = enum(c_int) { f10, f11, f12, - f13, - f14, - f15, - f16, - f17, - f18, - f19, - f20, - f21, - f22, - f23, - f24, - f25, + @"fn", + fn_lock, + print_screen, + scroll_lock, + pause, - // keypad - kp_0, - kp_1, - kp_2, - kp_3, - kp_4, - kp_5, - kp_6, - kp_7, - kp_8, - kp_9, - kp_decimal, - kp_divide, - kp_multiply, - kp_subtract, - kp_add, - kp_enter, - kp_equal, - kp_separator, - kp_left, - kp_right, - kp_up, - kp_down, - kp_page_up, - kp_page_down, - kp_home, - kp_end, - kp_insert, - kp_delete, - kp_begin, + // "Media Keys" § 3.6 + browser_back, + browser_favorites, + browser_forward, + browser_home, + browser_refresh, + browser_search, + browser_stop, + eject, + launch_app_1, + launch_app_2, + launch_mail, + media_play_pause, + media_select, + media_stop, + media_track_next, + media_track_previous, + power, + sleep, + audio_volume_down, + audio_volume_mute, + audio_volume_up, + wake_up, - // special keys - context_menu, - - // modifiers - left_shift, - left_control, - left_alt, - left_super, - right_shift, - right_control, - right_alt, - right_super, - - // To support more keys (there are obviously more!) add them here - // and ensure the mapping is up to date in the Window key handler. + // Backwards compatibility for Ghostty 1.1.x and earlier, we don't + // want to force people to rewrite their configs. + pub const a = .key_a; + pub const b = .key_b; + pub const c = .key_c; + pub const d = .key_d; + pub const e = .key_e; + pub const f = .key_f; + pub const g = .key_g; + pub const h = .key_h; + pub const i = .key_i; + pub const j = .key_j; + pub const k = .key_k; + pub const l = .key_l; + pub const m = .key_m; + pub const n = .key_n; + pub const o = .key_o; + pub const p = .key_p; + pub const q = .key_q; + pub const r = .key_r; + pub const s = .key_s; + pub const t = .key_t; + pub const u = .key_u; + pub const v = .key_v; + pub const w = .key_w; + pub const x = .key_x; + pub const y = .key_y; + pub const z = .key_z; + pub const zero = .digit_0; + pub const one = .digit_1; + pub const two = .digit_2; + pub const three = .digit_3; + pub const four = .digit_4; + pub const five = .digit_5; + pub const six = .digit_6; + pub const seven = .digit_7; + pub const eight = .digit_8; + pub const nine = .digit_9; + pub const apostrophe = .quote; + pub const grave_accent = .backquote; + pub const left_bracket = .bracket_left; + pub const right_bracket = .bracket_right; + pub const up = .arrow_up; + pub const down = .arrow_down; + pub const left = .arrow_left; + pub const right = .arrow_right; + pub const kp_0 = .numpad_0; + pub const kp_1 = .numpad_1; + pub const kp_2 = .numpad_2; + pub const kp_3 = .numpad_3; + pub const kp_4 = .numpad_4; + pub const kp_5 = .numpad_5; + pub const kp_6 = .numpad_6; + pub const kp_7 = .numpad_7; + pub const kp_8 = .numpad_8; + pub const kp_9 = .numpad_9; + pub const kp_decimal = .numpad_decimal; + pub const kp_divide = .numpad_divide; + pub const kp_multiply = .numpad_multiply; + pub const kp_subtract = .numpad_subtract; + pub const kp_add = .numpad_add; + pub const kp_enter = .numpad_enter; + pub const kp_equal = .numpad_equal; + pub const kp_separator = .numpad_separator; + pub const kp_left = .numpad_left; + pub const kp_right = .numpad_right; + pub const kp_up = .numpad_up; + pub const kp_down = .numpad_down; + pub const kp_page_up = .numpad_page_up; + pub const kp_page_down = .numpad_page_down; + pub const kp_home = .numpad_home; + pub const kp_end = .numpad_end; + pub const kp_insert = .numpad_insert; + pub const kp_delete = .numpad_delete; + pub const kp_begin = .numpad_begin; + pub const left_shift = .shift_left; + pub const right_shift = .shift_right; + pub const left_control = .control_left; + pub const right_control = .control_right; + pub const left_alt = .alt_left; + pub const right_alt = .alt_right; + pub const left_super = .meta_left; + pub const right_super = .meta_right; /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. From a3462dd2bd541af6372855917c6ccb5643aeda93 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 08:47:21 -0700 Subject: [PATCH 010/245] input: remove translated --- NOTES.md | 3 + src/config/Config.zig | 222 +++++++++++++------------- src/input/Binding.zig | 253 +++++++++++------------------- src/input/KeyEncoder.zig | 106 ++++++------- src/input/function_keys.zig | 62 ++++---- src/input/key.zig | 301 ++++++++++++++++++------------------ src/input/kitty.zig | 72 ++++----- src/inspector/key.zig | 6 +- src/surface_mouse.zig | 26 ++-- 9 files changed, 481 insertions(+), 570 deletions(-) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 000000000..8e4937bd4 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,3 @@ +- key backwards compatibility, e.g. `grave_accent` +- `physical:` backwards compatibility? + diff --git a/src/config/Config.zig b/src/config/Config.zig index 7850fd068..7d2814136 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4343,12 +4343,12 @@ pub const Keybinds = struct { // keybinds for opening and reloading config try self.set.put( alloc, - .{ .key = .{ .translated = .comma }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .reload_config = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .comma }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = ',' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .open_config = {} }, ); @@ -4362,12 +4362,12 @@ pub const Keybinds = struct { if (!builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .insert }, .mods = .{ .ctrl = true } }, .{ .copy_to_clipboard = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .insert }, .mods = .{ .shift = true } }, .{ .paste_from_clipboard = {} }, ); } @@ -4381,12 +4381,12 @@ pub const Keybinds = struct { try self.set.put( alloc, - .{ .key = .{ .translated = .c }, .mods = mods }, + .{ .key = .{ .unicode = 'c' }, .mods = mods }, .{ .copy_to_clipboard = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .v }, .mods = mods }, + .{ .key = .{ .unicode = 'v' }, .mods = mods }, .{ .paste_from_clipboard = {} }, ); } @@ -4397,84 +4397,84 @@ pub const Keybinds = struct { // set the expected keybind for the menu. try self.set.put( alloc, - .{ .key = .{ .translated = .plus }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '+' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .minus }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .minus }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .decrease_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .zero }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .digit_0 }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .reset_font_size = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .write_screen_file = .paste }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, + .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, .{ .write_screen_file = .open }, ); // Expand Selection try self.set.putFlags( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .shift = true } }, .{ .adjust_selection = .left }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .shift = true } }, .{ .adjust_selection = .right }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .up }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .down }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_up }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_down }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .home }, .mods = .{ .shift = true } }, .{ .adjust_selection = .home }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .end }, .mods = .{ .shift = true } }, .{ .adjust_selection = .end }, .{ .performable = true }, ); @@ -4482,12 +4482,12 @@ pub const Keybinds = struct { // Tabs common to all platforms try self.set.put( alloc, - .{ .key = .{ .translated = .tab }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .tab }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .tab }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .tab }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, ); @@ -4495,174 +4495,174 @@ pub const Keybinds = struct { if (comptime !builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .translated = .n }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .n }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .w }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_surface = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .q }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .q }, .mods = .{ .ctrl = true, .shift = true } }, .{ .quit = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .f4 }, .mods = .{ .alt = true } }, + .{ .key = .{ .physical = .f4 }, .mods = .{ .alt = true } }, .{ .close_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .t }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .t }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .w }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .ctrl = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .o }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .o }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .e }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .e }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left_bracket }, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right_bracket }, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, ); // Resizing splits try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .plus }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .plus }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .equalize_splits = {} }, ); // Viewport scrolling try self.set.put( alloc, - .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .home }, .mods = .{ .shift = true } }, .{ .scroll_to_top = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .end }, .mods = .{ .shift = true } }, .{ .scroll_to_bottom = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true } }, .{ .scroll_page_up = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true } }, .{ .scroll_page_down = {} }, ); // Semantic prompts try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .shift = true, .ctrl = true } }, .{ .jump_to_prompt = -1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .shift = true, .ctrl = true } }, .{ .jump_to_prompt = 1 }, ); // Inspector, matching Chromium try self.set.put( alloc, - .{ .key = .{ .translated = .i }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .physical = .i }, .mods = .{ .shift = true, .ctrl = true } }, .{ .inspector = .toggle }, ); // Terminal try self.set.put( alloc, - .{ .key = .{ .translated = .a }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .physical = .a }, .mods = .{ .shift = true, .ctrl = true } }, .{ .select_all = {} }, ); // Selection clipboard paste try self.set.put( alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .key = .{ .physical = .insert }, .mods = .{ .shift = true } }, .{ .paste_from_selection = {} }, ); } @@ -4675,23 +4675,14 @@ pub const Keybinds = struct { .{ .alt = true }; // Cmd+N for goto tab N - const start = @intFromEnum(inputpkg.Key.one); - const end = @intFromEnum(inputpkg.Key.eight); - var i: usize = start; + const start: u21 = '1'; + const end: u21 = '8'; + var i: u21 = start; while (i <= end) : (i += 1) { try self.set.put( alloc, .{ - // On macOS, we use the physical key for tab changing so - // that this works across all keyboard layouts. This may - // want to be true on other platforms as well but this - // is definitely true on macOS so we just do it here for - // now (#817) - .key = if (comptime builtin.target.os.tag.isDarwin()) - .{ .physical = @enumFromInt(i) } - else - .{ .translated = @enumFromInt(i) }, - + .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, @@ -4700,10 +4691,7 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ - .key = if (comptime builtin.target.os.tag.isDarwin()) - .{ .physical = .nine } - else - .{ .translated = .nine }, + .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, @@ -4713,14 +4701,14 @@ pub const Keybinds = struct { // Toggle fullscreen try self.set.put( alloc, - .{ .key = .{ .translated = .enter }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .enter }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .toggle_fullscreen = {} }, ); // Toggle zoom a split try self.set.put( alloc, - .{ .key = .{ .translated = .enter }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .key = .{ .physical = .enter }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, .{ .toggle_split_zoom = {} }, ); @@ -4728,199 +4716,199 @@ pub const Keybinds = struct { if (comptime builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .quit = {} }, ); try self.set.putFlags( alloc, - .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, .{ .performable = true }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .a }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, .{ .select_all = {} }, ); // Viewport scrolling try self.set.put( alloc, - .{ .key = .{ .translated = .home }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .home }, .mods = .{ .super = true } }, .{ .scroll_to_top = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .end }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .end }, .mods = .{ .super = true } }, .{ .scroll_to_bottom = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_up }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .page_up }, .mods = .{ .super = true } }, .{ .scroll_page_up = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .page_down }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .page_down }, .mods = .{ .super = true } }, .{ .scroll_page_down = {} }, ); // Semantic prompts try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .shift = true } }, .{ .jump_to_prompt = -1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .shift = true } }, .{ .jump_to_prompt = 1 }, ); // Mac windowing try self.set.put( alloc, - .{ .key = .{ .translated = .n }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'n' }, .mods = .{ .super = true } }, .{ .new_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true } }, .{ .close_surface = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .alt = true } }, .{ .close_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .shift = true } }, .{ .close_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true, .alt = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .shift = true, .alt = true } }, .{ .close_all_windows = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .t }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true } }, .{ .new_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .bracket_left }, .mods = .{ .super = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right_bracket }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .physical = .bracket_right }, .mods = .{ .super = true, .shift = true } }, .{ .next_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .d }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = 'd' }, .mods = .{ .super = true } }, .{ .new_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .d }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'd' }, .mods = .{ .super = true, .shift = true } }, .{ .new_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .bracket_left }, .mods = .{ .super = true } }, .{ .goto_split = .previous }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right_bracket }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .bracket_right }, .mods = .{ .super = true } }, .{ .goto_split = .next }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .up }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .left }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .alt = true } }, .{ .goto_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .up, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .down, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .left, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true } }, .{ .resize_split = .{ .right, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .equal }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .physical = .equal }, .mods = .{ .super = true, .ctrl = true } }, .{ .equalize_splits = {} }, ); // Jump to prompt, matches Terminal.app try self.set.put( alloc, - .{ .key = .{ .translated = .up }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true } }, .{ .jump_to_prompt = -1 }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .down }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true } }, .{ .jump_to_prompt = 1 }, ); // Toggle command palette, matches VSCode try self.set.put( alloc, - .{ .key = .{ .translated = .p }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'p' }, .mods = .{ .super = true, .shift = true } }, .{ .toggle_command_palette = {} }, ); // Inspector, matching Chromium try self.set.put( alloc, - .{ .key = .{ .translated = .i }, .mods = .{ .alt = true, .super = true } }, + .{ .key = .{ .unicode = 'i' }, .mods = .{ .alt = true, .super = true } }, .{ .inspector = .toggle }, ); // Alternate keybind, common to Mac programs try self.set.put( alloc, - .{ .key = .{ .translated = .f }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .ctrl = true } }, .{ .toggle_fullscreen = {} }, ); // Selection clipboard paste, matches Terminal.app try self.set.put( alloc, - .{ .key = .{ .translated = .v }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = 'v' }, .mods = .{ .super = true, .shift = true } }, .{ .paste_from_selection = {} }, ); @@ -4931,27 +4919,27 @@ pub const Keybinds = struct { // the keybinds to `unbind`. try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true } }, .{ .text = "\\x05" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true } }, .{ .text = "\\x01" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .backspace }, .mods = .{ .super = true } }, + .{ .key = .{ .physical = .backspace }, .mods = .{ .super = true } }, .{ .text = "\\x15" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .left }, .mods = .{ .alt = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .alt = true } }, .{ .esc = "b" }, ); try self.set.put( alloc, - .{ .key = .{ .translated = .right }, .mods = .{ .alt = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .alt = true } }, .{ .esc = "f" }, ); } @@ -5138,8 +5126,8 @@ pub const Keybinds = struct { // Note they turn into translated keys because they match // their ASCII mapping. const want = - \\keybind = ctrl+z>two=goto_tab:2 - \\keybind = ctrl+z>one=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 + \\keybind = ctrl+z>1=goto_tab:1 \\ ; try std.testing.expectEqualStrings(want, buf.items); @@ -5163,8 +5151,8 @@ pub const Keybinds = struct { // NB: This does not currently retain the order of the keybinds. const want = - \\a = ctrl+a>ctrl+b>w=close_window \\a = ctrl+a>ctrl+b>n=new_window + \\a = ctrl+a>ctrl+b>w=close_window \\a = ctrl+a>ctrl+c>t=new_tab \\a = ctrl+b>ctrl+d>a=previous_tab \\ diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6583e1462..30575bc30 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -99,9 +99,12 @@ pub const Parser = struct { 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 - // this lets us fall into that. + // If we don't recognize the prefix then we're done. We + // let any unknown prefix fallthrough to trigger-specific + // parsing in case there are trigger-specific prefixes + // (none currently but historically there was `physical:` + // at one point). Breaking here lets us always implement new + // prefixes. break; } @@ -202,14 +205,12 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { 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), } @@ -1065,18 +1066,12 @@ pub const Action = union(enum) { /// This must be kept in sync with include/ghostty.h ghostty_input_trigger_s pub const Trigger = struct { /// The key that has to be pressed for a binding to take action. - key: Trigger.Key = .{ .translated = .invalid }, + key: Trigger.Key = .{ .physical = .unidentified }, /// The key modifiers that must be active for this to match. mods: key.Mods = .{}, pub const Key = union(C.Tag) { - /// key is the translated version of a key. This is the key that - /// a logical keyboard layout at the OS level would translate the - /// physical key to. For example if you use a US hardware keyboard - /// but have a Dvorak layout, the key would be the Dvorak key. - translated: key.Key, - /// key is the "physical" version. This is the same as mapped for /// standard US keyboard layouts. For non-US keyboard layouts, this /// is used to bind to a physical key location rather than a translated @@ -1091,18 +1086,16 @@ pub const Trigger = struct { /// The extern struct used for triggers in the C API. pub const C = extern struct { - tag: Tag = .translated, - key: C.Key = .{ .translated = .invalid }, + tag: Tag = .physical, + key: C.Key = .{ .physical = .unidentified }, mods: key.Mods = .{}, pub const Tag = enum(c_int) { - translated, physical, unicode, }; pub const Key = extern union { - translated: key.Key, physical: key.Key, unicode: u32, }; @@ -1150,24 +1143,16 @@ pub const Trigger = struct { } } - // If the key starts with "physical" then this is an physical key. - const physical_prefix = "physical:"; - const physical = std.mem.startsWith(u8, part, physical_prefix); - const key_part = if (physical) part[physical_prefix.len..] else part; - // Check if its a key const keysInfo = @typeInfo(key.Key).@"enum"; inline for (keysInfo.fields) |field| { - if (!std.mem.eql(u8, field.name, "invalid")) { - if (std.mem.eql(u8, key_part, field.name)) { + if (!std.mem.eql(u8, field.name, "unidentified")) { + if (std.mem.eql(u8, part, field.name)) { // Repeat not allowed if (!result.isKeyUnset()) return Error.InvalidFormat; const keyval = @field(key.Key, field.name); - result.key = if (physical) - .{ .physical = keyval } - else - .{ .translated = keyval }; + result.key = .{ .physical = keyval }; continue :loop; } } @@ -1177,21 +1162,13 @@ pub const Trigger = struct { // character then we can use that as a key. if (result.isKeyUnset()) unicode: { // Invalid UTF8 drops to invalid format - const view = std.unicode.Utf8View.init(key_part) catch break :unicode; + const view = std.unicode.Utf8View.init(part) catch break :unicode; var it = view.iterator(); // No codepoints or multiple codepoints drops to invalid format 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; } @@ -1205,7 +1182,7 @@ pub const Trigger = struct { /// Returns true if this trigger has no key set. pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { - .translated => |v| v == .invalid, + .physical => |v| v == .unidentified, else => false, }; } @@ -1228,7 +1205,6 @@ pub const Trigger = struct { return .{ .tag = self.key, .key = switch (self.key) { - .translated => |v| .{ .translated = v }, .physical => |v| .{ .physical = v }, .unicode => |v| .{ .unicode = @intCast(v) }, }, @@ -1254,8 +1230,7 @@ pub const Trigger = struct { // Key switch (self.key) { - .translated => |k| try writer.print("{s}", .{@tagName(k)}), - .physical => |k| try writer.print("physical:{s}", .{@tagName(k)}), + .physical => |k| try writer.print("{s}", .{@tagName(k)}), .unicode => |c| try writer.print("{u}", .{c}), } } @@ -1620,13 +1595,10 @@ pub const Set = struct { pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry { var trigger: Trigger = .{ .mods = event.mods.binding(), - .key = .{ .translated = event.key }, + .key = .{ .physical = event.physical_key }, }; if (self.get(trigger)) |v| return v; - trigger.key = .{ .physical = event.physical_key }; - if (self.get(trigger)) |v| return v; - if (event.unshifted_codepoint > 0) { trigger.key = .{ .unicode = event.unshifted_codepoint }; if (self.get(trigger)) |v| return v; @@ -1637,19 +1609,7 @@ pub const Set = struct { /// Remove a binding for a given trigger. pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void { - // Remove whatever this trigger is self.removeExact(alloc, t); - - // If we have a physical we remove translated and vice versa. - const alternate: Trigger.Key = switch (t.key) { - .unicode => return, - .translated => |k| .{ .physical = k }, - .physical => |k| .{ .translated = k }, - }; - - var alt_t: Trigger = t; - alt_t.key = alternate; - self.removeExact(alloc, alt_t); } fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { @@ -1750,37 +1710,24 @@ test "parse: triggers" { // single character try testing.expectEqual( Binding{ - .trigger = .{ .key = .{ .translated = .a } }, + .trigger = .{ .key = .{ .unicode = 'a' } }, .action = .{ .ignore = {} }, }, 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 = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("shift+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("ctrl+a=ignore")); @@ -1789,7 +1736,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true, .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("shift+ctrl+a=ignore")); @@ -1798,7 +1745,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("a+shift=ignore")); @@ -1807,10 +1754,10 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, - }, try parseSingle("shift+physical:a=ignore")); + }, try parseSingle("shift+key_a=ignore")); // unicode keys try testing.expectEqual(Binding{ @@ -1825,7 +1772,7 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .consumed = false }, @@ -1835,17 +1782,17 @@ test "parse: triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, .flags = .{ .consumed = false }, - }, try parseSingle("unconsumed:physical:a+shift=ignore")); + }, try parseSingle("unconsumed:key_a+shift=ignore")); // performable keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .performable = true }, @@ -1868,7 +1815,7 @@ test "parse: global triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .global = true }, @@ -1878,17 +1825,17 @@ test "parse: global triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, .flags = .{ .global = true }, - }, try parseSingle("global:physical:a+shift=ignore")); + }, try parseSingle("global:key_a+shift=ignore")); // global unconsumed keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ @@ -1911,7 +1858,7 @@ test "parse: all triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ .all = true }, @@ -1921,17 +1868,17 @@ test "parse: all triggers" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .physical = .a }, + .key = .{ .physical = .key_a }, }, .action = .{ .ignore = {} }, .flags = .{ .all = true }, - }, try parseSingle("all:physical:a+shift=ignore")); + }, try parseSingle("all:key_a+shift=ignore")); // all unconsumed keys try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .shift = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, .flags = .{ @@ -1953,14 +1900,14 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("cmd+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .super = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("command+a=ignore")); @@ -1968,14 +1915,14 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("opt+a=ignore")); try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .alt = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("option+a=ignore")); @@ -1983,7 +1930,7 @@ test "parse: modifier aliases" { try testing.expectEqual(Binding{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, }, try parseSingle("control+a=ignore")); @@ -2002,7 +1949,7 @@ test "parse: action no parameters" { // no parameters try testing.expectEqual( Binding{ - .trigger = .{ .key = .{ .translated = .a } }, + .trigger = .{ .key = .{ .unicode = 'a' } }, .action = .{ .ignore = {} }, }, try parseSingle("a=ignore"), @@ -2108,15 +2055,15 @@ test "sequence iterator" { // single character { var it: SequenceIterator = .{ .input = "a" }; - try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); try testing.expect(try it.next() == null); } // multi character { var it: SequenceIterator = .{ .input = "a>b" }; - try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); - try testing.expectEqual(Trigger{ .key = .{ .translated = .b } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'b' } }, (try it.next()).?); try testing.expect(try it.next() == null); } @@ -2135,7 +2082,7 @@ test "sequence iterator" { // empty ending sequence { var it: SequenceIterator = .{ .input = "a>" }; - try testing.expectEqual(Trigger{ .key = .{ .translated = .a } }, (try it.next()).?); + try testing.expectEqual(Trigger{ .key = .{ .unicode = 'a' } }, (try it.next()).?); try testing.expectError(Error.InvalidFormat, it.next()); } } @@ -2149,7 +2096,7 @@ test "parse: sequences" { try testing.expectEqual(Parser.Elem{ .binding = .{ .trigger = .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .action = .{ .ignore = {} }, } }, (try p.next()).?); @@ -2160,11 +2107,11 @@ test "parse: sequences" { { var p = try Parser.init("a>b=ignore"); try testing.expectEqual(Parser.Elem{ .leader = .{ - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, } }, (try p.next()).?); try testing.expectEqual(Parser.Elem{ .binding = .{ .trigger = .{ - .key = .{ .translated = .b }, + .key = .{ .unicode = 'b' }, }, .action = .{ .ignore = {} }, } }, (try p.next()).?); @@ -2183,7 +2130,7 @@ test "set: parseAndPut typical binding" { // Creates forward mapping { - const action = s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf; + const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; try testing.expect(action.action == .new_window); try testing.expectEqual(Flags{}, action.flags); } @@ -2191,7 +2138,7 @@ test "set: parseAndPut typical binding" { // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2206,7 +2153,7 @@ test "set: parseAndPut unconsumed binding" { // Creates forward mapping { - const trigger: Trigger = .{ .key = .{ .translated = .a } }; + const trigger: Trigger = .{ .key = .{ .unicode = 'a' } }; const action = s.get(trigger).?.value_ptr.*.leaf; try testing.expect(action.action == .new_window); try testing.expectEqual(Flags{ .consumed = false }, action.flags); @@ -2215,7 +2162,7 @@ test "set: parseAndPut unconsumed binding" { // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2231,25 +2178,7 @@ test "set: parseAndPut removed binding" { // Creates forward mapping { - const trigger: Trigger = .{ .key = .{ .translated = .a } }; - try testing.expect(s.get(trigger) == null); - } - try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); -} - -test "set: parseAndPut removed physical binding" { - const testing = std.testing; - const alloc = testing.allocator; - - var s: Set = .{}; - defer s.deinit(alloc); - - try s.parseAndPut(alloc, "physical:a=new_window"); - try s.parseAndPut(alloc, "a=unbind"); - - // Creates forward mapping - { - const trigger: Trigger = .{ .key = .{ .physical = .a } }; + const trigger: Trigger = .{ .key = .{ .unicode = 'a' } }; try testing.expect(s.get(trigger) == null); } try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); @@ -2265,13 +2194,13 @@ test "set: parseAndPut sequence" { try s.parseAndPut(alloc, "a>b=new_window"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); @@ -2290,20 +2219,20 @@ test "set: parseAndPut sequence with two actions" { try s.parseAndPut(alloc, "a>c=new_tab"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); try testing.expectEqual(Flags{}, e.leaf.flags); } { - const t: Trigger = .{ .key = .{ .translated = .c } }; + const t: Trigger = .{ .key = .{ .unicode = 'c' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_tab); @@ -2322,13 +2251,13 @@ test "set: parseAndPut overwrite sequence" { try s.parseAndPut(alloc, "a>b=new_window"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); @@ -2347,13 +2276,13 @@ test "set: parseAndPut overwrite leader" { try s.parseAndPut(alloc, "a>b=new_window"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leader); current = e.leader; } { - const t: Trigger = .{ .key = .{ .translated = .b } }; + const t: Trigger = .{ .key = .{ .unicode = 'b' } }; const e = current.get(t).?.value_ptr.*; try testing.expect(e == .leaf); try testing.expect(e.leaf.action == .new_window); @@ -2372,7 +2301,7 @@ test "set: parseAndPut unbind sequence unbinds leader" { try s.parseAndPut(alloc, "a>b=unbind"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; try testing.expect(current.get(t) == null); } } @@ -2387,7 +2316,7 @@ test "set: parseAndPut unbind sequence unbinds leader if not set" { try s.parseAndPut(alloc, "a>b=unbind"); var current: *Set = &s; { - const t: Trigger = .{ .key = .{ .translated = .a } }; + const t: Trigger = .{ .key = .{ .unicode = 'a' } }; try testing.expect(current.get(t) == null); } } @@ -2405,7 +2334,7 @@ test "set: parseAndPut sequence preserves reverse mapping" { // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2419,13 +2348,13 @@ test "set: put overwrites sequence" { try s.parseAndPut(alloc, "ctrl+a>b=new_window"); try s.put(alloc, .{ .mods = .{ .ctrl = true }, - .key = .{ .translated = .a }, + .key = .{ .unicode = 'a' }, }, .{ .new_window = {} }); // Creates reverse mapping { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2436,24 +2365,24 @@ test "set: maintains reverse mapping" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // should be most recent - try s.put(alloc, .{ .key = .{ .translated = .b } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .b); + try testing.expect(trigger.key.unicode == 'b'); } // removal should replace - s.remove(alloc, .{ .key = .{ .translated = .b } }); + s.remove(alloc, .{ .key = .{ .unicode = 'b' } }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2464,29 +2393,29 @@ test "set: performable is not part of reverse mappings" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // trigger should be non-performable try s.putFlags( alloc, - .{ .key = .{ .translated = .b } }, + .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }, .{ .performable = true }, ); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // removal of performable should do nothing - s.remove(alloc, .{ .key = .{ .translated = .b } }); + s.remove(alloc, .{ .key = .{ .unicode = 'b' } }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } } @@ -2497,14 +2426,14 @@ test "set: overriding a mapping updates reverse" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }).?; - try testing.expect(trigger.key.translated == .a); + try testing.expect(trigger.key.unicode == 'a'); } // should be most recent - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_tab = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_tab = {} }); { const trigger = s.getTrigger(.{ .new_window = {} }); try testing.expect(trigger == null); @@ -2518,22 +2447,22 @@ test "set: consumed state" { var s: Set = .{}; defer s.deinit(alloc); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); try s.putFlags( alloc, - .{ .key = .{ .translated = .a } }, + .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }, .{ .consumed = false }, ); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); - try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(!s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); - try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.* == .leaf); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.value_ptr.*.leaf.flags.consumed); + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.* == .leaf); + try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } test "Action: clone" { diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index e79856a94..3d43a4e86 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -1082,7 +1082,7 @@ test "kitty: plain text" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{}, .utf8 = "abcd", }, @@ -1098,7 +1098,7 @@ test "kitty: repeat with just disambiguate" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .action = .repeat, .mods = .{}, .utf8 = "a", @@ -1222,7 +1222,7 @@ test "kitty: enter with all flags" { test "kitty: ctrl with all flags" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ - .event = .{ .key = .left_control, .mods = .{ .ctrl = true }, .utf8 = "" }, + .event = .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1240,7 +1240,7 @@ test "kitty: ctrl release with ctrl mod set" { var enc: KeyEncoder = .{ .event = .{ .action = .release, - .key = .left_control, + .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "", }, @@ -1272,7 +1272,7 @@ test "kitty: composing with no modifier" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{ .shift = true }, .composing = true, }, @@ -1287,7 +1287,7 @@ test "kitty: composing with modifier" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_shift, + .key = .shift_left, .mods = .{ .shift = true }, .composing = true, }, @@ -1302,7 +1302,7 @@ test "kitty: shift+a on US keyboard" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{ .shift = true }, .utf8 = "A", .unshifted_codepoint = 97, // lowercase A @@ -1321,7 +1321,7 @@ test "kitty: matching unshifted codepoint" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .a, + .key = .key_a, .mods = .{ .shift = true }, .utf8 = "A", .unshifted_codepoint = 65, @@ -1344,7 +1344,7 @@ test "kitty: report alternates with caps" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .j, + .key = .key_j, .mods = .{ .caps_lock = true }, .utf8 = "J", .unshifted_codepoint = 106, @@ -1450,7 +1450,7 @@ test "kitty: report alternates with hu layout release" { var enc: KeyEncoder = .{ .event = .{ .action = .release, - .key = .left_bracket, + .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "", .unshifted_codepoint = 337, @@ -1473,7 +1473,7 @@ test "kitty: up arrow with utf8" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .up, + .key = .arrow_up, .mods = .{}, .utf8 = &.{30}, }, @@ -1505,7 +1505,7 @@ test "kitty: left shift" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_shift, + .key = .shift_left, .mods = .{}, .utf8 = "", }, @@ -1521,7 +1521,7 @@ test "kitty: left shift with report all" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_shift, + .key = .shift_left, .mods = .{}, .utf8 = "", }, @@ -1539,7 +1539,7 @@ test "kitty: report associated with alt text on macOS with option" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .w, + .key = .key_w, .mods = .{ .alt = true }, .utf8 = "∑", .unshifted_codepoint = 119, @@ -1565,7 +1565,7 @@ test "kitty: report associated with alt text on macOS with alt" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .w, + .key = .key_w, .mods = .{ .alt = true }, .utf8 = "∑", .unshifted_codepoint = 119, @@ -1588,7 +1588,7 @@ test "kitty: report associated with alt text on macOS with alt" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .w, + .key = .key_w, .mods = .{}, .utf8 = "∑", .unshifted_codepoint = 119, @@ -1611,7 +1611,7 @@ test "kitty: report associated with modifiers" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .j, + .key = .key_j, .mods = .{ .ctrl = true }, .utf8 = "j", .unshifted_codepoint = 106, @@ -1632,7 +1632,7 @@ test "kitty: report associated" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .j, + .key = .key_j, .mods = .{ .shift = true }, .utf8 = "J", .unshifted_codepoint = 106, @@ -1654,7 +1654,7 @@ test "kitty: report associated on release" { var enc: KeyEncoder = .{ .event = .{ .action = .release, - .key = .j, + .key = .key_j, .mods = .{ .shift = true }, .utf8 = "J", .unshifted_codepoint = 106, @@ -1713,7 +1713,7 @@ test "kitty: enter with utf8 (dead key state)" { test "kitty: keypad number" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ - .event = .{ .key = .kp_1, .mods = .{}, .utf8 = "1" }, + .event = .{ .key = .numpad_1, .mods = .{}, .utf8 = "1" }, .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1807,7 +1807,7 @@ test "legacy: ctrl+alt+c" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .mods = .{ .ctrl = true, .alt = true }, .utf8 = "c", }, @@ -1821,7 +1821,7 @@ test "legacy: alt+c" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .utf8 = "c", .mods = .{ .alt = true }, }, @@ -1837,7 +1837,7 @@ test "legacy: alt+e only unshifted" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .e, + .key = .key_e, .unshifted_codepoint = 'e', .mods = .{ .alt = true }, }, @@ -1855,7 +1855,7 @@ test "legacy: alt+x macos" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .utf8 = "≈", .unshifted_codepoint = 'c', .mods = .{ .alt = true }, @@ -1891,7 +1891,7 @@ test "legacy: alt+ф" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .f, + .key = .key_f, .utf8 = "ф", .mods = .{ .alt = true }, }, @@ -1906,7 +1906,7 @@ test "legacy: ctrl+c" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .c, + .key = .key_c, .mods = .{ .ctrl = true }, .utf8 = "c", }, @@ -1947,7 +1947,7 @@ test "legacy: ctrl+shift+char with modify other state 2" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .h, + .key = .key_h, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "H", }, @@ -1962,7 +1962,7 @@ test "legacy: fixterm awkward letters" { var buf: [128]u8 = undefined; { var enc: KeyEncoder = .{ .event = .{ - .key = .i, + .key = .key_i, .mods = .{ .ctrl = true }, .utf8 = "i", } }; @@ -1971,7 +1971,7 @@ test "legacy: fixterm awkward letters" { } { var enc: KeyEncoder = .{ .event = .{ - .key = .m, + .key = .key_m, .mods = .{ .ctrl = true }, .utf8 = "m", } }; @@ -1980,7 +1980,7 @@ test "legacy: fixterm awkward letters" { } { var enc: KeyEncoder = .{ .event = .{ - .key = .left_bracket, + .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "[", } }; @@ -1989,7 +1989,7 @@ test "legacy: fixterm awkward letters" { } { var enc: KeyEncoder = .{ .event = .{ - .key = .two, + .key = .digit_2, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "@", .unshifted_codepoint = '2', @@ -2005,7 +2005,7 @@ test "legacy: ctrl+shift+letter ascii" { var buf: [128]u8 = undefined; { var enc: KeyEncoder = .{ .event = .{ - .key = .m, + .key = .key_m, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "M", .unshifted_codepoint = 'm', @@ -2019,7 +2019,7 @@ test "legacy: shift+function key should use all mods" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .up, + .key = .arrow_up, .mods = .{ .shift = true }, .consumed_mods = .{ .shift = true }, }, @@ -2033,7 +2033,7 @@ test "legacy: keypad enter" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_enter, + .key = .numpad_enter, .mods = .{}, .consumed_mods = .{}, }, @@ -2047,7 +2047,7 @@ test "legacy: keypad 1" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{}, .consumed_mods = .{}, .utf8 = "1", @@ -2062,7 +2062,7 @@ test "legacy: keypad 1 with application keypad" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{}, .consumed_mods = .{}, .utf8 = "1", @@ -2078,7 +2078,7 @@ test "legacy: keypad 1 with application keypad and numlock" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{ .num_lock = true }, .consumed_mods = .{}, .utf8 = "1", @@ -2094,7 +2094,7 @@ test "legacy: keypad 1 with application keypad and numlock ignore" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .kp_1, + .key = .numpad_1, .mods = .{ .num_lock = false }, .consumed_mods = .{}, .utf8 = "1", @@ -2189,8 +2189,8 @@ test "legacy: hu layout ctrl+ő sends proper codepoint" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .left_bracket, - .physical_key = .left_bracket, + .key = .bracket_left, + .physical_key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "ő", .unshifted_codepoint = 337, @@ -2207,7 +2207,7 @@ test "legacy: super-only on macOS with text" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .b, + .key = .key_b, .utf8 = "b", .mods = .{ .super = true }, }, @@ -2223,7 +2223,7 @@ test "legacy: super and other mods on macOS with text" { var buf: [128]u8 = undefined; var enc: KeyEncoder = .{ .event = .{ - .key = .b, + .key = .key_b, .utf8 = "B", .mods = .{ .super = true, .shift = true }, }, @@ -2234,50 +2234,50 @@ test "legacy: super and other mods on macOS with text" { } test "ctrlseq: normal ctrl c" { - const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true }); + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: normal ctrl c, right control" { - const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: alt should be allowed" { - const seq = ctrlSeq(.invalid, "c", 'c', .{ .alt = true, .ctrl = true }); + const seq = ctrlSeq(.unidentified, "c", 'c', .{ .alt = true, .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: no ctrl does nothing" { - try testing.expect(ctrlSeq(.invalid, "c", 'c', .{}) == null); + try testing.expect(ctrlSeq(.unidentified, "c", 'c', .{}) == null); } test "ctrlseq: shifted non-character" { - const seq = ctrlSeq(.invalid, "_", '-', .{ .ctrl = true, .shift = true }); + const seq = ctrlSeq(.unidentified, "_", '-', .{ .ctrl = true, .shift = true }); try testing.expectEqual(@as(u8, 0x1F), seq.?); } test "ctrlseq: caps ascii letter" { - const seq = ctrlSeq(.invalid, "C", 'c', .{ .ctrl = true, .caps_lock = true }); + const seq = ctrlSeq(.unidentified, "C", 'c', .{ .ctrl = true, .caps_lock = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: shift does not generate ctrl seq" { - try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true }) == null); - try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true, .ctrl = true }) == null); + try testing.expect(ctrlSeq(.unidentified, "C", 'c', .{ .shift = true }) == null); + try testing.expect(ctrlSeq(.unidentified, "C", 'c', .{ .shift = true, .ctrl = true }) == null); } test "ctrlseq: russian ctrl c" { - const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true }); + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: russian shifted ctrl c" { - const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .shift = true }); + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .shift = true }); try testing.expect(seq == null); } test "ctrlseq: russian alt ctrl c" { - const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .alt = true }); + const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .alt = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } diff --git a/src/input/function_keys.zig b/src/input/function_keys.zig index 612112e28..33a5b89c0 100644 --- a/src/input/function_keys.zig +++ b/src/input/function_keys.zig @@ -75,10 +75,10 @@ pub const KeyEntryArray = std.EnumArray(key.Key, []const Entry); pub const keys = keys: { var result = KeyEntryArray.initFill(&.{}); - result.set(.up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); - result.set(.down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); - result.set(.right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); - result.set(.left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); + result.set(.arrow_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); + result.set(.arrow_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); + result.set(.arrow_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); + result.set(.arrow_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); result.set(.home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); result.set(.end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); result.set(.insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); @@ -101,33 +101,33 @@ pub const keys = keys: { result.set(.f12, pcStyle("\x1b[24;{}~") ++ .{Entry{ .sequence = "\x1B[24~" }}); // Keypad keys - result.set(.kp_0, kpKeys("p")); - result.set(.kp_1, kpKeys("q")); - result.set(.kp_2, kpKeys("r")); - result.set(.kp_3, kpKeys("s")); - result.set(.kp_4, kpKeys("t")); - result.set(.kp_5, kpKeys("u")); - result.set(.kp_6, kpKeys("v")); - result.set(.kp_7, kpKeys("w")); - result.set(.kp_8, kpKeys("x")); - result.set(.kp_9, kpKeys("y")); - result.set(.kp_decimal, kpKeys("n")); - result.set(.kp_divide, kpKeys("o")); - result.set(.kp_multiply, kpKeys("j")); - result.set(.kp_subtract, kpKeys("m")); - result.set(.kp_add, kpKeys("k")); - result.set(.kp_enter, kpKeys("M") ++ .{Entry{ .sequence = "\r" }}); - result.set(.kp_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); - result.set(.kp_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); - result.set(.kp_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); - result.set(.kp_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); - result.set(.kp_begin, pcStyle("\x1b[1;{}E") ++ cursorKey("\x1b[E", "\x1bOE")); - result.set(.kp_home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); - result.set(.kp_end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); - result.set(.kp_insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); - result.set(.kp_delete, pcStyle("\x1b[3;{}~") ++ .{Entry{ .sequence = "\x1B[3~" }}); - result.set(.kp_page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); - result.set(.kp_page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); + result.set(.numpad_0, kpKeys("p")); + result.set(.numpad_1, kpKeys("q")); + result.set(.numpad_2, kpKeys("r")); + result.set(.numpad_3, kpKeys("s")); + result.set(.numpad_4, kpKeys("t")); + result.set(.numpad_5, kpKeys("u")); + result.set(.numpad_6, kpKeys("v")); + result.set(.numpad_7, kpKeys("w")); + result.set(.numpad_8, kpKeys("x")); + result.set(.numpad_9, kpKeys("y")); + result.set(.numpad_decimal, kpKeys("n")); + result.set(.numpad_divide, kpKeys("o")); + result.set(.numpad_multiply, kpKeys("j")); + result.set(.numpad_subtract, kpKeys("m")); + result.set(.numpad_add, kpKeys("k")); + result.set(.numpad_enter, kpKeys("M") ++ .{Entry{ .sequence = "\r" }}); + result.set(.numpad_up, pcStyle("\x1b[1;{}A") ++ cursorKey("\x1b[A", "\x1bOA")); + result.set(.numpad_down, pcStyle("\x1b[1;{}B") ++ cursorKey("\x1b[B", "\x1bOB")); + result.set(.numpad_right, pcStyle("\x1b[1;{}C") ++ cursorKey("\x1b[C", "\x1bOC")); + result.set(.numpad_left, pcStyle("\x1b[1;{}D") ++ cursorKey("\x1b[D", "\x1bOD")); + result.set(.numpad_begin, pcStyle("\x1b[1;{}E") ++ cursorKey("\x1b[E", "\x1bOE")); + result.set(.numpad_home, pcStyle("\x1b[1;{}H") ++ cursorKey("\x1b[H", "\x1bOH")); + result.set(.numpad_end, pcStyle("\x1b[1;{}F") ++ cursorKey("\x1b[F", "\x1bOF")); + result.set(.numpad_insert, pcStyle("\x1b[2;{}~") ++ .{Entry{ .sequence = "\x1B[2~" }}); + result.set(.numpad_delete, pcStyle("\x1b[3;{}~") ++ .{Entry{ .sequence = "\x1B[3~" }}); + result.set(.numpad_page_up, pcStyle("\x1b[5;{}~") ++ .{Entry{ .sequence = "\x1B[5~" }}); + result.set(.numpad_page_down, pcStyle("\x1b[6;{}~") ++ .{Entry{ .sequence = "\x1B[6~" }}); result.set(.backspace, &.{ // Modify Keys Normal diff --git a/src/input/key.zig b/src/input/key.zig index a082134a7..0609108a1 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -21,7 +21,7 @@ pub const KeyEvent = struct { /// the "i" physical key will be reported as "c". The physical /// key is the key that was physically pressed on the keyboard. key: Key, - physical_key: Key = .invalid, + physical_key: Key = .unidentified, /// Mods are the modifiers that are pressed. mods: Mods = .{}, @@ -391,6 +391,25 @@ pub const Key = enum(c_int) { numpad_paren_right, numpad_subtract, + // > For numpads that provide keys not listed here, a code value string + // > should be created by starting with "Numpad" and appending an + // > appropriate description of the key. + // + // These numpad entries are distinguished by various encoding protocols + // (legacy and Kitty) so we support them here in case the apprt can + // produce them. + numpad_up, + numpad_down, + numpad_right, + numpad_left, + numpad_begin, + numpad_home, + numpad_end, + numpad_insert, + numpad_delete, + numpad_page_up, + numpad_page_down, + // "Function Section" § 3.5 escape, f1, @@ -405,6 +424,19 @@ pub const Key = enum(c_int) { f10, f11, f12, + f13, + f14, + f15, + f16, + f17, + f18, + f19, + f20, + f21, + f22, + f23, + f24, + f25, @"fn", fn_lock, print_screen, @@ -437,87 +469,61 @@ pub const Key = enum(c_int) { // Backwards compatibility for Ghostty 1.1.x and earlier, we don't // want to force people to rewrite their configs. - pub const a = .key_a; - pub const b = .key_b; - pub const c = .key_c; - pub const d = .key_d; - pub const e = .key_e; - pub const f = .key_f; - pub const g = .key_g; - pub const h = .key_h; - pub const i = .key_i; - pub const j = .key_j; - pub const k = .key_k; - pub const l = .key_l; - pub const m = .key_m; - pub const n = .key_n; - pub const o = .key_o; - pub const p = .key_p; - pub const q = .key_q; - pub const r = .key_r; - pub const s = .key_s; - pub const t = .key_t; - pub const u = .key_u; - pub const v = .key_v; - pub const w = .key_w; - pub const x = .key_x; - pub const y = .key_y; - pub const z = .key_z; - pub const zero = .digit_0; - pub const one = .digit_1; - pub const two = .digit_2; - pub const three = .digit_3; - pub const four = .digit_4; - pub const five = .digit_5; - pub const six = .digit_6; - pub const seven = .digit_7; - pub const eight = .digit_8; - pub const nine = .digit_9; - pub const apostrophe = .quote; - pub const grave_accent = .backquote; - pub const left_bracket = .bracket_left; - pub const right_bracket = .bracket_right; - pub const up = .arrow_up; - pub const down = .arrow_down; - pub const left = .arrow_left; - pub const right = .arrow_right; - pub const kp_0 = .numpad_0; - pub const kp_1 = .numpad_1; - pub const kp_2 = .numpad_2; - pub const kp_3 = .numpad_3; - pub const kp_4 = .numpad_4; - pub const kp_5 = .numpad_5; - pub const kp_6 = .numpad_6; - pub const kp_7 = .numpad_7; - pub const kp_8 = .numpad_8; - pub const kp_9 = .numpad_9; - pub const kp_decimal = .numpad_decimal; - pub const kp_divide = .numpad_divide; - pub const kp_multiply = .numpad_multiply; - pub const kp_subtract = .numpad_subtract; - pub const kp_add = .numpad_add; - pub const kp_enter = .numpad_enter; - pub const kp_equal = .numpad_equal; - pub const kp_separator = .numpad_separator; - pub const kp_left = .numpad_left; - pub const kp_right = .numpad_right; - pub const kp_up = .numpad_up; - pub const kp_down = .numpad_down; - pub const kp_page_up = .numpad_page_up; - pub const kp_page_down = .numpad_page_down; - pub const kp_home = .numpad_home; - pub const kp_end = .numpad_end; - pub const kp_insert = .numpad_insert; - pub const kp_delete = .numpad_delete; - pub const kp_begin = .numpad_begin; - pub const left_shift = .shift_left; - pub const right_shift = .shift_right; - pub const left_control = .control_left; - pub const right_control = .control_right; - pub const left_alt = .alt_left; - pub const right_alt = .alt_right; - pub const left_super = .meta_left; - pub const right_super = .meta_right; + // pub const zero = .digit_0; + // pub const one = .digit_1; + // pub const two = .digit_2; + // pub const three = .digit_3; + // pub const four = .digit_4; + // pub const five = .digit_5; + // pub const six = .digit_6; + // pub const seven = .digit_7; + // pub const eight = .digit_8; + // pub const nine = .digit_9; + // pub const apostrophe = .quote; + // pub const grave_accent = .backquote; + // pub const left_bracket = .bracket_left; + // pub const right_bracket = .bracket_right; + // pub const up = .arrow_up; + // pub const down = .arrow_down; + // pub const left = .arrow_left; + // pub const right = .arrow_right; + // pub const kp_0 = .numpad_0; + // pub const kp_1 = .numpad_1; + // pub const kp_2 = .numpad_2; + // pub const kp_3 = .numpad_3; + // pub const kp_4 = .numpad_4; + // pub const kp_5 = .numpad_5; + // pub const kp_6 = .numpad_6; + // pub const kp_7 = .numpad_7; + // pub const kp_8 = .numpad_8; + // pub const kp_9 = .numpad_9; + // pub const kp_decimal = .numpad_decimal; + // pub const kp_divide = .numpad_divide; + // pub const kp_multiply = .numpad_multiply; + // pub const kp_subtract = .numpad_subtract; + // pub const kp_add = .numpad_add; + // pub const kp_enter = .numpad_enter; + // pub const kp_equal = .numpad_equal; + // pub const kp_separator = .numpad_separator; + // pub const kp_left = .numpad_left; + // pub const kp_right = .numpad_right; + // pub const kp_up = .numpad_up; + // pub const kp_down = .numpad_down; + // pub const kp_page_up = .numpad_page_up; + // pub const kp_page_down = .numpad_page_down; + // pub const kp_home = .numpad_home; + // pub const kp_end = .numpad_end; + // pub const kp_insert = .numpad_insert; + // pub const kp_delete = .numpad_delete; + // pub const kp_begin = .numpad_begin; + // pub const left_shift = .shift_left; + // pub const right_shift = .shift_right; + // pub const left_control = .control_left; + // pub const right_control = .control_right; + // pub const left_alt = .alt_left; + // pub const right_alt = .alt_right; + // pub const left_super = .meta_left; + // pub const right_super = .meta_right; /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. @@ -586,7 +592,7 @@ pub const Key = enum(c_int) { return switch (self) { inline else => |tag| { const name = @tagName(tag); - const result = comptime std.mem.startsWith(u8, name, "kp_"); + const result = comptime std.mem.startsWith(u8, name, "numpad_"); return result; }, }; @@ -763,107 +769,106 @@ pub const Key = enum(c_int) { /// or ctrl. pub fn ctrlOrSuper(self: Key) bool { if (comptime builtin.target.os.tag.isDarwin()) { - return self == .left_super or self == .right_super; + return self == .meta_left or self == .meta_right; } - return self == .left_control or self == .right_control; + return self == .control_left or self == .control_right; } /// true if this key is either left or right shift. pub fn leftOrRightShift(self: Key) bool { - return self == .left_shift or self == .right_shift; + return self == .shift_left or self == .shift_right; } /// true if this key is either left or right alt. pub fn leftOrRightAlt(self: Key) bool { - return self == .left_alt or self == .right_alt; + return self == .alt_left or self == .alt_right; } test "fromASCII should not return keypad keys" { const testing = std.testing; - try testing.expect(Key.fromASCII('0').? == .zero); + try testing.expect(Key.fromASCII('0').? == .digit_0); try testing.expect(Key.fromASCII('*') == null); } test "keypad keys" { const testing = std.testing; - try testing.expect(Key.kp_0.keypad()); - try testing.expect(!Key.one.keypad()); + try testing.expect(Key.numpad_0.keypad()); + try testing.expect(!Key.digit_1.keypad()); } const codepoint_map: []const struct { u21, Key } = &.{ - .{ 'a', .a }, - .{ 'b', .b }, - .{ 'c', .c }, - .{ 'd', .d }, - .{ 'e', .e }, - .{ 'f', .f }, - .{ 'g', .g }, - .{ 'h', .h }, - .{ 'i', .i }, - .{ 'j', .j }, - .{ 'k', .k }, - .{ 'l', .l }, - .{ 'm', .m }, - .{ 'n', .n }, - .{ 'o', .o }, - .{ 'p', .p }, - .{ 'q', .q }, - .{ 'r', .r }, - .{ 's', .s }, - .{ 't', .t }, - .{ 'u', .u }, - .{ 'v', .v }, - .{ 'w', .w }, - .{ 'x', .x }, - .{ 'y', .y }, - .{ 'z', .z }, - .{ '0', .zero }, - .{ '1', .one }, - .{ '2', .two }, - .{ '3', .three }, - .{ '4', .four }, - .{ '5', .five }, - .{ '6', .six }, - .{ '7', .seven }, - .{ '8', .eight }, - .{ '9', .nine }, + .{ 'a', .key_a }, + .{ 'b', .key_b }, + .{ 'c', .key_c }, + .{ 'd', .key_d }, + .{ 'e', .key_e }, + .{ 'f', .key_f }, + .{ 'g', .key_g }, + .{ 'h', .key_h }, + .{ 'i', .key_i }, + .{ 'j', .key_j }, + .{ 'k', .key_k }, + .{ 'l', .key_l }, + .{ 'm', .key_m }, + .{ 'n', .key_n }, + .{ 'o', .key_o }, + .{ 'p', .key_p }, + .{ 'q', .key_q }, + .{ 'r', .key_r }, + .{ 's', .key_s }, + .{ 't', .key_t }, + .{ 'u', .key_u }, + .{ 'v', .key_v }, + .{ 'w', .key_w }, + .{ 'x', .key_x }, + .{ 'y', .key_y }, + .{ 'z', .key_z }, + .{ '0', .digit_0 }, + .{ '1', .digit_1 }, + .{ '2', .digit_2 }, + .{ '3', .digit_3 }, + .{ '4', .digit_4 }, + .{ '5', .digit_5 }, + .{ '6', .digit_6 }, + .{ '7', .digit_7 }, + .{ '8', .digit_8 }, + .{ '9', .digit_9 }, .{ ';', .semicolon }, .{ ' ', .space }, - .{ '\'', .apostrophe }, + .{ '\'', .quote }, .{ ',', .comma }, - .{ '`', .grave_accent }, + .{ '`', .backquote }, .{ '.', .period }, .{ '/', .slash }, .{ '-', .minus }, - .{ '+', .plus }, .{ '=', .equal }, - .{ '[', .left_bracket }, - .{ ']', .right_bracket }, + .{ '[', .bracket_left }, + .{ ']', .bracket_right }, .{ '\\', .backslash }, // Control characters .{ '\t', .tab }, - // Keypad entries. We just assume keypad with the kp_ prefix + // Keypad entries. We just assume keypad with the numpad_ prefix // 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 }, - .{ '3', .kp_3 }, - .{ '4', .kp_4 }, - .{ '5', .kp_5 }, - .{ '6', .kp_6 }, - .{ '7', .kp_7 }, - .{ '8', .kp_8 }, - .{ '9', .kp_9 }, - .{ '.', .kp_decimal }, - .{ '/', .kp_divide }, - .{ '*', .kp_multiply }, - .{ '-', .kp_subtract }, - .{ '+', .kp_add }, - .{ '=', .kp_equal }, + .{ '0', .numpad_0 }, + .{ '1', .numpad_1 }, + .{ '2', .numpad_2 }, + .{ '3', .numpad_3 }, + .{ '4', .numpad_4 }, + .{ '5', .numpad_5 }, + .{ '6', .numpad_6 }, + .{ '7', .numpad_7 }, + .{ '8', .numpad_8 }, + .{ '9', .numpad_9 }, + .{ '.', .numpad_decimal }, + .{ '/', .numpad_divide }, + .{ '*', .numpad_multiply }, + .{ '-', .numpad_subtract }, + .{ '+', .numpad_add }, + .{ '=', .numpad_equal }, }; }; diff --git a/src/input/kitty.zig b/src/input/kitty.zig index 6e9cdddf8..be397b84b 100644 --- a/src/input/kitty.zig +++ b/src/input/kitty.zig @@ -49,10 +49,10 @@ const raw_entries: []const RawEntry = &.{ .{ .backspace, 127, 'u', false }, .{ .insert, 2, '~', false }, .{ .delete, 3, '~', false }, - .{ .left, 1, 'D', false }, - .{ .right, 1, 'C', false }, - .{ .up, 1, 'A', false }, - .{ .down, 1, 'B', false }, + .{ .arrow_left, 1, 'D', false }, + .{ .arrow_right, 1, 'C', false }, + .{ .arrow_up, 1, 'A', false }, + .{ .arrow_down, 1, 'B', false }, .{ .page_up, 5, '~', false }, .{ .page_down, 6, '~', false }, .{ .home, 1, 'H', false }, @@ -89,46 +89,32 @@ const raw_entries: []const RawEntry = &.{ .{ .f24, 57387, 'u', false }, .{ .f25, 57388, 'u', false }, - .{ .kp_0, 57399, 'u', false }, - .{ .kp_1, 57400, 'u', false }, - .{ .kp_2, 57401, 'u', false }, - .{ .kp_3, 57402, 'u', false }, - .{ .kp_4, 57403, 'u', false }, - .{ .kp_5, 57404, 'u', false }, - .{ .kp_6, 57405, 'u', false }, - .{ .kp_7, 57406, 'u', false }, - .{ .kp_8, 57407, 'u', false }, - .{ .kp_9, 57408, 'u', false }, - .{ .kp_decimal, 57409, 'u', false }, - .{ .kp_divide, 57410, 'u', false }, - .{ .kp_multiply, 57411, 'u', false }, - .{ .kp_subtract, 57412, 'u', false }, - .{ .kp_add, 57413, 'u', false }, - .{ .kp_enter, 57414, 'u', false }, - .{ .kp_equal, 57415, 'u', false }, - .{ .kp_separator, 57416, 'u', false }, - .{ .kp_left, 57417, 'u', false }, - .{ .kp_right, 57418, 'u', false }, - .{ .kp_up, 57419, 'u', false }, - .{ .kp_down, 57420, 'u', false }, - .{ .kp_page_up, 57421, 'u', false }, - .{ .kp_page_down, 57422, 'u', false }, - .{ .kp_home, 57423, 'u', false }, - .{ .kp_end, 57424, 'u', false }, - .{ .kp_insert, 57425, 'u', false }, - .{ .kp_delete, 57426, 'u', false }, - .{ .kp_begin, 57427, 'u', false }, + .{ .numpad_0, 57399, 'u', false }, + .{ .numpad_1, 57400, 'u', false }, + .{ .numpad_2, 57401, 'u', false }, + .{ .numpad_3, 57402, 'u', false }, + .{ .numpad_4, 57403, 'u', false }, + .{ .numpad_5, 57404, 'u', false }, + .{ .numpad_6, 57405, 'u', false }, + .{ .numpad_7, 57406, 'u', false }, + .{ .numpad_8, 57407, 'u', false }, + .{ .numpad_9, 57408, 'u', false }, + .{ .numpad_decimal, 57409, 'u', false }, + .{ .numpad_divide, 57410, 'u', false }, + .{ .numpad_multiply, 57411, 'u', false }, + .{ .numpad_subtract, 57412, 'u', false }, + .{ .numpad_add, 57413, 'u', false }, + .{ .numpad_enter, 57414, 'u', false }, + .{ .numpad_equal, 57415, 'u', false }, - // TODO: media keys - - .{ .left_shift, 57441, 'u', true }, - .{ .right_shift, 57447, 'u', true }, - .{ .left_control, 57442, 'u', true }, - .{ .right_control, 57448, 'u', true }, - .{ .left_super, 57444, 'u', true }, - .{ .right_super, 57450, 'u', true }, - .{ .left_alt, 57443, 'u', true }, - .{ .right_alt, 57449, 'u', true }, + .{ .shift_left, 57441, 'u', true }, + .{ .shift_right, 57447, 'u', true }, + .{ .control_left, 57442, 'u', true }, + .{ .control_right, 57448, 'u', true }, + .{ .meta_left, 57444, 'u', true }, + .{ .meta_right, 57450, 'u', true }, + .{ .alt_left, 57443, 'u', true }, + .{ .alt_right, 57449, 'u', true }, }; test { diff --git a/src/inspector/key.zig b/src/inspector/key.zig index e28bd5d4a..10626d6bd 100644 --- a/src/inspector/key.zig +++ b/src/inspector/key.zig @@ -56,7 +56,7 @@ pub const Event = struct { // Write our key. If we have an invalid key we attempt to write // the utf8 associated with it if we have it to handle non-ascii. try writer.writeAll(switch (self.event.key) { - .invalid => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(.invalid), + .unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key), else => @tagName(self.event.key), }); @@ -227,9 +227,9 @@ test "event string" { const testing = std.testing; const alloc = testing.allocator; - var event = try Event.init(alloc, .{ .key = .a }); + var event = try Event.init(alloc, .{ .key = .key_a }); defer event.deinit(alloc); var buf: [1024]u8 = undefined; - try testing.expectEqualStrings("Press: a", try event.label(&buf)); + try testing.expectEqualStrings("Press: key_a", try event.label(&buf)); } diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index ed1e36335..a9702a8fe 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -132,7 +132,7 @@ test "keyToMouseShape" { { // No specific key pressed const m: SurfaceMouse = .{ - .physical_key = .invalid, + .physical_key = .unidentified, .mouse_event = .none, .mouse_shape = .progress, .mods = .{}, @@ -148,7 +148,7 @@ test "keyToMouseShape" { // Over a link. NOTE: This tests that we don't touch the inbound state, // not necessarily if we're over a link. const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .none, .mouse_shape = .progress, .mods = .{}, @@ -163,7 +163,7 @@ test "keyToMouseShape" { { // Mouse is currently hidden const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .none, .mouse_shape = .progress, .mods = .{}, @@ -178,7 +178,7 @@ test "keyToMouseShape" { { // default, no mods (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .x10, .mouse_shape = .default, .mods = .{}, @@ -194,7 +194,7 @@ test "keyToMouseShape" { { // default -> crosshair (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .default, .mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true }, @@ -210,7 +210,7 @@ test "keyToMouseShape" { { // default -> text (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .x10, .mouse_shape = .default, .mods = .{ .shift = true }, @@ -226,7 +226,7 @@ test "keyToMouseShape" { { // crosshair -> text (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .crosshair, .mods = .{ .shift = true }, @@ -242,7 +242,7 @@ test "keyToMouseShape" { { // crosshair -> default (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .crosshair, .mods = .{}, @@ -258,7 +258,7 @@ test "keyToMouseShape" { { // text -> crosshair (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .x10, .mouse_shape = .text, .mods = .{ .ctrl = true, .super = true, .alt = true, .shift = true }, @@ -274,7 +274,7 @@ test "keyToMouseShape" { { // text -> default (mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .x10, .mouse_shape = .text, .mods = .{}, @@ -290,7 +290,7 @@ test "keyToMouseShape" { { // text, no mods (no mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_shift, + .physical_key = .shift_left, .mouse_event = .none, .mouse_shape = .text, .mods = .{}, @@ -306,7 +306,7 @@ test "keyToMouseShape" { { // text -> crosshair (no mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .none, .mouse_shape = .text, .mods = .{ .ctrl = true, .super = true, .alt = true }, @@ -322,7 +322,7 @@ test "keyToMouseShape" { { // crosshair -> text (no mouse tracking) const m: SurfaceMouse = .{ - .physical_key = .left_alt, + .physical_key = .alt_left, .mouse_event = .none, .mouse_shape = .crosshair, .mods = .{}, From 24d433333b5a28f5e484dfdc724262992eb67e91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 10:44:49 -0700 Subject: [PATCH 011/245] apprt/glfw: builds --- src/Surface.zig | 17 +++-- src/apprt/glfw.zig | 142 +++++++++++++++++++------------------- src/cli/list_keybinds.zig | 8 +-- src/input/key.zig | 16 ++--- 4 files changed, 93 insertions(+), 90 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0d4c9d984..e173d2d8b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1895,12 +1895,12 @@ pub fn keyCallback( // if we didn't have a previous event and this is a release // event then we just want to set it to null. const prev = self.pressed_key orelse break :event null; - if (prev.key == copy.key) copy.key = .invalid; + if (prev.key == copy.key) copy.key = .unidentified; } // If our key is invalid and we have no mods, then we're done! // This helps catch the state that we naturally released all keys. - if (copy.key == .invalid and copy.mods.empty()) break :event null; + if (copy.key == .unidentified and copy.mods.empty()) break :event null; break :event copy; }; @@ -2295,7 +2295,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { pressed_key.action = .release; // Release the full key first - if (pressed_key.key != .invalid) { + if (pressed_key.key != .unidentified) { assert(self.keyCallback(pressed_key) catch |err| err: { log.warn("error releasing key on focus loss err={}", .{err}); break :err .ignored; @@ -2315,8 +2315,15 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { if (@field(pressed_key.mods, key)) { @field(pressed_key.mods, key) = false; inline for (&.{ "right", "left" }) |side| { - const keyname = if (comptime std.mem.eql(u8, key, "ctrl")) "control" else key; - pressed_key.key = @field(input.Key, side ++ "_" ++ keyname); + const keyname = comptime keyname: { + break :keyname if (std.mem.eql(u8, key, "ctrl")) + "control" + else if (std.mem.eql(u8, key, "super")) + "meta" + else + key; + }; + pressed_key.key = @field(input.Key, keyname ++ "_" ++ side); if (pressed_key.key != original_key) { assert(self.keyCallback(pressed_key) catch |err| err: { log.warn("error releasing key on focus loss err={}", .{err}); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 9d1c8a6b5..763933b91 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -966,46 +966,46 @@ pub const Surface = struct { .repeat => .repeat, }; const key: input.Key = switch (glfw_key) { - .a => .a, - .b => .b, - .c => .c, - .d => .d, - .e => .e, - .f => .f, - .g => .g, - .h => .h, - .i => .i, - .j => .j, - .k => .k, - .l => .l, - .m => .m, - .n => .n, - .o => .o, - .p => .p, - .q => .q, - .r => .r, - .s => .s, - .t => .t, - .u => .u, - .v => .v, - .w => .w, - .x => .x, - .y => .y, - .z => .z, - .zero => .zero, - .one => .one, - .two => .two, - .three => .three, - .four => .four, - .five => .five, - .six => .six, - .seven => .seven, - .eight => .eight, - .nine => .nine, - .up => .up, - .down => .down, - .right => .right, - .left => .left, + .a => .key_a, + .b => .key_b, + .c => .key_c, + .d => .key_d, + .e => .key_e, + .f => .key_f, + .g => .key_g, + .h => .key_h, + .i => .key_i, + .j => .key_j, + .k => .key_k, + .l => .key_l, + .m => .key_m, + .n => .key_n, + .o => .key_o, + .p => .key_p, + .q => .key_q, + .r => .key_r, + .s => .key_s, + .t => .key_t, + .u => .key_u, + .v => .key_v, + .w => .key_w, + .x => .key_x, + .y => .key_y, + .z => .key_z, + .zero => .digit_0, + .one => .digit_1, + .two => .digit_2, + .three => .digit_3, + .four => .digit_4, + .five => .digit_5, + .six => .digit_6, + .seven => .digit_7, + .eight => .digit_8, + .nine => .digit_9, + .up => .arrow_up, + .down => .arrow_down, + .right => .arrow_right, + .left => .arrow_left, .home => .home, .end => .end, .page_up => .page_up, @@ -1036,34 +1036,34 @@ pub const Surface = struct { .F23 => .f23, .F24 => .f24, .F25 => .f25, - .kp_0 => .kp_0, - .kp_1 => .kp_1, - .kp_2 => .kp_2, - .kp_3 => .kp_3, - .kp_4 => .kp_4, - .kp_5 => .kp_5, - .kp_6 => .kp_6, - .kp_7 => .kp_7, - .kp_8 => .kp_8, - .kp_9 => .kp_9, - .kp_decimal => .kp_decimal, - .kp_divide => .kp_divide, - .kp_multiply => .kp_multiply, - .kp_subtract => .kp_subtract, - .kp_add => .kp_add, - .kp_enter => .kp_enter, - .kp_equal => .kp_equal, - .grave_accent => .grave_accent, + .kp_0 => .numpad_0, + .kp_1 => .numpad_1, + .kp_2 => .numpad_2, + .kp_3 => .numpad_3, + .kp_4 => .numpad_4, + .kp_5 => .numpad_5, + .kp_6 => .numpad_6, + .kp_7 => .numpad_7, + .kp_8 => .numpad_8, + .kp_9 => .numpad_9, + .kp_decimal => .numpad_decimal, + .kp_divide => .numpad_divide, + .kp_multiply => .numpad_multiply, + .kp_subtract => .numpad_subtract, + .kp_add => .numpad_add, + .kp_enter => .numpad_enter, + .kp_equal => .numpad_equal, + .grave_accent => .backquote, .minus => .minus, .equal => .equal, .space => .space, .semicolon => .semicolon, - .apostrophe => .apostrophe, + .apostrophe => .quote, .comma => .comma, .period => .period, .slash => .slash, - .left_bracket => .left_bracket, - .right_bracket => .right_bracket, + .left_bracket => .bracket_left, + .right_bracket => .bracket_right, .backslash => .backslash, .enter => .enter, .tab => .tab, @@ -1075,20 +1075,20 @@ pub const Surface = struct { .num_lock => .num_lock, .print_screen => .print_screen, .pause => .pause, - .left_shift => .left_shift, - .left_control => .left_control, - .left_alt => .left_alt, - .left_super => .left_super, - .right_shift => .right_shift, - .right_control => .right_control, - .right_alt => .right_alt, - .right_super => .right_super, + .left_shift => .shift_left, + .left_control => .control_left, + .left_alt => .alt_left, + .left_super => .meta_left, + .right_shift => .shift_right, + .right_control => .control_right, + .right_alt => .alt_right, + .right_super => .meta_right, + .menu => .context_menu, - .menu, .world_1, .world_2, .unknown, - => .invalid, + => .unidentified, }; // This is a hack for GLFW. We require our apprts to send both diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 6cd989201..f84d540c3 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -155,14 +155,12 @@ const ChordBinding = struct { while (l_trigger != null and r_trigger != null) { const lhs_key: c_int = blk: { switch (l_trigger.?.data.key) { - .translated => |key| break :blk @intFromEnum(key), .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), } }; const rhs_key: c_int = blk: { switch (r_trigger.?.data.key) { - .translated => |key| break :blk @intFromEnum(key), .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), } @@ -254,8 +252,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { result = win.printSegment(.{ .text = " + " }, .{ .col_offset = result.col }); } const key = switch (trigger.data.key) { - .translated => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}), - .physical => |k| try std.fmt.allocPrint(alloc, "physical:{s}", .{@tagName(k)}), + .physical => |k| try std.fmt.allocPrint(alloc, "{s}", .{@tagName(k)}), .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}), }; result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col }); @@ -297,8 +294,7 @@ fn iterateBindings(alloc: Allocator, iter: anytype, win: *const vaxis.Window) !s if (t.mods.shift) try std.fmt.format(buf.writer(), "shift + ", .{}); switch (t.key) { - .translated => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}), - .physical => |k| try std.fmt.format(buf.writer(), "physical:{s}", .{@tagName(k)}), + .physical => |k| try std.fmt.format(buf.writer(), "{s}", .{@tagName(k)}), .unicode => |c| try std.fmt.format(buf.writer(), "{u}", .{c}), } diff --git a/src/input/key.zig b/src/input/key.zig index 0609108a1..b39c5e5d3 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -573,14 +573,14 @@ pub const Key = enum(c_int) { /// True if this key is a modifier. pub fn modifier(self: Key) bool { return switch (self) { - .left_shift, - .left_control, - .left_alt, - .left_super, - .right_shift, - .right_control, - .right_alt, - .right_super, + .shift_left, + .control_left, + .alt_left, + .meta_left, + .shift_right, + .control_right, + .alt_right, + .meta_right, => true, else => false, From b991d36343e19ebff9026c331351b0dbe5b88fc8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 10:58:41 -0700 Subject: [PATCH 012/245] macOS: build --- include/ghostty.h | 221 +++++++++++-------- macos/Sources/Ghostty/Ghostty.Input.swift | 250 +++++----------------- src/apprt/embedded.zig | 33 +-- src/input/key.zig | 211 ++++++++++-------- src/input/keycodes.zig | 140 ++++++------ 5 files changed, 379 insertions(+), 476 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 9409fa7c6..600396a84 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -104,9 +104,28 @@ typedef enum { } ghostty_input_action_e; typedef enum { - GHOSTTY_KEY_INVALID, + GHOSTTY_KEY_UNIDENTIFIED, - // a-z + // "Writing System Keys" § 3.1.1 + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, GHOSTTY_KEY_A, GHOSTTY_KEY_B, GHOSTTY_KEY_C, @@ -133,56 +152,90 @@ typedef enum { GHOSTTY_KEY_X, GHOSTTY_KEY_Y, GHOSTTY_KEY_Z, - - // numbers - GHOSTTY_KEY_ZERO, - GHOSTTY_KEY_ONE, - GHOSTTY_KEY_TWO, - GHOSTTY_KEY_THREE, - GHOSTTY_KEY_FOUR, - GHOSTTY_KEY_FIVE, - GHOSTTY_KEY_SIX, - GHOSTTY_KEY_SEVEN, - GHOSTTY_KEY_EIGHT, - GHOSTTY_KEY_NINE, - - // puncuation - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_APOSTROPHE, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_GRAVE_ACCENT, // ` - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_SLASH, GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PLUS, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_LEFT_BRACKET, // [ - GHOSTTY_KEY_RIGHT_BRACKET, // ] - GHOSTTY_KEY_BACKSLASH, // \ + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, - // control - GHOSTTY_KEY_UP, - GHOSTTY_KEY_DOWN, - GHOSTTY_KEY_RIGHT, - GHOSTTY_KEY_LEFT, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_END, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_PAGE_UP, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_TAB, + // "Functional Keys" § 3.1.2 + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_PAUSE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, - // function keys + // "Control Pad Section" § 3.2 + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // "Arrow Pad Section" § 3.3 + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // "Numpad Section" § 3.4 + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // "Function Section" § 3.5 + GHOSTTY_KEY_ESCAPE, GHOSTTY_KEY_F1, GHOSTTY_KEY_F2, GHOSTTY_KEY_F3, @@ -208,50 +261,35 @@ typedef enum { GHOSTTY_KEY_F23, GHOSTTY_KEY_F24, GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, - // keypad - GHOSTTY_KEY_KP_0, - GHOSTTY_KEY_KP_1, - GHOSTTY_KEY_KP_2, - GHOSTTY_KEY_KP_3, - GHOSTTY_KEY_KP_4, - GHOSTTY_KEY_KP_5, - GHOSTTY_KEY_KP_6, - GHOSTTY_KEY_KP_7, - GHOSTTY_KEY_KP_8, - GHOSTTY_KEY_KP_9, - GHOSTTY_KEY_KP_DECIMAL, - GHOSTTY_KEY_KP_DIVIDE, - GHOSTTY_KEY_KP_MULTIPLY, - GHOSTTY_KEY_KP_SUBTRACT, - GHOSTTY_KEY_KP_ADD, - GHOSTTY_KEY_KP_ENTER, - GHOSTTY_KEY_KP_EQUAL, - GHOSTTY_KEY_KP_SEPARATOR, - GHOSTTY_KEY_KP_LEFT, - GHOSTTY_KEY_KP_RIGHT, - GHOSTTY_KEY_KP_UP, - GHOSTTY_KEY_KP_DOWN, - GHOSTTY_KEY_KP_PAGE_UP, - GHOSTTY_KEY_KP_PAGE_DOWN, - GHOSTTY_KEY_KP_HOME, - GHOSTTY_KEY_KP_END, - GHOSTTY_KEY_KP_INSERT, - GHOSTTY_KEY_KP_DELETE, - GHOSTTY_KEY_KP_BEGIN, - - // special keys - GHOSTTY_KEY_CONTEXT_MENU, - - // modifiers - GHOSTTY_KEY_LEFT_SHIFT, - GHOSTTY_KEY_LEFT_CONTROL, - GHOSTTY_KEY_LEFT_ALT, - GHOSTTY_KEY_LEFT_SUPER, - GHOSTTY_KEY_RIGHT_SHIFT, - GHOSTTY_KEY_RIGHT_CONTROL, - GHOSTTY_KEY_RIGHT_ALT, - GHOSTTY_KEY_RIGHT_SUPER, + // "Media Keys" § 3.6 + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, } ghostty_input_key_e; typedef struct { @@ -265,7 +303,6 @@ typedef struct { } ghostty_input_key_s; typedef enum { - GHOSTTY_TRIGGER_TRANSLATED, GHOSTTY_TRIGGER_PHYSICAL, GHOSTTY_TRIGGER_UNICODE, } ghostty_input_trigger_tag_e; diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 0be579122..942ca5973 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -5,12 +5,6 @@ import GhosttyKit extension Ghostty { // MARK: Keyboard Shortcuts - /// Returns the SwiftUI KeyEquivalent for a given key. Note that not all keys known by - /// Ghostty have a macOS equivalent since macOS doesn't allow all keys as equivalents. - static func keyEquivalent(key: ghostty_input_key_e) -> KeyEquivalent? { - return Self.keyToEquivalent[key] - } - /// Return the key equivalent for the given trigger. /// /// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible @@ -22,16 +16,11 @@ extension Ghostty { static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let key: KeyEquivalent switch (trigger.tag) { - case GHOSTTY_TRIGGER_TRANSLATED: - if let v = Ghostty.keyEquivalent(key: trigger.key.translated) { - key = v - } else { - return nil - } - case GHOSTTY_TRIGGER_PHYSICAL: - if let v = Ghostty.keyEquivalent(key: trigger.key.physical) { - key = v + // Only functional keys can be converted to a KeyboardShortcut. Other physical + // mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent. + if let equiv = Self.keyToEquivalent[trigger.key.physical] { + key = equiv } else { return nil } @@ -86,64 +75,11 @@ extension Ghostty { /// not all ghostty key enum values are represented here because not all of them can be /// mapped to a KeyEquivalent. static let keyToEquivalent: [ghostty_input_key_e : KeyEquivalent] = [ - // 0-9 - GHOSTTY_KEY_ZERO: "0", - GHOSTTY_KEY_ONE: "1", - GHOSTTY_KEY_TWO: "2", - GHOSTTY_KEY_THREE: "3", - GHOSTTY_KEY_FOUR: "4", - GHOSTTY_KEY_FIVE: "5", - GHOSTTY_KEY_SIX: "6", - GHOSTTY_KEY_SEVEN: "7", - GHOSTTY_KEY_EIGHT: "8", - GHOSTTY_KEY_NINE: "9", - - // a-z - GHOSTTY_KEY_A: "a", - GHOSTTY_KEY_B: "b", - GHOSTTY_KEY_C: "c", - GHOSTTY_KEY_D: "d", - GHOSTTY_KEY_E: "e", - GHOSTTY_KEY_F: "f", - GHOSTTY_KEY_G: "g", - GHOSTTY_KEY_H: "h", - GHOSTTY_KEY_I: "i", - GHOSTTY_KEY_J: "j", - GHOSTTY_KEY_K: "k", - GHOSTTY_KEY_L: "l", - GHOSTTY_KEY_M: "m", - GHOSTTY_KEY_N: "n", - GHOSTTY_KEY_O: "o", - GHOSTTY_KEY_P: "p", - GHOSTTY_KEY_Q: "q", - GHOSTTY_KEY_R: "r", - GHOSTTY_KEY_S: "s", - GHOSTTY_KEY_T: "t", - GHOSTTY_KEY_U: "u", - GHOSTTY_KEY_V: "v", - GHOSTTY_KEY_W: "w", - GHOSTTY_KEY_X: "x", - GHOSTTY_KEY_Y: "y", - GHOSTTY_KEY_Z: "z", - - // Symbols - GHOSTTY_KEY_APOSTROPHE: "'", - GHOSTTY_KEY_BACKSLASH: "\\", - GHOSTTY_KEY_COMMA: ",", - GHOSTTY_KEY_EQUAL: "=", - GHOSTTY_KEY_GRAVE_ACCENT: "`", - GHOSTTY_KEY_LEFT_BRACKET: "[", - GHOSTTY_KEY_MINUS: "-", - GHOSTTY_KEY_PERIOD: ".", - GHOSTTY_KEY_RIGHT_BRACKET: "]", - GHOSTTY_KEY_SEMICOLON: ";", - GHOSTTY_KEY_SLASH: "/", - // Function keys - GHOSTTY_KEY_UP: .upArrow, - GHOSTTY_KEY_DOWN: .downArrow, - GHOSTTY_KEY_LEFT: .leftArrow, - GHOSTTY_KEY_RIGHT: .rightArrow, + GHOSTTY_KEY_ARROW_UP: .upArrow, + GHOSTTY_KEY_ARROW_DOWN: .downArrow, + GHOSTTY_KEY_ARROW_LEFT: .leftArrow, + GHOSTTY_KEY_ARROW_RIGHT: .rightArrow, GHOSTTY_KEY_HOME: .home, GHOSTTY_KEY_END: .end, GHOSTTY_KEY_DELETE: .delete, @@ -153,104 +89,22 @@ extension Ghostty { GHOSTTY_KEY_ENTER: .return, GHOSTTY_KEY_TAB: .tab, GHOSTTY_KEY_BACKSPACE: .delete, - ] - - static let asciiToKey: [UInt8 : ghostty_input_key_e] = [ - // 0-9 - 0x30: GHOSTTY_KEY_ZERO, - 0x31: GHOSTTY_KEY_ONE, - 0x32: GHOSTTY_KEY_TWO, - 0x33: GHOSTTY_KEY_THREE, - 0x34: GHOSTTY_KEY_FOUR, - 0x35: GHOSTTY_KEY_FIVE, - 0x36: GHOSTTY_KEY_SIX, - 0x37: GHOSTTY_KEY_SEVEN, - 0x38: GHOSTTY_KEY_EIGHT, - 0x39: GHOSTTY_KEY_NINE, - - // A-Z - 0x41: GHOSTTY_KEY_A, - 0x42: GHOSTTY_KEY_B, - 0x43: GHOSTTY_KEY_C, - 0x44: GHOSTTY_KEY_D, - 0x45: GHOSTTY_KEY_E, - 0x46: GHOSTTY_KEY_F, - 0x47: GHOSTTY_KEY_G, - 0x48: GHOSTTY_KEY_H, - 0x49: GHOSTTY_KEY_I, - 0x4A: GHOSTTY_KEY_J, - 0x4B: GHOSTTY_KEY_K, - 0x4C: GHOSTTY_KEY_L, - 0x4D: GHOSTTY_KEY_M, - 0x4E: GHOSTTY_KEY_N, - 0x4F: GHOSTTY_KEY_O, - 0x50: GHOSTTY_KEY_P, - 0x51: GHOSTTY_KEY_Q, - 0x52: GHOSTTY_KEY_R, - 0x53: GHOSTTY_KEY_S, - 0x54: GHOSTTY_KEY_T, - 0x55: GHOSTTY_KEY_U, - 0x56: GHOSTTY_KEY_V, - 0x57: GHOSTTY_KEY_W, - 0x58: GHOSTTY_KEY_X, - 0x59: GHOSTTY_KEY_Y, - 0x5A: GHOSTTY_KEY_Z, - - // a-z - 0x61: GHOSTTY_KEY_A, - 0x62: GHOSTTY_KEY_B, - 0x63: GHOSTTY_KEY_C, - 0x64: GHOSTTY_KEY_D, - 0x65: GHOSTTY_KEY_E, - 0x66: GHOSTTY_KEY_F, - 0x67: GHOSTTY_KEY_G, - 0x68: GHOSTTY_KEY_H, - 0x69: GHOSTTY_KEY_I, - 0x6A: GHOSTTY_KEY_J, - 0x6B: GHOSTTY_KEY_K, - 0x6C: GHOSTTY_KEY_L, - 0x6D: GHOSTTY_KEY_M, - 0x6E: GHOSTTY_KEY_N, - 0x6F: GHOSTTY_KEY_O, - 0x70: GHOSTTY_KEY_P, - 0x71: GHOSTTY_KEY_Q, - 0x72: GHOSTTY_KEY_R, - 0x73: GHOSTTY_KEY_S, - 0x74: GHOSTTY_KEY_T, - 0x75: GHOSTTY_KEY_U, - 0x76: GHOSTTY_KEY_V, - 0x77: GHOSTTY_KEY_W, - 0x78: GHOSTTY_KEY_X, - 0x79: GHOSTTY_KEY_Y, - 0x7A: GHOSTTY_KEY_Z, - - // Symbols - 0x27: GHOSTTY_KEY_APOSTROPHE, - 0x5C: GHOSTTY_KEY_BACKSLASH, - 0x2C: GHOSTTY_KEY_COMMA, - 0x3D: GHOSTTY_KEY_EQUAL, - 0x60: GHOSTTY_KEY_GRAVE_ACCENT, - 0x5B: GHOSTTY_KEY_LEFT_BRACKET, - 0x2D: GHOSTTY_KEY_MINUS, - 0x2E: GHOSTTY_KEY_PERIOD, - 0x5D: GHOSTTY_KEY_RIGHT_BRACKET, - 0x3B: GHOSTTY_KEY_SEMICOLON, - 0x2F: GHOSTTY_KEY_SLASH, + GHOSTTY_KEY_SPACE: .space, ] // Mapping of event keyCode to ghostty input key values. This is cribbed from // glfw mostly since we started as a glfw-based app way back in the day! static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [ - 0x1D: GHOSTTY_KEY_ZERO, - 0x12: GHOSTTY_KEY_ONE, - 0x13: GHOSTTY_KEY_TWO, - 0x14: GHOSTTY_KEY_THREE, - 0x15: GHOSTTY_KEY_FOUR, - 0x17: GHOSTTY_KEY_FIVE, - 0x16: GHOSTTY_KEY_SIX, - 0x1A: GHOSTTY_KEY_SEVEN, - 0x1C: GHOSTTY_KEY_EIGHT, - 0x19: GHOSTTY_KEY_NINE, + 0x1D: GHOSTTY_KEY_DIGIT_0, + 0x12: GHOSTTY_KEY_DIGIT_1, + 0x13: GHOSTTY_KEY_DIGIT_2, + 0x14: GHOSTTY_KEY_DIGIT_3, + 0x15: GHOSTTY_KEY_DIGIT_4, + 0x17: GHOSTTY_KEY_DIGIT_5, + 0x16: GHOSTTY_KEY_DIGIT_6, + 0x1A: GHOSTTY_KEY_DIGIT_7, + 0x1C: GHOSTTY_KEY_DIGIT_8, + 0x19: GHOSTTY_KEY_DIGIT_9, 0x00: GHOSTTY_KEY_A, 0x0B: GHOSTTY_KEY_B, 0x08: GHOSTTY_KEY_C, @@ -278,22 +132,22 @@ extension Ghostty { 0x10: GHOSTTY_KEY_Y, 0x06: GHOSTTY_KEY_Z, - 0x27: GHOSTTY_KEY_APOSTROPHE, + 0x27: GHOSTTY_KEY_QUOTE, 0x2A: GHOSTTY_KEY_BACKSLASH, 0x2B: GHOSTTY_KEY_COMMA, 0x18: GHOSTTY_KEY_EQUAL, - 0x32: GHOSTTY_KEY_GRAVE_ACCENT, - 0x21: GHOSTTY_KEY_LEFT_BRACKET, + 0x32: GHOSTTY_KEY_BACKQUOTE, + 0x21: GHOSTTY_KEY_BRACKET_LEFT, 0x1B: GHOSTTY_KEY_MINUS, 0x2F: GHOSTTY_KEY_PERIOD, - 0x1E: GHOSTTY_KEY_RIGHT_BRACKET, + 0x1E: GHOSTTY_KEY_BRACKET_RIGHT, 0x29: GHOSTTY_KEY_SEMICOLON, 0x2C: GHOSTTY_KEY_SLASH, 0x33: GHOSTTY_KEY_BACKSPACE, 0x39: GHOSTTY_KEY_CAPS_LOCK, 0x75: GHOSTTY_KEY_DELETE, - 0x7D: GHOSTTY_KEY_DOWN, + 0x7D: GHOSTTY_KEY_ARROW_DOWN, 0x77: GHOSTTY_KEY_END, 0x24: GHOSTTY_KEY_ENTER, 0x35: GHOSTTY_KEY_ESCAPE, @@ -319,39 +173,39 @@ extension Ghostty { 0x5A: GHOSTTY_KEY_F20, 0x73: GHOSTTY_KEY_HOME, 0x72: GHOSTTY_KEY_INSERT, - 0x7B: GHOSTTY_KEY_LEFT, - 0x3A: GHOSTTY_KEY_LEFT_ALT, - 0x3B: GHOSTTY_KEY_LEFT_CONTROL, - 0x38: GHOSTTY_KEY_LEFT_SHIFT, - 0x37: GHOSTTY_KEY_LEFT_SUPER, + 0x7B: GHOSTTY_KEY_ARROW_LEFT, + 0x3A: GHOSTTY_KEY_ALT_LEFT, + 0x3B: GHOSTTY_KEY_CONTROL_LEFT, + 0x38: GHOSTTY_KEY_SHIFT_LEFT, + 0x37: GHOSTTY_KEY_META_LEFT, 0x47: GHOSTTY_KEY_NUM_LOCK, 0x79: GHOSTTY_KEY_PAGE_DOWN, 0x74: GHOSTTY_KEY_PAGE_UP, - 0x7C: GHOSTTY_KEY_RIGHT, - 0x3D: GHOSTTY_KEY_RIGHT_ALT, - 0x3E: GHOSTTY_KEY_RIGHT_CONTROL, - 0x3C: GHOSTTY_KEY_RIGHT_SHIFT, - 0x36: GHOSTTY_KEY_RIGHT_SUPER, + 0x7C: GHOSTTY_KEY_ARROW_RIGHT, + 0x3D: GHOSTTY_KEY_ALT_RIGHT, + 0x3E: GHOSTTY_KEY_CONTROL_RIGHT, + 0x3C: GHOSTTY_KEY_SHIFT_RIGHT, + 0x36: GHOSTTY_KEY_META_RIGHT, 0x31: GHOSTTY_KEY_SPACE, 0x30: GHOSTTY_KEY_TAB, - 0x7E: GHOSTTY_KEY_UP, + 0x7E: GHOSTTY_KEY_ARROW_UP, - 0x52: GHOSTTY_KEY_KP_0, - 0x53: GHOSTTY_KEY_KP_1, - 0x54: GHOSTTY_KEY_KP_2, - 0x55: GHOSTTY_KEY_KP_3, - 0x56: GHOSTTY_KEY_KP_4, - 0x57: GHOSTTY_KEY_KP_5, - 0x58: GHOSTTY_KEY_KP_6, - 0x59: GHOSTTY_KEY_KP_7, - 0x5B: GHOSTTY_KEY_KP_8, - 0x5C: GHOSTTY_KEY_KP_9, - 0x45: GHOSTTY_KEY_KP_ADD, - 0x41: GHOSTTY_KEY_KP_DECIMAL, - 0x4B: GHOSTTY_KEY_KP_DIVIDE, - 0x4C: GHOSTTY_KEY_KP_ENTER, - 0x51: GHOSTTY_KEY_KP_EQUAL, - 0x43: GHOSTTY_KEY_KP_MULTIPLY, - 0x4E: GHOSTTY_KEY_KP_SUBTRACT, + 0x52: GHOSTTY_KEY_NUMPAD_0, + 0x53: GHOSTTY_KEY_NUMPAD_1, + 0x54: GHOSTTY_KEY_NUMPAD_2, + 0x55: GHOSTTY_KEY_NUMPAD_3, + 0x56: GHOSTTY_KEY_NUMPAD_4, + 0x57: GHOSTTY_KEY_NUMPAD_5, + 0x58: GHOSTTY_KEY_NUMPAD_6, + 0x59: GHOSTTY_KEY_NUMPAD_7, + 0x5B: GHOSTTY_KEY_NUMPAD_8, + 0x5C: GHOSTTY_KEY_NUMPAD_9, + 0x45: GHOSTTY_KEY_NUMPAD_ADD, + 0x41: GHOSTTY_KEY_NUMPAD_DECIMAL, + 0x4B: GHOSTTY_KEY_NUMPAD_DIVIDE, + 0x4C: GHOSTTY_KEY_NUMPAD_ENTER, + 0x51: GHOSTTY_KEY_NUMPAD_EQUAL, + 0x43: GHOSTTY_KEY_NUMPAD_MULTIPLY, + 0x4E: GHOSTTY_KEY_NUMPAD_SUBTRACT, ]; } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c953300cd..cf3f73a55 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -92,41 +92,12 @@ pub const App = struct { // We want to get the physical unmapped key to process keybinds. const physical_key = keycode: for (input.keycodes.entries) |entry| { if (entry.native == self.keycode) break :keycode entry.key; - } else .invalid; - - // If the resulting text has length 1 then we can take its key - // and attempt to translate it to a key enum and call the key callback. - // If the length is greater than 1 then we're going to call the - // charCallback. - // - // We also only do key translation if this is not a dead key. - const key = if (!self.composing) key: { - // If our physical key is a keypad key, we use that. - if (physical_key.keypad()) break :key physical_key; - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (text.len > 0) { - if (input.Key.fromASCII(text[0])) |key| { - break :key key; - } - } - - // If the above doesn't work, we use the unmodified value. - if (std.math.cast(u8, unshifted_codepoint)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - break :key physical_key; - } else .invalid; + } else .unidentified; // Build our final key event return .{ .action = self.action, - .key = key, + .key = physical_key, .physical_key = physical_key, .mods = self.mods, .consumed_mods = self.consumed_mods, diff --git a/src/input/key.zig b/src/input/key.zig index b39c5e5d3..c0deea25c 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -270,7 +270,7 @@ pub const Action = enum(c_int) { /// UTF-8 and are produced by the associated apprt. Ghostty core has /// no mechanism to map input events to strings without the apprt. /// -/// IMPORTANT: Any changes here update include/ghostty.h +/// IMPORTANT: Any changes here update include/ghostty.h ghostty_input_key_e pub const Key = enum(c_int) { unidentified, @@ -618,61 +618,61 @@ pub const Key = enum(c_int) { /// Returns the cimgui key constant for this key. pub fn imguiKey(self: Key) ?c_uint { return switch (self) { - .a => cimgui.c.ImGuiKey_A, - .b => cimgui.c.ImGuiKey_B, - .c => cimgui.c.ImGuiKey_C, - .d => cimgui.c.ImGuiKey_D, - .e => cimgui.c.ImGuiKey_E, - .f => cimgui.c.ImGuiKey_F, - .g => cimgui.c.ImGuiKey_G, - .h => cimgui.c.ImGuiKey_H, - .i => cimgui.c.ImGuiKey_I, - .j => cimgui.c.ImGuiKey_J, - .k => cimgui.c.ImGuiKey_K, - .l => cimgui.c.ImGuiKey_L, - .m => cimgui.c.ImGuiKey_M, - .n => cimgui.c.ImGuiKey_N, - .o => cimgui.c.ImGuiKey_O, - .p => cimgui.c.ImGuiKey_P, - .q => cimgui.c.ImGuiKey_Q, - .r => cimgui.c.ImGuiKey_R, - .s => cimgui.c.ImGuiKey_S, - .t => cimgui.c.ImGuiKey_T, - .u => cimgui.c.ImGuiKey_U, - .v => cimgui.c.ImGuiKey_V, - .w => cimgui.c.ImGuiKey_W, - .x => cimgui.c.ImGuiKey_X, - .y => cimgui.c.ImGuiKey_Y, - .z => cimgui.c.ImGuiKey_Z, + .key_a => cimgui.c.ImGuiKey_A, + .key_b => cimgui.c.ImGuiKey_B, + .key_c => cimgui.c.ImGuiKey_C, + .key_d => cimgui.c.ImGuiKey_D, + .key_e => cimgui.c.ImGuiKey_E, + .key_f => cimgui.c.ImGuiKey_F, + .key_g => cimgui.c.ImGuiKey_G, + .key_h => cimgui.c.ImGuiKey_H, + .key_i => cimgui.c.ImGuiKey_I, + .key_j => cimgui.c.ImGuiKey_J, + .key_k => cimgui.c.ImGuiKey_K, + .key_l => cimgui.c.ImGuiKey_L, + .key_m => cimgui.c.ImGuiKey_M, + .key_n => cimgui.c.ImGuiKey_N, + .key_o => cimgui.c.ImGuiKey_O, + .key_p => cimgui.c.ImGuiKey_P, + .key_q => cimgui.c.ImGuiKey_Q, + .key_r => cimgui.c.ImGuiKey_R, + .key_s => cimgui.c.ImGuiKey_S, + .key_t => cimgui.c.ImGuiKey_T, + .key_u => cimgui.c.ImGuiKey_U, + .key_v => cimgui.c.ImGuiKey_V, + .key_w => cimgui.c.ImGuiKey_W, + .key_x => cimgui.c.ImGuiKey_X, + .key_y => cimgui.c.ImGuiKey_Y, + .key_z => cimgui.c.ImGuiKey_Z, - .zero => cimgui.c.ImGuiKey_0, - .one => cimgui.c.ImGuiKey_1, - .two => cimgui.c.ImGuiKey_2, - .three => cimgui.c.ImGuiKey_3, - .four => cimgui.c.ImGuiKey_4, - .five => cimgui.c.ImGuiKey_5, - .six => cimgui.c.ImGuiKey_6, - .seven => cimgui.c.ImGuiKey_7, - .eight => cimgui.c.ImGuiKey_8, - .nine => cimgui.c.ImGuiKey_9, + .digit_0 => cimgui.c.ImGuiKey_0, + .digit_1 => cimgui.c.ImGuiKey_1, + .digit_2 => cimgui.c.ImGuiKey_2, + .digit_3 => cimgui.c.ImGuiKey_3, + .digit_4 => cimgui.c.ImGuiKey_4, + .digit_5 => cimgui.c.ImGuiKey_5, + .digit_6 => cimgui.c.ImGuiKey_6, + .digit_7 => cimgui.c.ImGuiKey_7, + .digit_8 => cimgui.c.ImGuiKey_8, + .digit_9 => cimgui.c.ImGuiKey_9, .semicolon => cimgui.c.ImGuiKey_Semicolon, .space => cimgui.c.ImGuiKey_Space, - .apostrophe => cimgui.c.ImGuiKey_Apostrophe, + .quote => cimgui.c.ImGuiKey_Apostrophe, .comma => cimgui.c.ImGuiKey_Comma, - .grave_accent => cimgui.c.ImGuiKey_GraveAccent, + .backquote => cimgui.c.ImGuiKey_GraveAccent, .period => cimgui.c.ImGuiKey_Period, .slash => cimgui.c.ImGuiKey_Slash, .minus => cimgui.c.ImGuiKey_Minus, .equal => cimgui.c.ImGuiKey_Equal, - .left_bracket => cimgui.c.ImGuiKey_LeftBracket, - .right_bracket => cimgui.c.ImGuiKey_RightBracket, + .bracket_left => cimgui.c.ImGuiKey_LeftBracket, + .bracket_right => cimgui.c.ImGuiKey_RightBracket, .backslash => cimgui.c.ImGuiKey_Backslash, - .up => cimgui.c.ImGuiKey_UpArrow, - .down => cimgui.c.ImGuiKey_DownArrow, - .left => cimgui.c.ImGuiKey_LeftArrow, - .right => cimgui.c.ImGuiKey_RightArrow, + .arrow_up => cimgui.c.ImGuiKey_UpArrow, + .arrow_down => cimgui.c.ImGuiKey_DownArrow, + .arrow_left => cimgui.c.ImGuiKey_LeftArrow, + .arrow_right => cimgui.c.ImGuiKey_RightArrow, .home => cimgui.c.ImGuiKey_Home, .end => cimgui.c.ImGuiKey_End, .insert => cimgui.c.ImGuiKey_Insert, @@ -703,48 +703,47 @@ pub const Key = enum(c_int) { .f11 => cimgui.c.ImGuiKey_F11, .f12 => cimgui.c.ImGuiKey_F12, - .kp_0 => cimgui.c.ImGuiKey_Keypad0, - .kp_1 => cimgui.c.ImGuiKey_Keypad1, - .kp_2 => cimgui.c.ImGuiKey_Keypad2, - .kp_3 => cimgui.c.ImGuiKey_Keypad3, - .kp_4 => cimgui.c.ImGuiKey_Keypad4, - .kp_5 => cimgui.c.ImGuiKey_Keypad5, - .kp_6 => cimgui.c.ImGuiKey_Keypad6, - .kp_7 => cimgui.c.ImGuiKey_Keypad7, - .kp_8 => cimgui.c.ImGuiKey_Keypad8, - .kp_9 => cimgui.c.ImGuiKey_Keypad9, - .kp_decimal => cimgui.c.ImGuiKey_KeypadDecimal, - .kp_divide => cimgui.c.ImGuiKey_KeypadDivide, - .kp_multiply => cimgui.c.ImGuiKey_KeypadMultiply, - .kp_subtract => cimgui.c.ImGuiKey_KeypadSubtract, - .kp_add => cimgui.c.ImGuiKey_KeypadAdd, - .kp_enter => cimgui.c.ImGuiKey_KeypadEnter, - .kp_equal => cimgui.c.ImGuiKey_KeypadEqual, + .numpad_0 => cimgui.c.ImGuiKey_Keypad0, + .numpad_1 => cimgui.c.ImGuiKey_Keypad1, + .numpad_2 => cimgui.c.ImGuiKey_Keypad2, + .numpad_3 => cimgui.c.ImGuiKey_Keypad3, + .numpad_4 => cimgui.c.ImGuiKey_Keypad4, + .numpad_5 => cimgui.c.ImGuiKey_Keypad5, + .numpad_6 => cimgui.c.ImGuiKey_Keypad6, + .numpad_7 => cimgui.c.ImGuiKey_Keypad7, + .numpad_8 => cimgui.c.ImGuiKey_Keypad8, + .numpad_9 => cimgui.c.ImGuiKey_Keypad9, + .numpad_decimal => cimgui.c.ImGuiKey_KeypadDecimal, + .numpad_divide => cimgui.c.ImGuiKey_KeypadDivide, + .numpad_multiply => cimgui.c.ImGuiKey_KeypadMultiply, + .numpad_subtract => cimgui.c.ImGuiKey_KeypadSubtract, + .numpad_add => cimgui.c.ImGuiKey_KeypadAdd, + .numpad_enter => cimgui.c.ImGuiKey_KeypadEnter, + .numpad_equal => cimgui.c.ImGuiKey_KeypadEqual, // We map KP_SEPARATOR to Comma because traditionally a numpad would // have a numeric separator key. Most modern numpads do not - .kp_separator => cimgui.c.ImGuiKey_Comma, - .kp_left => cimgui.c.ImGuiKey_LeftArrow, - .kp_right => cimgui.c.ImGuiKey_RightArrow, - .kp_up => cimgui.c.ImGuiKey_UpArrow, - .kp_down => cimgui.c.ImGuiKey_DownArrow, - .kp_page_up => cimgui.c.ImGuiKey_PageUp, - .kp_page_down => cimgui.c.ImGuiKey_PageUp, - .kp_home => cimgui.c.ImGuiKey_Home, - .kp_end => cimgui.c.ImGuiKey_End, - .kp_insert => cimgui.c.ImGuiKey_Insert, - .kp_delete => cimgui.c.ImGuiKey_Delete, - .kp_begin => cimgui.c.ImGuiKey_NamedKey_BEGIN, + .numpad_left => cimgui.c.ImGuiKey_LeftArrow, + .numpad_right => cimgui.c.ImGuiKey_RightArrow, + .numpad_up => cimgui.c.ImGuiKey_UpArrow, + .numpad_down => cimgui.c.ImGuiKey_DownArrow, + .numpad_page_up => cimgui.c.ImGuiKey_PageUp, + .numpad_page_down => cimgui.c.ImGuiKey_PageUp, + .numpad_home => cimgui.c.ImGuiKey_Home, + .numpad_end => cimgui.c.ImGuiKey_End, + .numpad_insert => cimgui.c.ImGuiKey_Insert, + .numpad_delete => cimgui.c.ImGuiKey_Delete, + .numpad_begin => cimgui.c.ImGuiKey_NamedKey_BEGIN, - .left_shift => cimgui.c.ImGuiKey_LeftShift, - .left_control => cimgui.c.ImGuiKey_LeftCtrl, - .left_alt => cimgui.c.ImGuiKey_LeftAlt, - .left_super => cimgui.c.ImGuiKey_LeftSuper, - .right_shift => cimgui.c.ImGuiKey_RightShift, - .right_control => cimgui.c.ImGuiKey_RightCtrl, - .right_alt => cimgui.c.ImGuiKey_RightAlt, - .right_super => cimgui.c.ImGuiKey_RightSuper, + .shift_left => cimgui.c.ImGuiKey_LeftShift, + .control_left => cimgui.c.ImGuiKey_LeftCtrl, + .alt_left => cimgui.c.ImGuiKey_LeftAlt, + .meta_left => cimgui.c.ImGuiKey_LeftSuper, + .shift_right => cimgui.c.ImGuiKey_RightShift, + .control_right => cimgui.c.ImGuiKey_RightCtrl, + .alt_right => cimgui.c.ImGuiKey_RightAlt, + .meta_right => cimgui.c.ImGuiKey_RightSuper, - .invalid, + // These keys aren't represented in cimgui .f13, .f14, .f15, @@ -758,9 +757,51 @@ pub const Key = enum(c_int) { .f23, .f24, .f25, + .intl_backslash, + .intl_ro, + .intl_yen, + .convert, + .kana_mode, + .non_convert, + .numpad_backspace, + .numpad_clear, + .numpad_clear_entry, + .numpad_comma, + .numpad_memory_add, + .numpad_memory_clear, + .numpad_memory_recall, + .numpad_memory_store, + .numpad_memory_subtract, + .numpad_paren_left, + .numpad_paren_right, + .@"fn", + .fn_lock, + .browser_back, + .browser_favorites, + .browser_forward, + .browser_home, + .browser_refresh, + .browser_search, + .browser_stop, + .eject, + .launch_app_1, + .launch_app_2, + .launch_mail, + .media_play_pause, + .media_select, + .media_stop, + .media_track_next, + .media_track_previous, + .power, + .sleep, + .audio_volume_down, + .audio_volume_mute, + .audio_volume_up, + .wake_up, + .help, + => null, - // These keys aren't represented in cimgui - .plus, + .unidentified, => null, }; } diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index e9adbc156..fa95ff206 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -19,7 +19,7 @@ pub const entries: []const Entry = entries: { for (raw_entries, 0..) |raw, i| { @setEvalBranchQuota(10000); result[i] = .{ - .key = code_to_key.get(raw[5]) orelse .invalid, + .key = code_to_key.get(raw[5]) orelse .unidentified, .usb = raw[0], .code = raw[5], .native = raw[native_idx], @@ -45,42 +45,42 @@ pub const Entry = struct { const code_to_key = code_to_key: { @setEvalBranchQuota(5000); break :code_to_key std.StaticStringMap(Key).initComptime(.{ - .{ "KeyA", .a }, - .{ "KeyB", .b }, - .{ "KeyC", .c }, - .{ "KeyD", .d }, - .{ "KeyE", .e }, - .{ "KeyF", .f }, - .{ "KeyG", .g }, - .{ "KeyH", .h }, - .{ "KeyI", .i }, - .{ "KeyJ", .j }, - .{ "KeyK", .k }, - .{ "KeyL", .l }, - .{ "KeyM", .m }, - .{ "KeyN", .n }, - .{ "KeyO", .o }, - .{ "KeyP", .p }, - .{ "KeyQ", .q }, - .{ "KeyR", .r }, - .{ "KeyS", .s }, - .{ "KeyT", .t }, - .{ "KeyU", .u }, - .{ "KeyV", .v }, - .{ "KeyW", .w }, - .{ "KeyX", .x }, - .{ "KeyY", .y }, - .{ "KeyZ", .z }, - .{ "Digit1", .one }, - .{ "Digit2", .two }, - .{ "Digit3", .three }, - .{ "Digit4", .four }, - .{ "Digit5", .five }, - .{ "Digit6", .six }, - .{ "Digit7", .seven }, - .{ "Digit8", .eight }, - .{ "Digit9", .nine }, - .{ "Digit0", .zero }, + .{ "KeyA", .key_a }, + .{ "KeyB", .key_b }, + .{ "KeyC", .key_c }, + .{ "KeyD", .key_d }, + .{ "KeyE", .key_e }, + .{ "KeyF", .key_f }, + .{ "KeyG", .key_g }, + .{ "KeyH", .key_h }, + .{ "KeyI", .key_i }, + .{ "KeyJ", .key_j }, + .{ "KeyK", .key_k }, + .{ "KeyL", .key_l }, + .{ "KeyM", .key_m }, + .{ "KeyN", .key_n }, + .{ "KeyO", .key_o }, + .{ "KeyP", .key_p }, + .{ "KeyQ", .key_q }, + .{ "KeyR", .key_r }, + .{ "KeyS", .key_s }, + .{ "KeyT", .key_t }, + .{ "KeyU", .key_u }, + .{ "KeyV", .key_v }, + .{ "KeyW", .key_w }, + .{ "KeyX", .key_x }, + .{ "KeyY", .key_y }, + .{ "KeyZ", .key_z }, + .{ "Digit1", .digit_1 }, + .{ "Digit2", .digit_2 }, + .{ "Digit3", .digit_3 }, + .{ "Digit4", .digit_4 }, + .{ "Digit5", .digit_5 }, + .{ "Digit6", .digit_6 }, + .{ "Digit7", .digit_7 }, + .{ "Digit8", .digit_8 }, + .{ "Digit9", .digit_9 }, + .{ "Digit0", .digit_0 }, .{ "Enter", .enter }, .{ "Escape", .escape }, .{ "Backspace", .backspace }, @@ -88,12 +88,12 @@ const code_to_key = code_to_key: { .{ "Space", .space }, .{ "Minus", .minus }, .{ "Equal", .equal }, - .{ "BracketLeft", .left_bracket }, - .{ "BracketRight", .right_bracket }, + .{ "BracketLeft", .bracket_left }, + .{ "BracketRight", .bracket_left }, .{ "Backslash", .backslash }, .{ "Semicolon", .semicolon }, - .{ "Quote", .apostrophe }, - .{ "Backquote", .grave_accent }, + .{ "Quote", .quote }, + .{ "Backquote", .backquote }, .{ "Comma", .comma }, .{ "Period", .period }, .{ "Slash", .slash }, @@ -131,37 +131,37 @@ const code_to_key = code_to_key: { .{ "Delete", .delete }, .{ "End", .end }, .{ "PageDown", .page_down }, - .{ "ArrowRight", .right }, - .{ "ArrowLeft", .left }, - .{ "ArrowDown", .down }, - .{ "ArrowUp", .up }, + .{ "ArrowRight", .arrow_right }, + .{ "ArrowLeft", .arrow_left }, + .{ "ArrowDown", .arrow_down }, + .{ "ArrowUp", .arrow_up }, .{ "NumLock", .num_lock }, - .{ "NumpadDivide", .kp_divide }, - .{ "NumpadMultiply", .kp_multiply }, - .{ "NumpadSubtract", .kp_subtract }, - .{ "NumpadAdd", .kp_add }, - .{ "NumpadEnter", .kp_enter }, - .{ "Numpad1", .kp_1 }, - .{ "Numpad2", .kp_2 }, - .{ "Numpad3", .kp_3 }, - .{ "Numpad4", .kp_4 }, - .{ "Numpad5", .kp_5 }, - .{ "Numpad6", .kp_6 }, - .{ "Numpad7", .kp_7 }, - .{ "Numpad8", .kp_8 }, - .{ "Numpad9", .kp_9 }, - .{ "Numpad0", .kp_0 }, - .{ "NumpadDecimal", .kp_decimal }, - .{ "NumpadEqual", .kp_equal }, + .{ "NumpadDivide", .numpad_divide }, + .{ "NumpadMultiply", .numpad_multiply }, + .{ "NumpadSubtract", .numpad_subtract }, + .{ "NumpadAdd", .numpad_add }, + .{ "NumpadEnter", .numpad_enter }, + .{ "Numpad1", .numpad_1 }, + .{ "Numpad2", .numpad_2 }, + .{ "Numpad3", .numpad_3 }, + .{ "Numpad4", .numpad_4 }, + .{ "Numpad5", .numpad_5 }, + .{ "Numpad6", .numpad_6 }, + .{ "Numpad7", .numpad_7 }, + .{ "Numpad8", .numpad_8 }, + .{ "Numpad9", .numpad_9 }, + .{ "Numpad0", .numpad_0 }, + .{ "NumpadDecimal", .numpad_decimal }, + .{ "NumpadEqual", .numpad_equal }, .{ "ContextMenu", .context_menu }, - .{ "ControlLeft", .left_control }, - .{ "ShiftLeft", .left_shift }, - .{ "AltLeft", .left_alt }, - .{ "MetaLeft", .left_super }, - .{ "ControlRight", .right_control }, - .{ "ShiftRight", .right_shift }, - .{ "AltRight", .right_alt }, - .{ "MetaRight", .right_super }, + .{ "ControlLeft", .control_left }, + .{ "ShiftLeft", .shift_left }, + .{ "AltLeft", .alt_left }, + .{ "MetaLeft", .meta_left }, + .{ "ControlRight", .control_right }, + .{ "ShiftRight", .shift_right }, + .{ "AltRight", .alt_right }, + .{ "MetaRight", .meta_right }, }); }; From ffdf86374a964dba28ca2998e914c0eece13a394 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 11:18:03 -0700 Subject: [PATCH 013/245] apprt/gtk: build --- include/ghostty.h | 1 + src/apprt/gtk/Surface.zig | 54 +---------- src/apprt/gtk/key.zig | 184 +++++++++++++++++++------------------- src/config/Config.zig | 43 ++++----- src/input/key.zig | 2 + 5 files changed, 118 insertions(+), 166 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 600396a84..2734fc368 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -222,6 +222,7 @@ typedef enum { GHOSTTY_KEY_NUMPAD_PAREN_LEFT, GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, GHOSTTY_KEY_NUMPAD_UP, GHOSTTY_KEY_NUMPAD_DOWN, GHOSTTY_KEY_NUMPAD_RIGHT, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7ff96480e..a04372494 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1840,7 +1840,7 @@ pub fn keyEvent( // (These are keybinds explicitly marked as requesting physical mapping). const physical_key = keycode: for (input.keycodes.entries) |entry| { if (entry.native == keycode) break :keycode entry.key; - } else .invalid; + } else .unidentified; // Get our modifier for the event const mods: input.Mods = gtk_key.eventMods( @@ -1861,52 +1861,6 @@ pub fn keyEvent( break :consumed gtk_key.translateMods(@bitCast(masked)); }; - // If we're not in a dead key state, we want to translate our text - // to some input.Key. - const key = if (!self.im_composing) key: { - // First, try to convert the keyval directly to a key. This allows the - // use of key remapping and identification of keypad numerics (as - // opposed to their ASCII counterparts) - if (gtk_key.keyFromKeyval(keyval)) |key| { - break :key key; - } - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (self.im_len > 0) { - if (input.Key.fromASCII(self.im_buf[0])) |key| { - break :key key; - } - } - - // If that doesn't work then we try to translate the kevval.. - if (keyval_unicode != 0) { - if (std.math.cast(u8, keyval_unicode)) |byte| { - if (input.Key.fromASCII(byte)) |key| { - break :key key; - } - } - } - - // If that doesn't work we use the unshifted value... - if (std.math.cast(u8, keyval_unicode_unshifted)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - // If we have im text then this is invalid. This means that - // the keypress generated some character that we don't know about - // in our key enum. We don't want to use the physical key because - // it can be simply wrong. For example on "Turkish Q" the "i" key - // on a US layout results in "ı" which is not the same as "i" so - // we shouldn't use the physical key. - if (self.im_len > 0 or keyval_unicode_unshifted != 0) break :key .invalid; - - break :key physical_key; - } else .invalid; - // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ // key, // keyval, @@ -1936,7 +1890,7 @@ pub fn keyEvent( // Invoke the core Ghostty logic to handle this input. const effect = self.core_surface.keyCallback(.{ .action = action, - .key = key, + .key = physical_key, .physical_key = physical_key, .mods = mods, .consumed_mods = consumed_mods, @@ -2088,8 +2042,8 @@ fn gtkInputCommit( // invalid key, which should produce no PTY encoding). _ = self.core_surface.keyCallback(.{ .action = .press, - .key = .invalid, - .physical_key = .invalid, + .key = .unidentified, + .physical_key = .unidentified, .mods = .{}, .consumed_mods = .{}, .composing = false, diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 2e00552a6..b3330eb40 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -21,7 +21,7 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u // Write our key switch (trigger.key) { - .physical, .translated => |k| { + .physical => |k| { const keyval = keyvalFromKey(k) orelse return null; try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return null)); }, @@ -122,42 +122,42 @@ pub fn eventMods( // if only the modifier key is pressed, but our core logic // relies on it. switch (physical_key) { - .left_shift => { + .shift_left => { mods.shift = action != .release; mods.sides.shift = .left; }, - .right_shift => { + .shift_right => { mods.shift = action != .release; mods.sides.shift = .right; }, - .left_control => { + .control_left => { mods.ctrl = action != .release; mods.sides.ctrl = .left; }, - .right_control => { + .control_right => { mods.ctrl = action != .release; mods.sides.ctrl = .right; }, - .left_alt => { + .alt_left => { mods.alt = action != .release; mods.sides.alt = .left; }, - .right_alt => { + .alt_right => { mods.alt = action != .release; mods.sides.alt = .right; }, - .left_super => { + .meta_left => { mods.super = action != .release; mods.sides.super = .left; }, - .right_super => { + .meta_right => { mods.super = action != .release; mods.sides.super = .right; }, @@ -182,7 +182,7 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint { switch (key) { inline else => |key_comptime| { return comptime value: { - @setEvalBranchQuota(10_000); + @setEvalBranchQuota(50_000); for (keymap) |entry| { if (entry[1] == key_comptime) break :value entry[0]; } @@ -199,7 +199,7 @@ test "accelFromTrigger" { try testing.expectEqualStrings("q", (try accelFromTrigger(&buf, .{ .mods = .{ .super = true }, - .key = .{ .translated = .q }, + .key = .{ .unicode = 'q' }, })).?); try testing.expectEqualStrings("backslash", (try accelFromTrigger(&buf, .{ @@ -213,61 +213,61 @@ test "accelFromTrigger" { const RawEntry = struct { c_uint, input.Key }; const keymap: []const RawEntry = &.{ - .{ gdk.KEY_a, .a }, - .{ gdk.KEY_b, .b }, - .{ gdk.KEY_c, .c }, - .{ gdk.KEY_d, .d }, - .{ gdk.KEY_e, .e }, - .{ gdk.KEY_f, .f }, - .{ gdk.KEY_g, .g }, - .{ gdk.KEY_h, .h }, - .{ gdk.KEY_i, .i }, - .{ gdk.KEY_j, .j }, - .{ gdk.KEY_k, .k }, - .{ gdk.KEY_l, .l }, - .{ gdk.KEY_m, .m }, - .{ gdk.KEY_n, .n }, - .{ gdk.KEY_o, .o }, - .{ gdk.KEY_p, .p }, - .{ gdk.KEY_q, .q }, - .{ gdk.KEY_r, .r }, - .{ gdk.KEY_s, .s }, - .{ gdk.KEY_t, .t }, - .{ gdk.KEY_u, .u }, - .{ gdk.KEY_v, .v }, - .{ gdk.KEY_w, .w }, - .{ gdk.KEY_x, .x }, - .{ gdk.KEY_y, .y }, - .{ gdk.KEY_z, .z }, + .{ gdk.KEY_a, .key_a }, + .{ gdk.KEY_b, .key_b }, + .{ gdk.KEY_c, .key_c }, + .{ gdk.KEY_d, .key_d }, + .{ gdk.KEY_e, .key_e }, + .{ gdk.KEY_f, .key_f }, + .{ gdk.KEY_g, .key_g }, + .{ gdk.KEY_h, .key_h }, + .{ gdk.KEY_i, .key_i }, + .{ gdk.KEY_j, .key_j }, + .{ gdk.KEY_k, .key_k }, + .{ gdk.KEY_l, .key_l }, + .{ gdk.KEY_m, .key_m }, + .{ gdk.KEY_n, .key_n }, + .{ gdk.KEY_o, .key_o }, + .{ gdk.KEY_p, .key_p }, + .{ gdk.KEY_q, .key_q }, + .{ gdk.KEY_r, .key_r }, + .{ gdk.KEY_s, .key_s }, + .{ gdk.KEY_t, .key_t }, + .{ gdk.KEY_u, .key_u }, + .{ gdk.KEY_v, .key_v }, + .{ gdk.KEY_w, .key_w }, + .{ gdk.KEY_x, .key_x }, + .{ gdk.KEY_y, .key_y }, + .{ gdk.KEY_z, .key_z }, - .{ gdk.KEY_0, .zero }, - .{ gdk.KEY_1, .one }, - .{ gdk.KEY_2, .two }, - .{ gdk.KEY_3, .three }, - .{ gdk.KEY_4, .four }, - .{ gdk.KEY_5, .five }, - .{ gdk.KEY_6, .six }, - .{ gdk.KEY_7, .seven }, - .{ gdk.KEY_8, .eight }, - .{ gdk.KEY_9, .nine }, + .{ gdk.KEY_0, .digit_0 }, + .{ gdk.KEY_1, .digit_1 }, + .{ gdk.KEY_2, .digit_2 }, + .{ gdk.KEY_3, .digit_3 }, + .{ gdk.KEY_4, .digit_4 }, + .{ gdk.KEY_5, .digit_5 }, + .{ gdk.KEY_6, .digit_6 }, + .{ gdk.KEY_7, .digit_7 }, + .{ gdk.KEY_8, .digit_8 }, + .{ gdk.KEY_9, .digit_9 }, .{ gdk.KEY_semicolon, .semicolon }, .{ gdk.KEY_space, .space }, - .{ gdk.KEY_apostrophe, .apostrophe }, + .{ gdk.KEY_apostrophe, .quote }, .{ gdk.KEY_comma, .comma }, - .{ gdk.KEY_grave, .grave_accent }, + .{ gdk.KEY_grave, .backquote }, .{ gdk.KEY_period, .period }, .{ gdk.KEY_slash, .slash }, .{ gdk.KEY_minus, .minus }, .{ gdk.KEY_equal, .equal }, - .{ gdk.KEY_bracketleft, .left_bracket }, - .{ gdk.KEY_bracketright, .right_bracket }, + .{ gdk.KEY_bracketleft, .bracket_left }, + .{ gdk.KEY_bracketright, .bracket_right }, .{ gdk.KEY_backslash, .backslash }, - .{ gdk.KEY_Up, .up }, - .{ gdk.KEY_Down, .down }, - .{ gdk.KEY_Right, .right }, - .{ gdk.KEY_Left, .left }, + .{ gdk.KEY_Up, .arrow_up }, + .{ gdk.KEY_Down, .arrow_down }, + .{ gdk.KEY_Right, .arrow_right }, + .{ gdk.KEY_Left, .arrow_left }, .{ gdk.KEY_Home, .home }, .{ gdk.KEY_End, .end }, .{ gdk.KEY_Insert, .insert }, @@ -310,45 +310,45 @@ const keymap: []const RawEntry = &.{ .{ gdk.KEY_F24, .f24 }, .{ gdk.KEY_F25, .f25 }, - .{ gdk.KEY_KP_0, .kp_0 }, - .{ gdk.KEY_KP_1, .kp_1 }, - .{ gdk.KEY_KP_2, .kp_2 }, - .{ gdk.KEY_KP_3, .kp_3 }, - .{ gdk.KEY_KP_4, .kp_4 }, - .{ gdk.KEY_KP_5, .kp_5 }, - .{ gdk.KEY_KP_6, .kp_6 }, - .{ gdk.KEY_KP_7, .kp_7 }, - .{ gdk.KEY_KP_8, .kp_8 }, - .{ gdk.KEY_KP_9, .kp_9 }, - .{ gdk.KEY_KP_Decimal, .kp_decimal }, - .{ gdk.KEY_KP_Divide, .kp_divide }, - .{ gdk.KEY_KP_Multiply, .kp_multiply }, - .{ gdk.KEY_KP_Subtract, .kp_subtract }, - .{ gdk.KEY_KP_Add, .kp_add }, - .{ gdk.KEY_KP_Enter, .kp_enter }, - .{ gdk.KEY_KP_Equal, .kp_equal }, + .{ gdk.KEY_KP_0, .numpad_0 }, + .{ gdk.KEY_KP_1, .numpad_1 }, + .{ gdk.KEY_KP_2, .numpad_2 }, + .{ gdk.KEY_KP_3, .numpad_3 }, + .{ gdk.KEY_KP_4, .numpad_4 }, + .{ gdk.KEY_KP_5, .numpad_5 }, + .{ gdk.KEY_KP_6, .numpad_6 }, + .{ gdk.KEY_KP_7, .numpad_7 }, + .{ gdk.KEY_KP_8, .numpad_8 }, + .{ gdk.KEY_KP_9, .numpad_9 }, + .{ gdk.KEY_KP_Decimal, .numpad_decimal }, + .{ gdk.KEY_KP_Divide, .numpad_divide }, + .{ gdk.KEY_KP_Multiply, .numpad_multiply }, + .{ gdk.KEY_KP_Subtract, .numpad_subtract }, + .{ gdk.KEY_KP_Add, .numpad_add }, + .{ gdk.KEY_KP_Enter, .numpad_enter }, + .{ gdk.KEY_KP_Equal, .numpad_equal }, - .{ gdk.KEY_KP_Separator, .kp_separator }, - .{ gdk.KEY_KP_Left, .kp_left }, - .{ gdk.KEY_KP_Right, .kp_right }, - .{ gdk.KEY_KP_Up, .kp_up }, - .{ gdk.KEY_KP_Down, .kp_down }, - .{ gdk.KEY_KP_Page_Up, .kp_page_up }, - .{ gdk.KEY_KP_Page_Down, .kp_page_down }, - .{ gdk.KEY_KP_Home, .kp_home }, - .{ gdk.KEY_KP_End, .kp_end }, - .{ gdk.KEY_KP_Insert, .kp_insert }, - .{ gdk.KEY_KP_Delete, .kp_delete }, - .{ gdk.KEY_KP_Begin, .kp_begin }, + .{ gdk.KEY_KP_Separator, .numpad_separator }, + .{ gdk.KEY_KP_Left, .numpad_left }, + .{ gdk.KEY_KP_Right, .numpad_right }, + .{ gdk.KEY_KP_Up, .numpad_up }, + .{ gdk.KEY_KP_Down, .numpad_down }, + .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, + .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, + .{ gdk.KEY_KP_Home, .numpad_home }, + .{ gdk.KEY_KP_End, .numpad_end }, + .{ gdk.KEY_KP_Insert, .numpad_insert }, + .{ gdk.KEY_KP_Delete, .numpad_delete }, + .{ gdk.KEY_KP_Begin, .numpad_begin }, - .{ gdk.KEY_Shift_L, .left_shift }, - .{ gdk.KEY_Control_L, .left_control }, - .{ gdk.KEY_Alt_L, .left_alt }, - .{ gdk.KEY_Super_L, .left_super }, - .{ gdk.KEY_Shift_R, .right_shift }, - .{ gdk.KEY_Control_R, .right_control }, - .{ gdk.KEY_Alt_R, .right_alt }, - .{ gdk.KEY_Super_R, .right_super }, + .{ gdk.KEY_Shift_L, .shift_left }, + .{ gdk.KEY_Control_L, .control_left }, + .{ gdk.KEY_Alt_L, .alt_left }, + .{ gdk.KEY_Super_L, .meta_left }, + .{ gdk.KEY_Shift_R, .shift_right }, + .{ gdk.KEY_Control_R, .control_right }, + .{ gdk.KEY_Alt_R, .alt_right }, + .{ gdk.KEY_Super_R, .meta_right }, // TODO: media keys }; diff --git a/src/config/Config.zig b/src/config/Config.zig index 7d2814136..2a34f9c80 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4495,17 +4495,17 @@ pub const Keybinds = struct { if (comptime !builtin.target.os.tag.isDarwin()) { try self.set.put( alloc, - .{ .key = .{ .physical = .n }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'n' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_window = {} }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_surface = {} }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .q }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'q' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .quit = {} }, ); try self.set.put( @@ -4515,22 +4515,22 @@ pub const Keybinds = struct { ); try self.set.put( alloc, - .{ .key = .{ .physical = .t }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 't' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .left }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .right }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, ); try self.set.put( @@ -4545,12 +4545,12 @@ pub const Keybinds = struct { ); try self.set.put( alloc, - .{ .key = .{ .physical = .o }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'o' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .right }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .e }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); try self.set.put( @@ -4565,51 +4565,46 @@ pub const Keybinds = struct { ); try self.set.put( alloc, - .{ .key = .{ .physical = .up }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .down }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .left }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .right }, .mods = .{ .ctrl = true, .alt = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, ); // Resizing splits try self.set.put( alloc, - .{ .key = .{ .physical = .up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, + .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, ); - try self.set.put( - alloc, - .{ .key = .{ .physical = .plus }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, - .{ .equalize_splits = {} }, - ); // Viewport scrolling try self.set.put( @@ -4648,14 +4643,14 @@ pub const Keybinds = struct { // Inspector, matching Chromium try self.set.put( alloc, - .{ .key = .{ .physical = .i }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .unicode = 'i' }, .mods = .{ .shift = true, .ctrl = true } }, .{ .inspector = .toggle }, ); // Terminal try self.set.put( alloc, - .{ .key = .{ .physical = .a }, .mods = .{ .shift = true, .ctrl = true } }, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .shift = true, .ctrl = true } }, .{ .select_all = {} }, ); diff --git a/src/input/key.zig b/src/input/key.zig index c0deea25c..7e770b332 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -398,6 +398,7 @@ pub const Key = enum(c_int) { // These numpad entries are distinguished by various encoding protocols // (legacy and Kitty) so we support them here in case the apprt can // produce them. + numpad_separator, numpad_up, numpad_down, numpad_right, @@ -763,6 +764,7 @@ pub const Key = enum(c_int) { .convert, .kana_mode, .non_convert, + .numpad_separator, .numpad_backspace, .numpad_clear, .numpad_clear_entry, From 7983e0d62ce2a57cdcfc2c88659f019e999ffde5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 12:30:00 -0700 Subject: [PATCH 014/245] input: backwards compatibility --- NOTES.md | 3 - src/input/Binding.zig | 179 +++++++++++++++++++++++++++++++++++++++++- src/input/key.zig | 58 -------------- 3 files changed, 176 insertions(+), 64 deletions(-) delete mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index 8e4937bd4..000000000 --- a/NOTES.md +++ /dev/null @@ -1,3 +0,0 @@ -- key backwards compatibility, e.g. `grave_accent` -- `physical:` backwards compatibility? - diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 30575bc30..805a3726a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1143,14 +1143,15 @@ pub const Trigger = struct { } } + // Anything after this point is a key and we only support + // single keys. + if (!result.isKeyUnset()) return Error.InvalidFormat; + // Check if its a key const keysInfo = @typeInfo(key.Key).@"enum"; inline for (keysInfo.fields) |field| { if (!std.mem.eql(u8, field.name, "unidentified")) { if (std.mem.eql(u8, part, field.name)) { - // Repeat not allowed - if (!result.isKeyUnset()) return Error.InvalidFormat; - const keyval = @field(key.Key, field.name); result.key = .{ .physical = keyval }; continue :loop; @@ -1173,12 +1174,141 @@ pub const Trigger = struct { continue :loop; } + // If we're still unset then we look for backwards compatible + // keys with Ghostty 1.1.x. We do this last so its least likely + // to impact performance for modern users. + if (backwards_compatible_keys.get(part)) |old_key| { + result.key = old_key; + continue :loop; + } + // We didn't recognize this value return Error.InvalidFormat; } return result; } + + /// The values that are backwards compatible with Ghostty 1.1.x. + /// Ghostty 1.2+ doesn't support these anymore since we moved to + /// W3C key codes. + const backwards_compatible_keys = std.StaticStringMap(Key).initComptime(.{ + .{ "zero", Key{ .unicode = '0' } }, + .{ "one", Key{ .unicode = '1' } }, + .{ "two", Key{ .unicode = '2' } }, + .{ "three", Key{ .unicode = '3' } }, + .{ "four", Key{ .unicode = '4' } }, + .{ "five", Key{ .unicode = '5' } }, + .{ "six", Key{ .unicode = '6' } }, + .{ "seven", Key{ .unicode = '7' } }, + .{ "eight", Key{ .unicode = '8' } }, + .{ "nine", Key{ .unicode = '9' } }, + .{ "apostrophe", Key{ .unicode = '\'' } }, + .{ "grave_accent", Key{ .physical = .backquote } }, + .{ "left_bracket", Key{ .physical = .bracket_left } }, + .{ "right_bracket", Key{ .physical = .bracket_right } }, + .{ "up", Key{ .physical = .arrow_up } }, + .{ "down", Key{ .physical = .arrow_down } }, + .{ "left", Key{ .physical = .arrow_left } }, + .{ "right", Key{ .physical = .arrow_right } }, + .{ "kp_0", Key{ .physical = .numpad_0 } }, + .{ "kp_1", Key{ .physical = .numpad_1 } }, + .{ "kp_2", Key{ .physical = .numpad_2 } }, + .{ "kp_3", Key{ .physical = .numpad_3 } }, + .{ "kp_4", Key{ .physical = .numpad_4 } }, + .{ "kp_5", Key{ .physical = .numpad_5 } }, + .{ "kp_6", Key{ .physical = .numpad_6 } }, + .{ "kp_7", Key{ .physical = .numpad_7 } }, + .{ "kp_8", Key{ .physical = .numpad_8 } }, + .{ "kp_9", Key{ .physical = .numpad_9 } }, + .{ "kp_add", Key{ .physical = .numpad_add } }, + .{ "kp_subtract", Key{ .physical = .numpad_subtract } }, + .{ "kp_multiply", Key{ .physical = .numpad_multiply } }, + .{ "kp_divide", Key{ .physical = .numpad_divide } }, + .{ "kp_decimal", Key{ .physical = .numpad_decimal } }, + .{ "kp_enter", Key{ .physical = .numpad_enter } }, + .{ "kp_equal", Key{ .physical = .numpad_equal } }, + .{ "kp_separator", Key{ .physical = .numpad_separator } }, + .{ "kp_left", Key{ .physical = .numpad_left } }, + .{ "kp_right", Key{ .physical = .numpad_right } }, + .{ "kp_up", Key{ .physical = .numpad_up } }, + .{ "kp_down", Key{ .physical = .numpad_down } }, + .{ "kp_page_up", Key{ .physical = .numpad_page_up } }, + .{ "kp_page_down", Key{ .physical = .numpad_page_down } }, + .{ "kp_home", Key{ .physical = .numpad_home } }, + .{ "kp_end", Key{ .physical = .numpad_end } }, + .{ "kp_insert", Key{ .physical = .numpad_insert } }, + .{ "kp_delete", Key{ .physical = .numpad_delete } }, + .{ "kp_begin", Key{ .physical = .numpad_begin } }, + .{ "left_shift", Key{ .physical = .shift_left } }, + .{ "right_shift", Key{ .physical = .shift_right } }, + .{ "left_control", Key{ .physical = .control_left } }, + .{ "right_control", Key{ .physical = .control_right } }, + .{ "left_alt", Key{ .physical = .alt_left } }, + .{ "right_alt", Key{ .physical = .alt_right } }, + .{ "left_super", Key{ .physical = .meta_left } }, + .{ "right_super", Key{ .physical = .meta_right } }, + + // Physical variants. This is a blunt approach to this but its + // glue for backwards compatibility so I'm not too worried about + // making this super nice. + .{ "physical:zero", Key{ .physical = .digit_0 } }, + .{ "physical:one", Key{ .physical = .digit_1 } }, + .{ "physical:two", Key{ .physical = .digit_2 } }, + .{ "physical:three", Key{ .physical = .digit_3 } }, + .{ "physical:four", Key{ .physical = .digit_4 } }, + .{ "physical:five", Key{ .physical = .digit_5 } }, + .{ "physical:six", Key{ .physical = .digit_6 } }, + .{ "physical:seven", Key{ .physical = .digit_7 } }, + .{ "physical:eight", Key{ .physical = .digit_8 } }, + .{ "physical:nine", Key{ .physical = .digit_9 } }, + .{ "physical:apostrophe", Key{ .physical = .quote } }, + .{ "physical:grave_accent", Key{ .physical = .backquote } }, + .{ "physical:left_bracket", Key{ .physical = .bracket_left } }, + .{ "physical:right_bracket", Key{ .physical = .bracket_right } }, + .{ "physical:up", Key{ .physical = .arrow_up } }, + .{ "physical:down", Key{ .physical = .arrow_down } }, + .{ "physical:left", Key{ .physical = .arrow_left } }, + .{ "physical:right", Key{ .physical = .arrow_right } }, + .{ "physical:kp_0", Key{ .physical = .numpad_0 } }, + .{ "physical:kp_1", Key{ .physical = .numpad_1 } }, + .{ "physical:kp_2", Key{ .physical = .numpad_2 } }, + .{ "physical:kp_3", Key{ .physical = .numpad_3 } }, + .{ "physical:kp_4", Key{ .physical = .numpad_4 } }, + .{ "physical:kp_5", Key{ .physical = .numpad_5 } }, + .{ "physical:kp_6", Key{ .physical = .numpad_6 } }, + .{ "physical:kp_7", Key{ .physical = .numpad_7 } }, + .{ "physical:kp_8", Key{ .physical = .numpad_8 } }, + .{ "physical:kp_9", Key{ .physical = .numpad_9 } }, + .{ "physical:kp_add", Key{ .physical = .numpad_add } }, + .{ "physical:kp_subtract", Key{ .physical = .numpad_subtract } }, + .{ "physical:kp_multiply", Key{ .physical = .numpad_multiply } }, + .{ "physical:kp_divide", Key{ .physical = .numpad_divide } }, + .{ "physical:kp_decimal", Key{ .physical = .numpad_decimal } }, + .{ "physical:kp_enter", Key{ .physical = .numpad_enter } }, + .{ "physical:kp_equal", Key{ .physical = .numpad_equal } }, + .{ "physical:kp_separator", Key{ .physical = .numpad_separator } }, + .{ "physical:kp_left", Key{ .physical = .numpad_left } }, + .{ "physical:kp_right", Key{ .physical = .numpad_right } }, + .{ "physical:kp_up", Key{ .physical = .numpad_up } }, + .{ "physical:kp_down", Key{ .physical = .numpad_down } }, + .{ "physical:kp_page_up", Key{ .physical = .numpad_page_up } }, + .{ "physical:kp_page_down", Key{ .physical = .numpad_page_down } }, + .{ "physical:kp_home", Key{ .physical = .numpad_home } }, + .{ "physical:kp_end", Key{ .physical = .numpad_end } }, + .{ "physical:kp_insert", Key{ .physical = .numpad_insert } }, + .{ "physical:kp_delete", Key{ .physical = .numpad_delete } }, + .{ "physical:kp_begin", Key{ .physical = .numpad_begin } }, + .{ "physical:left_shift", Key{ .physical = .shift_left } }, + .{ "physical:right_shift", Key{ .physical = .shift_right } }, + .{ "physical:left_control", Key{ .physical = .control_left } }, + .{ "physical:right_control", Key{ .physical = .control_right } }, + .{ "physical:left_alt", Key{ .physical = .alt_left } }, + .{ "physical:right_alt", Key{ .physical = .alt_right } }, + .{ "physical:left_super", Key{ .physical = .meta_left } }, + .{ "physical:right_super", Key{ .physical = .meta_right } }, + }); + /// Returns true if this trigger has no key set. pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { @@ -1808,6 +1938,49 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); } +// For Ghostty 1.2+ we changed our key names to match the W3C and removed +// `physical:`. This tests the backwards compatibility with the old format. +// Note that our backwards compatibility isn't 100% perfect since triggers +// like `a` now map to unicode instead of "translated" (which was also +// removed). But we did our best here with what was unambiguous. +test "parse: backwards compatibility with <= 1.1.x" { + const testing = std.testing; + + // simple, for sanity + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '0' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("zero=ignore"), + ); + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .physical = .digit_0 } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("physical:zero=ignore"), + ); + + // duplicates + try testing.expectError(Error.InvalidFormat, parseSingle("zero+one=ignore")); + + // test our full map + for ( + Trigger.backwards_compatible_keys.keys(), + Trigger.backwards_compatible_keys.values(), + ) |k, v| { + var buf: [128]u8 = undefined; + try testing.expectEqual( + Binding{ + .trigger = .{ .key = v }, + .action = .{ .ignore = {} }, + }, + try parseSingle(try std.fmt.bufPrint(&buf, "{s}=ignore", .{k})), + ); + } +} + test "parse: global triggers" { const testing = std.testing; diff --git a/src/input/key.zig b/src/input/key.zig index 7e770b332..961d4cefe 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -468,64 +468,6 @@ pub const Key = enum(c_int) { audio_volume_up, wake_up, - // Backwards compatibility for Ghostty 1.1.x and earlier, we don't - // want to force people to rewrite their configs. - // pub const zero = .digit_0; - // pub const one = .digit_1; - // pub const two = .digit_2; - // pub const three = .digit_3; - // pub const four = .digit_4; - // pub const five = .digit_5; - // pub const six = .digit_6; - // pub const seven = .digit_7; - // pub const eight = .digit_8; - // pub const nine = .digit_9; - // pub const apostrophe = .quote; - // pub const grave_accent = .backquote; - // pub const left_bracket = .bracket_left; - // pub const right_bracket = .bracket_right; - // pub const up = .arrow_up; - // pub const down = .arrow_down; - // pub const left = .arrow_left; - // pub const right = .arrow_right; - // pub const kp_0 = .numpad_0; - // pub const kp_1 = .numpad_1; - // pub const kp_2 = .numpad_2; - // pub const kp_3 = .numpad_3; - // pub const kp_4 = .numpad_4; - // pub const kp_5 = .numpad_5; - // pub const kp_6 = .numpad_6; - // pub const kp_7 = .numpad_7; - // pub const kp_8 = .numpad_8; - // pub const kp_9 = .numpad_9; - // pub const kp_decimal = .numpad_decimal; - // pub const kp_divide = .numpad_divide; - // pub const kp_multiply = .numpad_multiply; - // pub const kp_subtract = .numpad_subtract; - // pub const kp_add = .numpad_add; - // pub const kp_enter = .numpad_enter; - // pub const kp_equal = .numpad_equal; - // pub const kp_separator = .numpad_separator; - // pub const kp_left = .numpad_left; - // pub const kp_right = .numpad_right; - // pub const kp_up = .numpad_up; - // pub const kp_down = .numpad_down; - // pub const kp_page_up = .numpad_page_up; - // pub const kp_page_down = .numpad_page_down; - // pub const kp_home = .numpad_home; - // pub const kp_end = .numpad_end; - // pub const kp_insert = .numpad_insert; - // pub const kp_delete = .numpad_delete; - // pub const kp_begin = .numpad_begin; - // pub const left_shift = .shift_left; - // pub const right_shift = .shift_right; - // pub const left_control = .control_left; - // pub const right_control = .control_right; - // pub const left_alt = .alt_left; - // pub const right_alt = .alt_right; - // pub const left_super = .meta_left; - // pub const right_super = .meta_right; - /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. /// From 1e76222f19eceae25e9a496324a6fb6afb36e8d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 12:44:34 -0700 Subject: [PATCH 015/245] update docs --- src/config/Config.zig | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2a34f9c80..d154109a6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -929,12 +929,23 @@ class: ?[:0]const u8 = null, /// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, /// `ctrl+shift+b`, `up`. /// -/// Valid keys are currently only listed in the -/// [Ghostty source code](https://github.com/ghostty-org/ghostty/blob/d6e76858164d52cff460fedc61ddf2e560912d71/src/input/key.zig#L255). -/// This is a documentation limitation and we will improve this in the future. -/// A common gotcha is that numeric keys are written as words: e.g. `one`, -/// `two`, `three`, etc. and not `1`, `2`, `3`. This will also be improved in -/// the future. +/// If the key is a single Unicode codepoint, the trigger will match +/// any presses that produce that codepoint. These are impacted by +/// keyboard layouts. For example, `a` will match the `a` key on a +/// QWERTY keyboard, but will match the `q` key on a AZERTY keyboard +/// (assuming US physical layout). +/// +/// Physical key codes can be specified by using any of the key codes +/// as specified by the [W3C specification](https://www.w3.org/TR/uievents-code/). +/// For example, `KeyA` will match the physical `a` key on a US standard +/// keyboard regardless of the keyboard layout. +/// +/// Function keys such as `insert`, `up`, `f5`, etc. are also specified +/// using the keys as specified by the previously linked W3C specification. +/// +/// Physical keys always match with a higher priority than Unicode codepoints, +/// so if you specify both `a` and `KeyA`, the physical key will always be used +/// regardless of what order they are configured. /// /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, /// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier @@ -954,11 +965,6 @@ class: ?[:0]const u8 = null, /// /// * only a single key input is allowed, `ctrl+a+b` is invalid. /// -/// * the key input can be prefixed with `physical:` to specify a -/// physical key mapping rather than a logical one. A physical key -/// mapping responds to the hardware keycode and not the keycode -/// translated by any system keyboard layouts. Example: "ctrl+physical:a" -/// /// You may also specify multiple triggers separated by `>` to require a /// sequence of triggers to activate the action. For example, /// `ctrl+a>n=new_window` will only trigger the `new_window` action if the From cc748305fb7364594baf1e81aa5c7cf63c3a17b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 14:14:09 -0700 Subject: [PATCH 016/245] input: w3c names for keys --- src/input/Binding.zig | 22 ++++++++++++ src/input/key.zig | 80 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 805a3726a..e6347ab9d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1174,6 +1174,12 @@ pub const Trigger = struct { continue :loop; } + // Look for a matching w3c name next. + if (key.Key.fromW3C(part)) |w3c_key| { + result.key = .{ .physical = w3c_key }; + continue :loop; + } + // If we're still unset then we look for backwards compatible // keys with Ghostty 1.1.x. We do this last so its least likely // to impact performance for modern users. @@ -1938,6 +1944,22 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); } +test "parse: w3c key names" { + const testing = std.testing; + + // Exact match + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .physical = .key_a } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("KeyA=ignore"), + ); + + // Case-sensitive + try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); +} + // For Ghostty 1.2+ we changed our key names to match the W3C and removed // `physical:`. This tests the backwards compatibility with the old format. // Note that our backwards compatibility isn't 100% perfect since triggers diff --git a/src/input/key.zig b/src/input/key.zig index 961d4cefe..d9ef284d1 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -497,6 +497,74 @@ pub const Key = enum(c_int) { }; } + /// Converts a W3C key code to a Ghostty key enum value. + /// + /// All required W3C key codes are supported, but there are a number of + /// non-standard key codes that are not supported. In the case the value is + /// invalid or unsupported, this function will return null. + pub fn fromW3C(code: []const u8) ?Key { + var result: [128]u8 = undefined; + + // If the code is bigger than our buffer it can't possibly match. + if (code.len > result.len) return null; + + // First just check the whole thing lowercased, this is the simple case + if (std.meta.stringToEnum( + Key, + std.ascii.lowerString(&result, code), + )) |key| return key; + + // We need to convert FooBar to foo_bar + var fbs = std.io.fixedBufferStream(&result); + const w = fbs.writer(); + for (code, 0..) |ch, i| switch (ch) { + 'a'...'z' => w.writeByte(ch) catch return null, + + // Caps and numbers trigger underscores + 'A'...'Z', '0'...'9' => { + if (i > 0) w.writeByte('_') catch return null; + w.writeByte(std.ascii.toLower(ch)) catch return null; + }, + + // We don't know of any key codes that aren't alphanumeric. + else => return null, + }; + + return std.meta.stringToEnum(Key, fbs.getWritten()); + } + + /// Converts a Ghostty key enum value to a W3C key code. + pub fn w3c(self: Key) []const u8 { + return switch (self) { + inline else => |tag| comptime w3c: { + @setEvalBranchQuota(50_000); + + const name = @tagName(tag); + + var buf: [128]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const w = fbs.writer(); + var i: usize = 0; + while (i < name.len) { + if (i == 0) { + w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; + } else if (name[i] == '_') { + i += 1; + w.writeByte(std.ascii.toUpper(name[i])) catch unreachable; + } else { + w.writeByte(name[i]) catch unreachable; + } + + i += 1; + } + + const written = buf; + const result = written[0..fbs.getWritten().len]; + break :w3c result; + }, + }; + } + /// True if this key represents a printable character. pub fn printable(self: Key) bool { return switch (self) { @@ -781,6 +849,18 @@ pub const Key = enum(c_int) { try testing.expect(!Key.digit_1.keypad()); } + test "w3c" { + // All our keys should convert to and from the W3C format. + // We don't support every key in the W3C spec, so we only + // check the enum fields. + const testing = std.testing; + inline for (@typeInfo(Key).@"enum".fields) |field| { + const key = @field(Key, field.name); + const w3c_name = key.w3c(); + try testing.expectEqual(key, Key.fromW3C(w3c_name).?); + } + } + const codepoint_map: []const struct { u21, Key } = &.{ .{ 'a', .key_a }, .{ 'b', .key_b }, From 11a623aa17536d3dd48abb223e3e8639a65d0b12 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 14:32:38 -0700 Subject: [PATCH 017/245] docs --- src/config/Config.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index d154109a6..251dca147 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -938,7 +938,14 @@ class: ?[:0]const u8 = null, /// Physical key codes can be specified by using any of the key codes /// as specified by the [W3C specification](https://www.w3.org/TR/uievents-code/). /// For example, `KeyA` will match the physical `a` key on a US standard -/// keyboard regardless of the keyboard layout. +/// keyboard regardless of the keyboard layout. These are case-sensitive. +/// +/// For aesthetic reasons, the w3c codes also support snake case. For +/// example, `key_a` is equivalent to `KeyA`. The only exceptions are +/// function keys, e.g. `F1` is `f1` (no underscore). This is a consequence +/// of our internal code using snake case but is purposely supported +/// and tested so it is safe to use. It allows an all-lowercase binding +/// which I find more aesthetically pleasing. /// /// Function keys such as `insert`, `up`, `f5`, etc. are also specified /// using the keys as specified by the previously linked W3C specification. From 5962696c3b0f42f863b91f6995d2d41e35dcc550 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 20:53:09 -0700 Subject: [PATCH 018/245] input: remove `physical_key` from the key event (all keys are physical) --- src/Surface.zig | 2 +- src/apprt/embedded.zig | 1 - src/apprt/glfw.zig | 1 - src/apprt/gtk/Surface.zig | 2 -- src/input/Binding.zig | 2 +- src/input/KeyEncoder.zig | 1 - src/input/key.zig | 11 ++++------- src/inspector/key.zig | 7 ------- 8 files changed, 6 insertions(+), 21 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index e173d2d8b..a71c180ff 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1870,7 +1870,7 @@ pub fn keyCallback( // Process the cursor state logic. This will update the cursor shape if // needed, depending on the key state. if ((SurfaceMouse{ - .physical_key = event.physical_key, + .physical_key = event.key, .mouse_event = self.io.terminal.flags.mouse_event, .mouse_shape = self.io.terminal.mouse_shape, .mods = self.mouse.mods, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index cf3f73a55..7bc84bcad 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -98,7 +98,6 @@ pub const App = struct { return .{ .action = self.action, .key = physical_key, - .physical_key = physical_key, .mods = self.mods, .consumed_mods = self.consumed_mods, .composing = self.composing, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 763933b91..e416d5645 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -1108,7 +1108,6 @@ pub const Surface = struct { const key_event: input.KeyEvent = .{ .action = action, .key = key, - .physical_key = key, .mods = mods, .consumed_mods = .{}, .composing = false, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index a04372494..0a9f644b7 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1891,7 +1891,6 @@ pub fn keyEvent( const effect = self.core_surface.keyCallback(.{ .action = action, .key = physical_key, - .physical_key = physical_key, .mods = mods, .consumed_mods = consumed_mods, .composing = self.im_composing, @@ -2043,7 +2042,6 @@ fn gtkInputCommit( _ = self.core_surface.keyCallback(.{ .action = .press, .key = .unidentified, - .physical_key = .unidentified, .mods = .{}, .consumed_mods = .{}, .composing = false, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e6347ab9d..2c53fb49f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1731,7 +1731,7 @@ pub const Set = struct { pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry { var trigger: Trigger = .{ .mods = event.mods.binding(), - .key = .{ .physical = event.physical_key }, + .key = .{ .physical = event.key }, }; if (self.get(trigger)) |v| return v; diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 3d43a4e86..7f9972779 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -2190,7 +2190,6 @@ test "legacy: hu layout ctrl+ő sends proper codepoint" { var enc: KeyEncoder = .{ .event = .{ .key = .bracket_left, - .physical_key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "ő", .unshifted_codepoint = 337, diff --git a/src/input/key.zig b/src/input/key.zig index d9ef284d1..831c9f07f 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -16,12 +16,10 @@ pub const KeyEvent = struct { /// The action: press, release, etc. action: Action = .press, - /// "key" is the logical key that was pressed. For example, if - /// a Dvorak keyboard layout is being used on a US keyboard, - /// the "i" physical key will be reported as "c". The physical - /// key is the key that was physically pressed on the keyboard. - key: Key, - physical_key: Key = .unidentified, + /// The keycode of the physical key that was pressed. This is agnostic + /// to the layout. Layout-dependent matching can only be done via the + /// UTF-8 or unshifted codepoint. + key: Key = .unidentified, /// Mods are the modifiers that are pressed. mods: Mods = .{}, @@ -63,7 +61,6 @@ pub const KeyEvent = struct { // These are all the fields that are explicitly part of Trigger. std.hash.autoHash(&hasher, self.key); - std.hash.autoHash(&hasher, self.physical_key); std.hash.autoHash(&hasher, self.unshifted_codepoint); std.hash.autoHash(&hasher, self.mods.binding()); diff --git a/src/inspector/key.zig b/src/inspector/key.zig index 10626d6bd..dbccb47a8 100644 --- a/src/inspector/key.zig +++ b/src/inspector/key.zig @@ -117,13 +117,6 @@ pub const Event = struct { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText("%s", @tagName(self.event.key).ptr); } - if (self.event.physical_key != self.event.key) { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Physical Key"); - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(self.event.physical_key).ptr); - } if (!self.event.mods.empty()) { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); _ = cimgui.c.igTableSetColumnIndex(0); From d015efc87d59f873e2ae7a5df8fa432cd5db2bdc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 21:08:00 -0700 Subject: [PATCH 019/245] clean up bindings so that they match macOS menus --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 16 ++++++++++------ src/Surface.zig | 2 ++ src/config/Config.zig | 8 ++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 921c32c8b..af9895c35 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1043,12 +1043,16 @@ extension Ghostty { } // If this event as-is would result in a key binding then we send it. - if let surface, - ghostty_surface_key_is_binding( - surface, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { - self.keyDown(with: event) - return true + if let surface { + var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + let match = (event.characters ?? "").withCString { ptr in + ghosttyEvent.text = ptr + return ghostty_surface_key_is_binding(surface, ghosttyEvent) + } + if match { + self.keyDown(with: event) + return true + } } let equivalent: String diff --git a/src/Surface.zig b/src/Surface.zig index a71c180ff..138cd4839 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1761,6 +1761,8 @@ pub fn keyEventIsBinding( // sequences) or the root set. const set = self.keyboard.bindings orelse &self.config.keybind.set; + // log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null }); + // If we have a keybinding for this event then we return true. return set.getEvent(event) != null; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 251dca147..0ec61d4c5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4410,23 +4410,23 @@ pub const Keybinds = struct { // set the expected keybind for the menu. try self.set.put( alloc, - .{ .key = .{ .unicode = '+' }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .physical = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '+' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .minus }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '-' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .decrease_font_size = 1 }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .digit_0 }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '0' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .reset_font_size = {} }, ); From 5dc88bda6a71d952209ff0fc02c4a1bae2e1384a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 May 2025 21:22:26 -0700 Subject: [PATCH 020/245] macOS: send proper UTF-8 text for more key events --- macos/Sources/Ghostty/NSEvent+Extension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift index 058e7aace..a5aec3870 100644 --- a/macos/Sources/Ghostty/NSEvent+Extension.swift +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -65,6 +65,6 @@ extension NSEvent { return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) } - return nil + return characters } } From 54bd701ba973bee77163408b6438d97e98b5ff5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 May 2025 07:22:04 -0700 Subject: [PATCH 021/245] input: bindings should match on single-codepoint utf-8 text too --- src/input/Binding.zig | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2c53fb49f..d1fcabb1b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1735,6 +1735,23 @@ pub const Set = struct { }; if (self.get(trigger)) |v| return v; + // If our UTF-8 text is exactly one codepoint, we try to match that. + if (event.utf8.len > 0) unicode: { + const view = std.unicode.Utf8View.init(event.utf8) catch break :unicode; + var it = view.iterator(); + + // No codepoints or multiple codepoints drops to invalid format + const cp = it.nextCodepoint() orelse break :unicode; + if (it.nextCodepoint() != null) break :unicode; + + trigger.key = .{ .unicode = cp }; + if (self.get(trigger)) |v| return v; + } + + // Finally fallback to the full unshifted codepoint if we have one. + // Question: should we be doing this if we have UTF-8 text? I + // suspect "no" but we don't currently have any failing scenarios + // to verify this. if (event.unshifted_codepoint > 0) { trigger.key = .{ .unicode = event.unshifted_codepoint }; if (self.get(trigger)) |v| return v; @@ -2660,6 +2677,76 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } +test "set: getEvent physical" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+quote=new_window"); + + // Physical matches on physical + { + const action = s.getEvent(.{ + .key = .quote, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Physical does not match on UTF8/codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "'", + .unshifted_codepoint = '\'', + }); + try testing.expect(action == null); + } +} + +test "set: getEvent codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+'=new_window"); + + // Matches on codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = '\'', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Matches on UTF-8 + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "'", + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Doesn't match on physical + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }); + try testing.expect(action == null); + } +} + test "Action: clone" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); From 293a67cd01a1fdb8a131c4591c078f49aa6dd3d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 May 2025 10:16:10 -0700 Subject: [PATCH 022/245] input: control-encode right control properly --- src/input/KeyEncoder.zig | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 7f9972779..5dfcf7ff5 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -571,7 +571,9 @@ fn ctrlSeq( if (!mods.ctrl) return null; const char, const unset_mods = unset_mods: { - var unset_mods = mods; + // We need to only get binding modifiers so we strip lock + // keys, sides, etc. + var unset_mods = mods.binding(); // Remove alt from our modifiers because it does not impact whether // we are generating a ctrl sequence and we handle the ESC-prefix @@ -640,7 +642,7 @@ fn ctrlSeq( // only matches Kitty in behavior. But I believe this is a // justified divergence because it's a useful distinction. - break :unset_mods .{ char, unset_mods.binding() }; + break :unset_mods .{ char, unset_mods }; }; // After unsetting, we only continue if we have ONLY control set. @@ -2280,3 +2282,11 @@ test "ctrlseq: russian alt ctrl c" { const seq = ctrlSeq(.key_c, "с", 0x0441, .{ .ctrl = true, .alt = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } + +test "ctrlseq: right ctrl c" { + const seq = ctrlSeq(.key_c, "с", 'c', .{ + .ctrl = true, + .sides = .{ .ctrl = .right }, + }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} From a26310e83f13f8f0db9d4c3321e2927a3b8be9e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 May 2025 10:23:50 -0700 Subject: [PATCH 023/245] macOS: app key is binding check should include utf-8 chars --- macos/Sources/App/macOS/AppDelegate.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a3a3185d9..c5d63f55d 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -469,17 +469,22 @@ class AppDelegate: NSObject, guard NSApp.mainWindow == nil else { return event } // If this event as-is would result in a key binding then we send it. - if let app = ghostty.app, - ghostty_app_key_is_binding( - app, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + if let app = ghostty.app { + var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + let match = (event.characters ?? "").withCString { ptr in + ghosttyEvent.text = ptr + if !ghostty_app_key_is_binding(app, ghosttyEvent) { + return false + } + + return ghostty_app_key(app, ghosttyEvent) + } + // If the key was handled by Ghostty we stop the event chain. If // the key wasn't handled then we let it fall through and continue // processing. This is important because some bindings may have no // affect at this scope. - if (ghostty_app_key( - app, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { + if match { return nil } } From ebabdb322c911f190e547473b681b3daccbb7373 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 May 2025 10:58:50 -0700 Subject: [PATCH 024/245] input: ignore control characters for backspace/enter/escape special case --- src/input/KeyEncoder.zig | 46 ++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 5dfcf7ff5..9e68dae2d 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -103,11 +103,15 @@ fn kitty( // and UTF8 text we just send it directly since we assume that is // whats happening. See legacy()'s similar logic for more details // on how to verify this. - if (self.event.utf8.len > 0) { + if (self.event.utf8.len > 0) utf8: { switch (self.event.key) { - .enter => return try copyToBuf(buf, self.event.utf8), - .backspace => return "", else => {}, + inline .enter, .backspace => |tag| { + // See legacy for why we handle this this way. + if (isControlUtf8(self.event.utf8)) break :utf8; + if (comptime tag == .backspace) return ""; + return try copyToBuf(buf, self.event.utf8); + }, } } @@ -272,11 +276,21 @@ fn legacy( // - Korean: escape commits the dead key state // - Korean: backspace should delete a single preedit char // - if (self.event.utf8.len > 0) { + if (self.event.utf8.len > 0) utf8: { switch (self.event.key) { else => {}, - .backspace => return "", - .enter, .escape => break :pc_style, + inline .backspace, .enter, .escape => |tag| { + // We want to ignore control characters. This is because + // some apprts (macOS) will send control characters as + // UTF-8 encodings and we handle that manually. + if (isControlUtf8(self.event.utf8)) break :utf8; + + // Backspace encodes nothing because we modified IME. + // Enter/escape don't encode the PC-style encoding + // because we want to encode committed text. + if (comptime tag == .backspace) return ""; + break :pc_style; + }, } } @@ -712,6 +726,12 @@ fn isControl(cp: u21) bool { return cp < 0x20 or cp == 0x7F; } +/// Returns true if this string is comprised of a single +/// control character. This returns false for multi-byte strings. +fn isControlUtf8(str: []const u8) bool { + return str.len == 1 and isControl(@intCast(str[0])); +} + /// This is the bitmask for fixterm CSI u modifiers. const CsiUMods = packed struct(u3) { shift: bool = false, @@ -2234,6 +2254,20 @@ test "legacy: super and other mods on macOS with text" { try testing.expectEqualStrings("", actual); } +test "legacy: backspace with DEL utf8" { + var buf: [128]u8 = undefined; + var enc: KeyEncoder = .{ + .event = .{ + .key = .backspace, + .utf8 = &.{0x7F}, + .unshifted_codepoint = 0x08, + }, + }; + + const actual = try enc.legacy(&buf); + try testing.expectEqualStrings("\x7F", actual); +} + test "ctrlseq: normal ctrl c" { const seq = ctrlSeq(.unidentified, "c", 'c', .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); From ca2ead9647b0c5cc5fad59033ae192714b2aae89 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 May 2025 11:09:22 -0700 Subject: [PATCH 025/245] input: kitty add missing numpad keycodes since we support those now --- src/input/kitty.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/input/kitty.zig b/src/input/kitty.zig index be397b84b..7ebbd7757 100644 --- a/src/input/kitty.zig +++ b/src/input/kitty.zig @@ -106,6 +106,18 @@ const raw_entries: []const RawEntry = &.{ .{ .numpad_add, 57413, 'u', false }, .{ .numpad_enter, 57414, 'u', false }, .{ .numpad_equal, 57415, 'u', false }, + .{ .numpad_separator, 57416, 'u', false }, + .{ .numpad_left, 57417, 'u', false }, + .{ .numpad_right, 57418, 'u', false }, + .{ .numpad_up, 57419, 'u', false }, + .{ .numpad_down, 57420, 'u', false }, + .{ .numpad_page_up, 57421, 'u', false }, + .{ .numpad_page_down, 57422, 'u', false }, + .{ .numpad_home, 57423, 'u', false }, + .{ .numpad_end, 57424, 'u', false }, + .{ .numpad_insert, 57425, 'u', false }, + .{ .numpad_delete, 57426, 'u', false }, + .{ .numpad_begin, 57427, 'u', false }, .{ .shift_left, 57441, 'u', true }, .{ .shift_right, 57447, 'u', true }, From 1752edd9ebc42ead4ad20b922993412e9d3e9038 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 May 2025 07:22:20 -0700 Subject: [PATCH 026/245] input: implement case folding for binding matching --- src/input/Binding.zig | 65 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d1fcabb1b..31ce8b554 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -5,6 +5,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const ziglyph = @import("ziglyph"); const key = @import("key.zig"); const KeyEvent = key.KeyEvent; @@ -1332,10 +1333,32 @@ pub const Trigger = struct { /// Hash the trigger into the given hasher. fn hashIncremental(self: Trigger, hasher: anytype) void { - std.hash.autoHash(hasher, self.key); + std.hash.autoHash(hasher, std.meta.activeTag(self.key)); + switch (self.key) { + .physical => |v| std.hash.autoHash(hasher, v), + .unicode => |cp| std.hash.autoHash( + hasher, + foldedCodepoint(cp), + ), + } std.hash.autoHash(hasher, self.mods.binding()); } + /// The codepoint we use for comparisons. Case folding can result + /// in more codepoints so we need to use a 3 element array. + fn foldedCodepoint(cp: u21) [3]u21 { + // ASCII fast path + if (ziglyph.letter.isAsciiLetter(cp)) { + return .{ ziglyph.letter.toLower(cp), 0, 0 }; + } + + // Unicode slow path. Case folding can resultin more codepoints. + // If more codepoints are produced then we return the codepoint + // as-is which isn't correct but until we have a failing test + // then I don't want to handle this. + return ziglyph.letter.toCaseFold(cp); + } + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ @@ -2747,6 +2770,46 @@ test "set: getEvent codepoint" { } } +test "set: getEvent codepoint case folding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+A=new_window"); + + // Lowercase codepoint + { + const action = s.getEvent(.{ + .key = .key_j, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 'a', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Uppercase codepoint + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 'A', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } + + // Negative case for sanity + { + const action = s.getEvent(.{ + .key = .key_j, + .mods = .{ .ctrl = true }, + }); + try testing.expect(action == null); + } +} test "Action: clone" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); From ed1194cd7571c681df17909dfff69311c22731b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 May 2025 08:51:03 -0700 Subject: [PATCH 027/245] fix tests --- include/ghostty.h | 1 + src/config/Config.zig | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 2734fc368..72f23b22b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -103,6 +103,7 @@ typedef enum { GHOSTTY_ACTION_REPEAT, } ghostty_input_action_e; +// Based on: https://www.w3.org/TR/uievents-code/ typedef enum { GHOSTTY_KEY_UNIDENTIFIED, diff --git a/src/config/Config.zig b/src/config/Config.zig index 0ec61d4c5..7c93ac845 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5159,9 +5159,9 @@ pub const Keybinds = struct { // NB: This does not currently retain the order of the keybinds. const want = - \\a = ctrl+a>ctrl+b>n=new_window - \\a = ctrl+a>ctrl+b>w=close_window \\a = ctrl+a>ctrl+c>t=new_tab + \\a = ctrl+a>ctrl+b>w=close_window + \\a = ctrl+a>ctrl+b>n=new_window \\a = ctrl+b>ctrl+d>a=previous_tab \\ ; From db1608ff1674f3d5338180ae5dbc42e0726e89d0 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 11 May 2025 00:14:21 +0000 Subject: [PATCH 028/245] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7c5ff8ffc..187c67531 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz", - .hash = "N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz", + .hash = "N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 513ee0dcd..8b29ff0c3 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A": { + "N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz", - "hash": "sha256-xpDitXpZrdU/EcgLyG4G0cEiT4r42viy+DJALmy2sQE=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz", + "hash": "sha256-YIlb2eSviWrRc+hbwgsAHLeCY3JgbYWjd9ZbOpXe1Qg=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 46cf07cc9..056c7b75e 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A"; + name = "N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz"; - hash = "sha256-xpDitXpZrdU/EcgLyG4G0cEiT4r42viy+DJALmy2sQE="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz"; + hash = "sha256-YIlb2eSviWrRc+hbwgsAHLeCY3JgbYWjd9ZbOpXe1Qg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 5f06418a7..dd38069c4 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index bc3b6cd0c..9dbd2c18d 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/1e4957e65005908993250f8f07be3f70e805195e.tar.gz", - "dest": "vendor/p/N-V-__8AAHOASAQuLADcCSHLHEJiKFVLZiCD9Aq2rh5GT01A", - "sha256": "c690e2b57a59add53f11c80bc86e06d1c1224f8af8daf8b2f832402e6cb6b101" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz", + "dest": "vendor/p/N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs", + "sha256": "60895bd9e4af896ad173e85bc20b001cb7826372606d85a377d65b3a95ded508" }, { "type": "archive", From c4f1c78fcf254bd23196eff3c00bb86d071fe0fd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 May 2025 14:50:56 -0700 Subject: [PATCH 029/245] macOS: treat C-/ specially again to prevent beep Fixes #7310 --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 10 ++++++++++ src/input/key.zig | 11 ----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index af9895c35..8e8838471 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1066,6 +1066,16 @@ extension Ghostty { equivalent = "\r" + case "/": + // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep + // sound and we don't like the beep sound. + if (!event.modifierFlags.contains(.control) || + !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { + return false + } + + equivalent = "_" + default: // It looks like some part of AppKit sometimes generates synthetic NSEvents // with a zero timestamp. We never process these at this point. Concretely, diff --git a/src/input/key.zig b/src/input/key.zig index 831c9f07f..9dad37d78 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -149,9 +149,6 @@ pub const Mods = packed struct(Mods.Backing) { pub fn translation(self: Mods, option_as_alt: config.OptionAsAlt) Mods { var result = self; - // Control is never used for translation. - result.ctrl = false; - // macos-option-as-alt for darwin if (comptime builtin.target.os.tag.isDarwin()) alt: { // Alt has to be set only on the correct side @@ -187,14 +184,6 @@ pub const Mods = packed struct(Mods.Backing) { ); } - test "translation removes control" { - const testing = std.testing; - - const mods: Mods = .{ .ctrl = true }; - const result = mods.translation(.true); - try testing.expectEqual(Mods{}, result); - } - test "translation macos-option-as-alt" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; From 8f40d1331e322066a6c466f0eb895af3a4d721fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 12 May 2025 09:07:53 -0700 Subject: [PATCH 030/245] ensure `ctrl++` parses, clarify case folding docs --- src/config/Config.zig | 16 ++++++++++++++++ src/input/Binding.zig | 42 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7c93ac845..ca330f8f6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -935,6 +935,22 @@ class: ?[:0]const u8 = null, /// QWERTY keyboard, but will match the `q` key on a AZERTY keyboard /// (assuming US physical layout). /// +/// For Unicode codepoints, matching is done by comparing the set of +/// modifiers with the unmodified codepoint. The unmodified codepoint is +/// sometimes called an "unshifted character" in other software, but all +/// modifiers are considered, not only shift. For example, `ctrl+a` will match +/// `a` but not `ctrl+shift+a` (which is `A` on a US keyboard). +/// +/// Further, codepoint matching is case-insensitive and the unmodified +/// codepoint is always case folded for comparison. As a result, +/// `ctrl+A` configured will match when `ctrl+a` is pressed. Note that +/// this means some key combinations are impossible depending on keyboard +/// layout. For example, `ctrl+_` is impossible on a US keyboard because +/// `_` is `shift+-` and `ctrl+shift+-` is not equal to `ctrl+_` (because +/// the modifiers don't match!). More details on impossible key combinations +/// can be found at this excellent source written by Qt developers: +/// https://doc.qt.io/qt-6/qkeysequence.html#keyboard-layout-issues +/// /// Physical key codes can be specified by using any of the key codes /// as specified by the [W3C specification](https://www.w3.org/TR/uievents-code/). /// For example, `KeyA` will match the physical `a` key on a US standard diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 31ce8b554..d02a58078 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1109,10 +1109,11 @@ pub const Trigger = struct { pub fn parse(input: []const u8) !Trigger { if (input.len == 0) return Error.InvalidFormat; var result: Trigger = .{}; - var iter = std.mem.tokenizeScalar(u8, input, '+'); - loop: while (iter.next()) |part| { - // All parts must be non-empty - if (part.len == 0) return Error.InvalidFormat; + var rem: []const u8 = input; + loop: while (rem.len > 0) { + const idx = std.mem.indexOfScalar(u8, rem, '+') orelse rem.len; + const part = rem[0..idx]; + rem = if (idx >= rem.len) "" else rem[idx + 1 ..]; // Check if its a modifier const modsInfo = @typeInfo(key.Mods).@"struct"; @@ -1148,6 +1149,13 @@ pub const Trigger = struct { // single keys. if (!result.isKeyUnset()) return Error.InvalidFormat; + // If the part is empty it means that it is actually + // a literal `+`, which we treat as a Unicode character. + if (part.len == 0) { + result.key = .{ .unicode = '+' }; + continue :loop; + } + // Check if its a key const keysInfo = @typeInfo(key.Key).@"enum"; inline for (keysInfo.fields) |field| { @@ -2000,6 +2008,32 @@ test "parse: w3c key names" { try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); } +test "parse: plus sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '+' } }, + .action = .{ .ignore = {} }, + }, + try parseSingle("+=ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '+' }, + .mods = .{ .ctrl = true }, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl++=ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("++=ignore")); +} + // For Ghostty 1.2+ we changed our key names to match the W3C and removed // `physical:`. This tests the backwards compatibility with the old format. // Note that our backwards compatibility isn't 100% perfect since triggers From 6c6cdf4c4f615fd3c882679191784f5907956af2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 12 May 2025 09:56:58 -0700 Subject: [PATCH 031/245] input: bracket right was mapped to left, a typo --- src/input/keycodes.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index fa95ff206..b4004088e 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -89,7 +89,7 @@ const code_to_key = code_to_key: { .{ "Minus", .minus }, .{ "Equal", .equal }, .{ "BracketLeft", .bracket_left }, - .{ "BracketRight", .bracket_left }, + .{ "BracketRight", .bracket_right }, .{ "Backslash", .backslash }, .{ "Semicolon", .semicolon }, .{ "Quote", .quote }, From ecda5ec327288bae5e27c8d2ec1e7ac3a99b3087 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 12 May 2025 11:12:04 -0700 Subject: [PATCH 032/245] macos: do not send UTF-8 PUA codepoints to key events Fixes #7337 AppKit encodes functional keys as PUA codepoints. We don't want to send that down as valid text encoding for a key event because KKP uses that in particular to change the encoding with associated text. I think there may be a more specific solution to this by only doing this within the KKP encoding part of KeyEncoder but that was filled with edge cases and I didn't want to risk breaking anything else. --- macos/Sources/Ghostty/NSEvent+Extension.swift | 19 +++++++++++++------ src/input/KeyEncoder.zig | 8 ++++++-- src/terminal/kitty/key.zig | 9 +++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift index a5aec3870..b67c1932e 100644 --- a/macos/Sources/Ghostty/NSEvent+Extension.swift +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -56,13 +56,20 @@ extension NSEvent { // If we have no characters associated with this event we do nothing. guard let characters else { return nil } - // If we have a single control character, then we return the characters - // without control pressed. We do this because we handle control character - // encoding directly within Ghostty's KeyEncoder. if characters.count == 1, - let scalar = characters.unicodeScalars.first, - scalar.value < 0x20 { - return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) + let scalar = characters.unicodeScalars.first { + // If we have a single control character, then we return the characters + // without control pressed. We do this because we handle control character + // encoding directly within Ghostty's KeyEncoder. + if scalar.value < 0x20 { + return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) + } + + // If we have a single value in the PUA, then it's a function key and + // we don't want to send PUA ranges down to Ghostty. + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { + return nil + } } return characters diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 9e68dae2d..41634f2f1 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -146,7 +146,9 @@ fn kitty( // the real world issue is usually control characters. const view = try std.unicode.Utf8View.init(self.event.utf8); var it = view.iterator(); - while (it.nextCodepoint()) |cp| if (isControl(cp)) break :plain_text; + while (it.nextCodepoint()) |cp| { + if (isControl(cp)) break :plain_text; + } return try copyToBuf(buf, self.event.utf8); } @@ -212,7 +214,9 @@ fn kitty( } } - if (self.kitty_flags.report_associated and seq.event != .release) associated: { + if (self.kitty_flags.report_associated and + seq.event != .release) + associated: { // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig index a04bd181a..8bafcb7dc 100644 --- a/src/terminal/kitty/key.zig +++ b/src/terminal/kitty/key.zig @@ -83,6 +83,15 @@ pub const Flags = packed struct(u5) { report_all: bool = false, report_associated: bool = false, + /// Sets all modes on. + pub const @"true": Flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }; + pub fn int(self: Flags) u5 { return @bitCast(self); } From e2daf04cbad11c1e207334e826810f61b0d88695 Mon Sep 17 00:00:00 2001 From: Ken VanDine Date: Mon, 12 May 2025 17:40:48 -0400 Subject: [PATCH 033/245] snap: Build with cpu=baseline as documented in PACKAGING.md --- snap/snapcraft.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 8f1a7180a..b57411a6c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -80,7 +80,7 @@ parts: - gettext override-build: | craftctl set version=$(cat VERSION) - $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast + $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline cp -rp zig-out/* $CRAFT_PART_INSTALL/ sed -i 's|Icon=com.mitchellh.ghostty|Icon=/snap/ghostty/current/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop From 507e808a7caec36c5f852be54a943fe6f3b7bdfe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 12 May 2025 15:32:27 -0700 Subject: [PATCH 034/245] input: add backwards compatible alias for `plus` to `+` From #7320 Discussion #7340 There isn't a `physical` alias because there is no physical plus key defined for the W3C keycode spec. --- src/input/Binding.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d02a58078..89c5e4352 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1218,6 +1218,7 @@ pub const Trigger = struct { .{ "seven", Key{ .unicode = '7' } }, .{ "eight", Key{ .unicode = '8' } }, .{ "nine", Key{ .unicode = '9' } }, + .{ "plus", Key{ .unicode = '+' } }, .{ "apostrophe", Key{ .unicode = '\'' } }, .{ "grave_accent", Key{ .physical = .backquote } }, .{ "left_bracket", Key{ .physical = .bracket_left } }, From 8d0c3c7b7c4b4aaba59d5562fe43701d2d9e566a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 23 Jan 2025 13:57:36 -0600 Subject: [PATCH 035/245] gtk: implement custom audio for bell --- nix/build-support/build-inputs.nix | 3 ++ nix/devShell.nix | 4 ++ nix/package.nix | 4 ++ src/apprt/gtk/Surface.zig | 65 ++++++++++++++++++++++++++++++ src/config/Config.zig | 30 ++++++++++++-- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/nix/build-support/build-inputs.nix b/nix/build-support/build-inputs.nix index 5886cfe30..7c9258675 100644 --- a/nix/build-support/build-inputs.nix +++ b/nix/build-support/build-inputs.nix @@ -28,6 +28,9 @@ pkgs.glib pkgs.gobject-introspection pkgs.gsettings-desktop-schemas + pkgs.gst_all_1.gst-plugins-base + pkgs.gst_all_1.gst-plugins-good + pkgs.gst_all_1.gstreamer pkgs.gtk4 pkgs.libadwaita ] diff --git a/nix/devShell.nix b/nix/devShell.nix index 498102ef4..b87c23dd1 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -35,6 +35,7 @@ gtk4, gtk4-layer-shell, gobject-introspection, + gst_all_1, libadwaita, blueprint-compiler, gettext, @@ -166,6 +167,9 @@ in wayland wayland-scanner wayland-protocols + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good ]; # This should be set onto the rpath of the ghostty binary if you want diff --git a/nix/package.nix b/nix/package.nix index 9368b2cde..a39f5b835 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -127,6 +127,10 @@ in mv $out/share/vim/vimfiles "$vim" ln -sf "$vim" "$out/share/vim/vimfiles" echo "$vim" >> "$out/nix-support/propagated-user-env-packages" + + echo "gst_all_1.gstreamer" >> "$out/nix-support/propagated-user-env-packages" + echo "gst_all_1.gst-plugins-base" >> "$out/nix-support/propagated-user-env-packages" + echo "gst_all_1.gst-plugins-good" >> "$out/nix-support/propagated-user-env-packages" ''; meta = { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 0a9f644b7..e47316ac3 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2405,6 +2405,47 @@ pub fn ringBell(self: *Surface) !void { surface.beep(); } + if (features.audio) audio: { + // Play a user-specified audio file. + + const pathname, const optional = switch (self.app.config.@"bell-audio-path" orelse break :audio) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + + const volume: f64 = @min( + @max( + 0.0, + self.app.config.@"bell-audio-volume", + ), + 1.0, + ); + + std.debug.assert(std.fs.path.isAbsolute(pathname)); + const media_file = gtk.MediaFile.newForFilename(pathname); + + if (!optional) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + gtkStreamError, + null, + .{ .detail = "error" }, + ); + } + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + gtkStreamEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); + } + // Mark tab as needing attention if (self.container.tab()) |tab| tab: { const page = window.notebook.getTabPage(tab) orelse break :tab; @@ -2413,3 +2454,27 @@ pub fn ringBell(self: *Surface) !void { if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); } } + +/// Handle a stream that is in an error state. +fn gtkStreamError(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + + log.warn("error playing bell from {s}: {s} {d} {s}", .{ + path orelse "<>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); +} + +/// Stream is finished, release the memory. +fn gtkStreamEnded(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + media_file.unref(); +} diff --git a/src/config/Config.zig b/src/config/Config.zig index ca330f8f6..b51f053cd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1890,8 +1890,10 @@ keybind: Keybinds = .{}, /// open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, -/// The list of enabled features that are activated after encountering -/// a bell character. +/// Bell features to enable if bell support is available in your runtime. Not +/// all features are available on all runtimes. The format of this is a list of +/// features to enable separated by commas. If you prefix a feature with `no-` +/// then it is disabled. If you omit a feature, its default value is used. /// /// Valid values are: /// @@ -1901,17 +1903,36 @@ keybind: Keybinds = .{}, /// This could result in an audiovisual effect, a notification, or something /// else entirely. Changing these effects require altering system settings: /// for instance under the "Sound > Alert Sound" setting in GNOME, -/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// or the "Accessibility > System Bell" settings in KDE Plasma. (GTK only) /// -/// On macOS this has no affect. +/// * `audio` +/// +/// Play a custom sound. (GTK only) +/// +/// Example: `audio`, `no-audio`, `system`, `no-system`: /// /// On macOS, if the app is unfocused, it will bounce the app icon in the dock /// once. Additionally, the title of the window with the alerted terminal /// surface will contain a bell emoji (🔔) until the terminal is focused /// or a key is pressed. These are not currently configurable since they're /// considered unobtrusive. +/// +/// By default, no bell features are enabled. @"bell-features": BellFeatures = .{}, +/// If `audio` is an enabled bell feature, this is a path to an audio file. If +/// the path is not absolute, it is considered relative to the directory of the +/// configuration file that it is referenced from, or from the current working +/// directory if this is used as a CLI flag. The path may be prefixed with `~/` +/// to reference the user's home directory. (GTK only) +@"bell-audio-path": ?Path = null, + +/// If `audio` is an enabled bell feature, this is the volume to play the audio +/// file at (relative to the system volume). This is a floating point number +/// ranging from 0.0 (silence) to 1.0 (as loud as possible). The default is 0.5. +/// (GTK only) +@"bell-audio-volume": f64 = 0.5, + /// Control the in-app notifications that Ghostty shows. /// /// On Linux (GTK), in-app notifications show up as toasts. Toasts appear @@ -5765,6 +5786,7 @@ pub const AppNotifications = packed struct { /// See bell-features pub const BellFeatures = packed struct { system: bool = false, + audio: bool = false, }; /// See mouse-shift-capture From 0e8b266662d35f1df3abd181f2251e54dc474957 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 16 Apr 2025 11:06:31 -0500 Subject: [PATCH 036/245] Use `std.math.clamp` Co-authored-by: Leah Amelia Chen --- src/apprt/gtk/Surface.zig | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index e47316ac3..d756925b3 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2413,13 +2413,7 @@ pub fn ringBell(self: *Surface) !void { .required => |path| .{ path, false }, }; - const volume: f64 = @min( - @max( - 0.0, - self.app.config.@"bell-audio-volume", - ), - 1.0, - ); + const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0); std.debug.assert(std.fs.path.isAbsolute(pathname)); const media_file = gtk.MediaFile.newForFilename(pathname); From ba08b0cce51b61efd01c1d9634cbab7c5ddea6c3 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 23 Apr 2025 10:38:47 -0500 Subject: [PATCH 037/245] gtk custom bell audio: optional -> required --- src/apprt/gtk/Surface.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index d756925b3..bcb78e087 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2408,9 +2408,9 @@ pub fn ringBell(self: *Surface) !void { if (features.audio) audio: { // Play a user-specified audio file. - const pathname, const optional = switch (self.app.config.@"bell-audio-path" orelse break :audio) { - .optional => |path| .{ path, true }, - .required => |path| .{ path, false }, + const pathname, const required = switch (self.app.config.@"bell-audio-path" orelse break :audio) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, }; const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0); @@ -2418,7 +2418,7 @@ pub fn ringBell(self: *Surface) !void { std.debug.assert(std.fs.path.isAbsolute(pathname)); const media_file = gtk.MediaFile.newForFilename(pathname); - if (!optional) { + if (required) { _ = gobject.Object.signals.notify.connect( media_file, ?*anyopaque, From 528814da7984a1ab28dfcc277a97fdd81965724e Mon Sep 17 00:00:00 2001 From: Weizhao Ouyang Date: Wed, 14 May 2025 23:05:36 +0800 Subject: [PATCH 038/245] url: restrict file paths regex to one slash This restricts the valid path prefixes to prevent false matches caused by literal dot. Signed-off-by: Weizhao Ouyang --- src/config/url.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/url.zig b/src/config/url.zig index 9f9f3fa4a..da3928aff 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -26,7 +26,7 @@ pub const regex = "(?:" ++ url_schemes ++ \\)(?: ++ ipv6_url_pattern ++ - \\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? Date: Wed, 14 May 2025 12:05:33 -0700 Subject: [PATCH 039/245] bench: add `--mode=gen-osc` to generate synthetic OSC sequences This commit adds a few new mode flags to the `bench-stream` program to generator synthetic OSC sequences. The new modes are `gen-osc`, `gen-osc-valid`, and `gen-osc-invalid`. The `gen-osc` mode generates equal parts valid and invalid OSC sequences, while the suffixed variants are for generating only valid or invalid sequences, respectively. This commit also fixes our build system to actually be able to build the benchmarks. It turns out we were just rebuilding the main Ghostty binary for `-Demit-bench`. And, our benchmarks didn't run under Zig 0.14, which is now fixed. An important new design I'm working towards in this commit is to split out synthetic data generation to a dedicated package in `src/bench/synth` although I'm tempted to move it to `src/synth` since it may be useful outside of benchmarks. The synth package is a work-in-progress, but it contains a hint of what's to come. I ultimately want to able to generate all kinds of synthetic data with a lot of knobs to control dimensionality (e.g. in the case of OSC sequences: valid/invalid, length, operation types, etc.). --- src/bench/codepoint-width.zig | 2 +- src/bench/grapheme-break.zig | 2 +- src/bench/page-init.zig | 2 +- src/bench/parser.zig | 2 +- src/bench/stream.zig | 34 +++++- src/bench/synth/main.zig | 15 +++ src/bench/synth/osc.zig | 197 ++++++++++++++++++++++++++++++++++ src/build/SharedDeps.zig | 3 + src/main_ghostty.zig | 1 + src/terminal/osc.zig | 6 +- 10 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 src/bench/synth/main.zig create mode 100644 src/bench/synth/osc.zig diff --git a/src/bench/codepoint-width.zig b/src/bench/codepoint-width.zig index ce44bccb0..07c865e55 100644 --- a/src/bench/codepoint-width.zig +++ b/src/bench/codepoint-width.zig @@ -68,7 +68,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } diff --git a/src/bench/grapheme-break.zig b/src/bench/grapheme-break.zig index bbe2171d5..049af4a91 100644 --- a/src/bench/grapheme-break.zig +++ b/src/bench/grapheme-break.zig @@ -60,7 +60,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } diff --git a/src/bench/page-init.zig b/src/bench/page-init.zig index e45d64fbb..9b0d1ac1d 100644 --- a/src/bench/page-init.zig +++ b/src/bench/page-init.zig @@ -45,7 +45,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } diff --git a/src/bench/parser.zig b/src/bench/parser.zig index ee6c3ee94..9245c06cb 100644 --- a/src/bench/parser.zig +++ b/src/bench/parser.zig @@ -27,7 +27,7 @@ pub fn main() !void { var args: Args = args: { var args: Args = .{}; errdefer args.deinit(); - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); break :args args; diff --git a/src/bench/stream.zig b/src/bench/stream.zig index a7abb37cc..0c7d421cc 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -15,6 +15,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const ziglyph = @import("ziglyph"); const cli = @import("../cli.zig"); const terminal = @import("../terminal/main.zig"); +const synth = @import("synth/main.zig"); const Args = struct { mode: Mode = .noop, @@ -70,6 +71,14 @@ const Mode = enum { // Generate an infinite stream of arbitrary random bytes. @"gen-rand", + + // Generate an infinite stream of OSC requests. These will be mixed + // with valid and invalid OSC requests by default, but the + // `-valid` and `-invalid`-suffixed variants can be used to get only + // a specific type of OSC request. + @"gen-osc", + @"gen-osc-valid", + @"gen-osc-invalid", }; pub const std_options: std.Options = .{ @@ -84,7 +93,7 @@ pub fn main() !void { var args: Args = .{}; defer args.deinit(); { - var iter = try std.process.argsWithAllocator(alloc); + var iter = try cli.args.argsIterator(alloc); defer iter.deinit(); try cli.args.parse(Args, alloc, &args, &iter); } @@ -100,6 +109,9 @@ pub fn main() !void { .@"gen-ascii" => try genAscii(writer, seed), .@"gen-utf8" => try genUtf8(writer, seed), .@"gen-rand" => try genRand(writer, seed), + .@"gen-osc" => try genOsc(writer, seed, 0.5), + .@"gen-osc-valid" => try genOsc(writer, seed, 1.0), + .@"gen-osc-invalid" => try genOsc(writer, seed, 0.0), .noop => try benchNoop(reader, buf), // Handle the ones that depend on terminal state next @@ -142,7 +154,7 @@ fn genAscii(writer: anytype, seed: u64) !void { /// Generates an infinite stream of bytes from the given alphabet. fn genData(writer: anytype, alphabet: []const u8, seed: u64) !void { - var prng = std.rand.DefaultPrng.init(seed); + var prng = std.Random.DefaultPrng.init(seed); const rnd = prng.random(); var buf: [1024]u8 = undefined; while (true) { @@ -159,7 +171,7 @@ fn genData(writer: anytype, alphabet: []const u8, seed: u64) !void { } fn genUtf8(writer: anytype, seed: u64) !void { - var prng = std.rand.DefaultPrng.init(seed); + var prng = std.Random.DefaultPrng.init(seed); const rnd = prng.random(); var buf: [1024]u8 = undefined; while (true) { @@ -180,8 +192,22 @@ fn genUtf8(writer: anytype, seed: u64) !void { } } +fn genOsc(writer: anytype, seed: u64, p_valid: f64) !void { + var prng = std.Random.DefaultPrng.init(seed); + const gen: synth.OSC = .{ .rand = prng.random(), .p_valid = p_valid }; + + var buf: [1024]u8 = undefined; + while (true) { + const seq = try gen.next(&buf); + writer.writeAll(seq) catch |err| switch (err) { + error.BrokenPipe => return, // stdout closed + else => return err, + }; + } +} + fn genRand(writer: anytype, seed: u64) !void { - var prng = std.rand.DefaultPrng.init(seed); + var prng = std.Random.DefaultPrng.init(seed); const rnd = prng.random(); var buf: [1024]u8 = undefined; while (true) { diff --git a/src/bench/synth/main.zig b/src/bench/synth/main.zig new file mode 100644 index 000000000..eda2dec28 --- /dev/null +++ b/src/bench/synth/main.zig @@ -0,0 +1,15 @@ +//! Package synth contains functions for generating synthetic data for +//! the purpose of benchmarking, primarily. This can also probably be used +//! for testing and fuzzing (probably generating a corpus rather than +//! directly fuzzing) and more. +//! +//! The synthetic data generators in this package are usually not performant +//! enough to be streamed in real time. They should instead be used to +//! generate a large amount of data in a single go and then streamed +//! from there. + +pub const OSC = @import("osc.zig").Generator; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/bench/synth/osc.zig b/src/bench/synth/osc.zig new file mode 100644 index 000000000..61f168b58 --- /dev/null +++ b/src/bench/synth/osc.zig @@ -0,0 +1,197 @@ +const std = @import("std"); +const assert = std.debug.assert; + +/// Synthetic OSC request generator. +/// +/// I tried to balance generality and practicality. I implemented mainly +/// all I need at the time of writing this, but I think this can be iterated +/// over time to be a general purpose OSC generator with a lot of +/// configurability. I limited the configurability to what I need but still +/// tried to lay out the code in a way that it can be extended easily. +pub const Generator = struct { + /// Random number generator. + rand: std.Random, + + /// Probability of a valid OSC sequence being generated. + p_valid: f64 = 1.0, + + pub const Error = error{NoSpaceLeft}; + + /// We use a FBS as a direct parameter below in non-pub functions, + /// but we should probably just switch to `[]u8`. + const FBS = std.io.FixedBufferStream([]u8); + + /// Get the next OSC request in bytes. The generated OSC request will + /// have the prefix `ESC ]` and the terminator `BEL` (0x07). + /// + /// This will generate both valid and invalid OSC requests (based on + /// the `p_valid` probability value). Invalid requests still have the + /// prefix and terminator, but the content in between is not a valid + /// OSC request. + /// + /// The buffer must be at least 3 bytes long to accommodate the + /// prefix and terminator. + pub fn next(self: *const Generator, buf: []u8) Error![]const u8 { + assert(buf.len >= 3); + var fbs: FBS = std.io.fixedBufferStream(buf); + const writer = fbs.writer(); + + // Start OSC (ESC ]) + try writer.writeAll("\x1b]"); + + // Determine if we are generating a valid or invalid OSC request. + switch (self.chooseValidity()) { + .valid => try self.nextValid(&fbs), + .invalid => try self.nextInvalid(&fbs), + } + + // Terminate OSC + try writer.writeAll("\x07"); + return fbs.getWritten(); + } + + fn nextValid(self: *const Generator, fbs: *FBS) Error!void { + try self.nextValidExact(fbs, self.rand.enumValue(ValidKind)); + } + + fn nextValidExact(self: *const Generator, fbs: *FBS, k: ValidKind) Error!void { + switch (k) { + .change_window_title => { + try fbs.writer().writeAll("0;"); // Set window title + try self.randomBytes(fbs, 1, fbs.buffer.len); + }, + + .prompt_start => { + try fbs.writer().writeAll("133;A"); // Start prompt + + // aid + if (self.rand.boolean()) { + try fbs.writer().writeAll(";aid="); + try self.randomBytes(fbs, 1, 16); + } + + // redraw + if (self.rand.boolean()) { + try fbs.writer().writeAll(";redraw="); + if (self.rand.boolean()) { + try fbs.writer().writeAll("1"); + } else { + try fbs.writer().writeAll("0"); + } + } + }, + + .prompt_end => try fbs.writer().writeAll("133;B"), // End prompt + } + } + + fn nextInvalid(self: *const Generator, fbs: *FBS) Error!void { + switch (self.rand.enumValue(InvalidKind)) { + .random => try self.randomBytes(fbs, 1, fbs.buffer.len), + .good_prefix => { + try fbs.writer().writeAll("133;"); + try self.randomBytes(fbs, 2, fbs.buffer.len); + }, + } + } + + /// Generate a random string of bytes up to `max_len` bytes or + /// until we run out of space in the buffer, whichever is + /// smaller. + /// + /// This will avoid the terminator characters (0x1B and 0x07) and + /// replace them by incrementing them by one. + fn randomBytes( + self: *const Generator, + fbs: *FBS, + min_len: usize, + max_len: usize, + ) Error!void { + const len = @min( + self.rand.intRangeAtMostBiased(usize, min_len, max_len), + fbs.buffer.len - fbs.pos - 1, // leave space for terminator + ); + var rem: usize = len; + var buf: [1024]u8 = undefined; + while (rem > 0) { + self.rand.bytes(&buf); + std.mem.replaceScalar(u8, &buf, 0x1B, 0x1C); + std.mem.replaceScalar(u8, &buf, 0x07, 0x08); + + const n = @min(rem, buf.len); + try fbs.writer().writeAll(buf[0..n]); + rem -= n; + } + } + + /// Choose whether to generate a valid or invalid OSC request based + /// on the validity probability. + fn chooseValidity(self: *const Generator) Validity { + return if (self.rand.float(f64) > self.p_valid) + .invalid + else + .valid; + } + + const Validity = enum { valid, invalid }; + + const ValidKind = enum { + change_window_title, + prompt_start, + prompt_end, + }; + + const InvalidKind = enum { + /// Literally random bytes. Might even be valid, but probably not. + random, + + /// A good prefix, but ultimately invalid format. + good_prefix, + }; +}; + +/// A fixed seed we can use for our tests to avoid flakes. +const test_seed = 0xC0FFEEEEEEEEEEEE; + +test "OSC generator" { + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [4096]u8 = undefined; + const gen: Generator = .{ .rand = prng.random() }; + for (0..50) |_| _ = try gen.next(&buf); +} + +test "OSC generator valid" { + const testing = std.testing; + const terminal = @import("../../terminal/main.zig"); + + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [256]u8 = undefined; + const gen: Generator = .{ + .rand = prng.random(), + .p_valid = 1.0, + }; + for (0..50) |_| { + const seq = try gen.next(&buf); + var parser: terminal.osc.Parser = .{}; + for (seq[2 .. seq.len - 1]) |c| parser.next(c); + try testing.expect(parser.end(null) != null); + } +} + +test "OSC generator invalid" { + const testing = std.testing; + const terminal = @import("../../terminal/main.zig"); + + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [256]u8 = undefined; + const gen: Generator = .{ + .rand = prng.random(), + .p_valid = 0.0, + }; + for (0..50) |_| { + const seq = try gen.next(&buf); + var parser: terminal.osc.Parser = .{}; + for (seq[2 .. seq.len - 1]) |c| parser.next(c); + try testing.expect(parser.end(null) == null); + } +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 4b97298f7..0df261600 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -60,6 +60,9 @@ pub fn changeEntrypoint( var result = self.*; result.config = config; + result.options = b.addOptions(); + try config.addOptions(result.options); + return result; } diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 6a4688dc7..4a9f2b138 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -182,6 +182,7 @@ test { _ = @import("surface_mouse.zig"); // Libraries + _ = @import("bench/synth/main.zig"); _ = @import("crash/main.zig"); _ = @import("datastruct/main.zig"); _ = @import("inspector/main.zig"); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index faf376d13..ce7afdf64 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -6,6 +6,7 @@ const osc = @This(); const std = @import("std"); +const builtin = @import("builtin"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; @@ -1332,7 +1333,10 @@ pub const Parser = struct { /// the response terminator. pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { if (!self.complete) { - log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); + if (comptime !builtin.is_test) log.warn( + "invalid OSC command: {s}", + .{self.buf[0..self.buf_idx]}, + ); return null; } From 55c1ef779f314d87b138943d8a5b3e1f18157862 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 14 May 2025 21:12:20 -0600 Subject: [PATCH 040/245] fix(Metal): interpolate kitty images uint textures can't be interpolated apparently --- src/renderer/metal/image.zig | 2 +- src/renderer/shaders/cell.metal | 25 ++++++++++++------------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig index 835fbd672..cb675404d 100644 --- a/src/renderer/metal/image.zig +++ b/src/renderer/metal/image.zig @@ -441,7 +441,7 @@ pub const Image = union(enum) { }; // Set our properties - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8uint)); + desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8unorm)); desc.setProperty("width", @as(c_ulong, @intCast(p.width))); desc.setProperty("height", @as(c_ulong, @intCast(p.height))); diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index e80ead9ad..80ffc00de 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -621,9 +621,6 @@ vertex ImageVertexOut image_vertex( texture2d image [[texture(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { - // The size of the image in pixels - float2 image_size = float2(image.get_width(), image.get_height()); - // Turn the image position into a vertex point depending on the // vertex ID. Since we use instanced drawing, we have 4 vertices // for each corner of the cell. We can use vertex ID to determine @@ -638,11 +635,12 @@ vertex ImageVertexOut image_vertex( corner.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f; corner.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f; - // The texture coordinates start at our source x/y, then add the width/height - // as enabled by our instance id, then normalize to [0, 1] + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + // + // We don't need to normalize because we use pixel addressing for our sampler. float2 tex_coord = in.source_rect.xy; tex_coord += in.source_rect.zw * corner; - tex_coord /= image_size; ImageVertexOut out; @@ -659,18 +657,19 @@ vertex ImageVertexOut image_vertex( fragment float4 image_fragment( ImageVertexOut in [[stage_in]], - texture2d image [[texture(0)]], + texture2d image [[texture(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { - constexpr sampler textureSampler(address::clamp_to_edge, filter::linear); + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_edge, + filter::linear + ); - // Ehhhhh our texture is in RGBA8Uint but our color attachment is - // BGRA8Unorm. So we need to convert it. We should really be converting - // our texture to BGRA8Unorm. - uint4 rgba = image.sample(textureSampler, in.tex_coord); + float4 rgba = image.sample(textureSampler, in.tex_coord); return load_color( - uchar4(rgba), + uchar4(rgba * 255.0), // We assume all images are sRGB regardless of the configured colorspace // TODO: Maybe support wide gamut images? false, From 7ccc18133205e504ad08e992d1d52411465f0312 Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Thu, 15 May 2025 13:34:44 +0800 Subject: [PATCH 041/245] macos: add "Check for Updates" action, menu item & key-binding support --- include/ghostty.h | 1 + macos/Sources/App/macOS/AppDelegate.swift | 9 +++++---- macos/Sources/App/macOS/MainMenu.xib | 3 +++ macos/Sources/Ghostty/Ghostty.App.swift | 11 +++++++++++ src/App.zig | 1 + src/apprt/action.zig | 3 +++ src/input/Binding.zig | 6 ++++++ src/input/command.zig | 6 ++++++ 8 files changed, 36 insertions(+), 4 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 72f23b22b..941223943 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -667,6 +667,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_CHECK_FOR_UPDATES } ghostty_action_tag_e; typedef union { diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c5d63f55d..38b26f606 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -154,10 +154,6 @@ class AppDelegate: NSObject, toggleSecureInput(self) } - // Hook up updater menu - menuCheckForUpdates?.target = updaterController - menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) - // Initial config loading ghosttyConfigDidChange(config: ghostty.config) @@ -374,6 +370,7 @@ class AppDelegate: NSObject, private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } + syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) @@ -791,6 +788,10 @@ class AppDelegate: NSObject, ghostty.reloadConfig() } + @IBAction func checkForUpdates(_ sender: Any?) { + updaterController.checkForUpdates(sender) + } + @IBAction func newWindow(_ sender: Any?) { terminalManager.newWindow() diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 724f21355..828e82bd0 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -76,6 +76,9 @@ + + + diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 65e91ce83..7b9e49f4c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -550,6 +550,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: + checkForUpdates(app) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -588,6 +591,14 @@ extension Ghostty { #endif } + private static func checkForUpdates( + _ app: ghostty_app_t, + ) { + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.checkForUpdates(nil) + } + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/src/App.zig b/src/App.zig index 15859d115..005b745a6 100644 --- a/src/App.zig +++ b/src/App.zig @@ -444,6 +444,7 @@ pub fn performAction( .close_all_windows => _ = try rt_app.performAction(.app, .close_all_windows, {}), .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}), .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}), + .check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}), } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4be296f09..8a23bc1a4 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -255,6 +255,8 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + check_for_updates, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -301,6 +303,7 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + check_for_updates, }; /// Sync with: ghostty_action_u diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 89c5e4352..a22eb174d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -509,6 +509,11 @@ pub const Action = union(enum) { /// This currently only works on macOS. toggle_visibility, + /// Check for updates. + /// + /// This currently only works on macOS. + check_for_updates, + /// Quit ghostty. quit, @@ -791,6 +796,7 @@ pub const Action = union(enum) { .quit, .toggle_quick_terminal, .toggle_visibility, + .check_for_updates, => .app, // These are app but can be special-cased in a surface context. diff --git a/src/input/command.zig b/src/input/command.zig index 1f685269b..8ef4a5f0e 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -364,6 +364,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle secure input mode.", }}, + .check_for_updates => comptime &.{.{ + .action = .check_for_updates, + .title = "Check for Updates", + .description = "Check for updates to the application.", + }}, + .quit => comptime &.{.{ .action = .quit, .title = "Quit", From f6d56f4f03ceb33b52e8cc9358eb7128807652f4 Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Thu, 15 May 2025 23:26:47 +0800 Subject: [PATCH 042/245] Handle check_for_updates as unimplemented action --- src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 1 + 2 files changed, 2 insertions(+) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index e416d5645..221d5344a 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -249,6 +249,7 @@ pub const App = struct { .prompt_title, .reset_window_size, .ring_bell, + .check_for_updates, => { log.info("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 06cc41b9d..cddcf7159 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -504,6 +504,7 @@ pub fn performAction( .renderer_health, .color_change, .reset_window_size, + .check_for_updates, => { log.warn("unimplemented action={}", .{action}); return false; From 048e4acb2c744620b6aa9de5b2e26dfd2bb1b3cb Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Tue, 22 Apr 2025 13:09:15 +0800 Subject: [PATCH 043/245] gtk: implement command palette --- src/apprt/gtk/App.zig | 21 ++- src/apprt/gtk/CommandPalette.zig | 229 +++++++++++++++++++++++ src/apprt/gtk/Window.zig | 22 ++- src/apprt/gtk/gresource.zig | 1 + src/apprt/gtk/style.css | 16 ++ src/apprt/gtk/ui/1.5/command-palette.blp | 106 +++++++++++ src/config/Config.zig | 14 +- src/input/Binding.zig | 2 - 8 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 src/apprt/gtk/CommandPalette.zig create mode 100644 src/apprt/gtk/ui/1.5/command-palette.blp diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 06cc41b9d..e29cbc306 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -492,11 +492,11 @@ pub fn performAction( .toggle_quick_terminal => return try self.toggleQuickTerminal(), .secure_input => self.setSecureInput(target, value), .ring_bell => try self.ringBell(target), + .toggle_command_palette => try self.toggleCommandPalette(target), // Unimplemented .close_all_windows, .float_window, - .toggle_command_palette, .toggle_visibility, .cell_size, .key_sequence, @@ -750,7 +750,7 @@ fn toggleWindowDecorations( .surface => |v| { const window = v.rt_surface.container.window() orelse { log.info( - "toggleFullscreen invalid for container={s}", + "toggleWindowDecorations invalid for container={s}", .{@tagName(v.rt_surface.container)}, ); return; @@ -792,6 +792,23 @@ fn ringBell(_: *App, target: apprt.Target) !void { } } +fn toggleCommandPalette(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |surface| { + const window = surface.rt_surface.container.window() orelse { + log.info( + "toggleCommandPalette invalid for container={s}", + .{@tagName(surface.rt_surface.container)}, + ); + return; + }; + + window.toggleCommandPalette(); + }, + } +} + fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { switch (mode) { .start => self.startQuitTimer(), diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig new file mode 100644 index 000000000..ce6d035a5 --- /dev/null +++ b/src/apprt/gtk/CommandPalette.zig @@ -0,0 +1,229 @@ +const CommandPalette = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const adw = @import("adw"); +const gio = @import("gio"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const configpkg = @import("../../config.zig"); +const inputpkg = @import("../../input.zig"); +const key = @import("key.zig"); +const Builder = @import("Builder.zig"); +const Window = @import("Window.zig"); + +const log = std.log.scoped(.command_palette); + +window: *Window, + +arena: std.heap.ArenaAllocator, + +/// The dialog object containing the palette UI. +dialog: *adw.Dialog, + +/// The search input text field. +search: *gtk.SearchEntry, + +/// The view containing each result row. +view: *gtk.ListView, + +/// The model that provides filtered data for the view to display. +model: *gio.ListModel, + +/// The list that serves as the data source of the model. +/// This is where all command data is ultimately stored. +source: *gio.ListStore, + +pub fn init(self: *CommandPalette, window: *Window) !void { + // Register the custom command type *before* initializing the builder + // If we don't do this now, the builder will complain that it doesn't know + // about this type and fail to initialize + _ = Command.getGObjectType(); + + var builder = Builder.init("command-palette", 1, 5); + + self.* = .{ + .window = window, + .arena = .init(window.app.core_app.alloc), + .dialog = builder.getObject(adw.Dialog, "command-palette").?, + .search = builder.getObject(gtk.SearchEntry, "search").?, + .view = builder.getObject(gtk.ListView, "view").?, + .model = builder.getObject(gio.ListModel, "model").?, + .source = builder.getObject(gio.ListStore, "source").?, + }; + + // Manually take a reference here so that the dialog + // remains in memory after closing + self.dialog.ref(); + errdefer self.dialog.unref(); + + _ = gtk.SearchEntry.signals.stop_search.connect( + self.search, + *CommandPalette, + searchStopped, + self, + .{}, + ); + + _ = gtk.SearchEntry.signals.activate.connect( + self.search, + *CommandPalette, + searchActivated, + self, + .{}, + ); + + _ = gtk.ListView.signals.activate.connect( + self.view, + *CommandPalette, + rowActivated, + self, + .{}, + ); + + try self.updateConfig(&self.window.app.config); +} + +pub fn deinit(self: *CommandPalette) void { + self.arena.deinit(); + self.dialog.unref(); +} + +pub fn toggle(self: *CommandPalette) void { + self.dialog.present(self.window.window.as(gtk.Widget)); +} + +pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { + // Clear existing binds and clear allocated data + self.source.removeAll(); + _ = self.arena.reset(.retain_capacity); + + // TODO: Allow user-configured palette entries + for (inputpkg.command.defaults) |command| { + const cmd = try Command.new( + self.arena.allocator(), + command, + config.keybind.set, + ); + self.source.append(cmd.as(gobject.Object)); + } +} + +fn activated(self: *CommandPalette, pos: c_uint) void { + // Use self.model and not self.source here to use the list of *visible* results + const object = self.model.as(gio.ListModel).getObject(pos) orelse return; + const cmd = gobject.ext.cast(Command, object) orelse return; + + const action = inputpkg.Binding.Action.parse( + std.mem.span(cmd.cmd_c.action_key), + ) catch |err| { + log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err }); + return; + }; + + self.window.performBindingAction(action); + _ = self.dialog.close(); +} + +fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // ESC was pressed - close the palette + _ = self.dialog.close(); +} + +fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // If Enter is pressed in the search bar, + // then activate the first entry (if any) + self.activated(0); +} + +fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { + self.activated(pos); +} + +/// Object that wraps around a command. +/// +/// As GTK list models only accept objects that are within the GObject hierarchy, +/// we have to construct a wrapper to be easily consumed by the list model. +const Command = extern struct { + parent: Parent, + cmd_c: inputpkg.Command.C, + + pub const getGObjectType = gobject.ext.defineClass(Command, .{ + .name = "GhosttyCommand", + .classInit = Class.init, + }); + + pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command { + const self = gobject.ext.newInstance(Command, .{}); + var buf: [64]u8 = undefined; + + const action = action: { + const trigger = keybinds.getTrigger(cmd.action) orelse break :action null; + const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null; + break :action try alloc.dupeZ(u8, accel); + }; + + self.cmd_c = .{ + .title = cmd.title.ptr, + .description = cmd.description.ptr, + .action = if (action) |v| v.ptr else "", + .action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}), + }; + + return self; + } + + fn as(self: *Command, comptime T: type) *T { + return gobject.ext.as(T, self); + } + + pub const Parent = gobject.Object; + + pub const Class = extern struct { + parent: Parent.Class, + + pub const Instance = Command; + + pub fn init(class: *Class) callconv(.c) void { + const info = @typeInfo(inputpkg.Command.C).@"struct"; + + // Expose all fields on the Command.C struct as properties + // that can be accessed by the GObject type system + // (and by extension, blueprints) + const properties = comptime props: { + var props: [info.fields.len]type = undefined; + + for (info.fields, 0..) |field, i| { + const accessor = struct { + fn getter(cmd: *Command) ?[:0]const u8 { + return std.mem.span(@field(cmd.cmd_c, field.name)); + } + }; + + // "Canonicalize" field names into the format GObject expects + const prop_name = prop_name: { + var buf: [field.name.len:0]u8 = undefined; + _ = std.mem.replace(u8, field.name, "_", "-", &buf); + break :prop_name buf; + }; + + props[i] = gobject.ext.defineProperty( + &prop_name, + Command, + ?[:0]const u8, + .{ + .default = null, + .accessor = .{ .getter = &accessor.getter }, + }, + ); + } + + break :props props; + }; + + gobject.ext.registerProperties(class, &properties); + } + }; +}; diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index d82087ff0..f2dde2ab9 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -34,6 +34,7 @@ const gtk_key = @import("key.zig"); const TabView = @import("TabView.zig"); const HeaderBar = @import("headerbar.zig"); const CloseDialog = @import("CloseDialog.zig"); +const CommandPalette = @import("CommandPalette.zig"); const winprotopkg = @import("winproto.zig"); const gtk_version = @import("gtk_version.zig"); const adw_version = @import("adw_version.zig"); @@ -67,6 +68,9 @@ titlebar_menu: Menu(Window, "titlebar_menu", true), /// The libadwaita widget for receiving toast send requests. toast_overlay: *adw.ToastOverlay, +/// The command palette. +command_palette: CommandPalette, + /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c_uint = null, @@ -139,6 +143,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .titlebar_menu = undefined, .toast_overlay = undefined, + .command_palette = undefined, .winproto = .none, }; @@ -167,6 +172,8 @@ pub fn init(self: *Window, app: *App) !void { // Setup our notebook self.notebook.init(self); + if (adw_version.supportsDialogs()) try self.command_palette.init(self); + // If we are using Adwaita, then we can support the tab overview. self.tab_overview = if (adw_version.supportsTabOverview()) overview: { const tab_overview = adw.TabOverview.new(); @@ -460,6 +467,9 @@ pub fn updateConfig( // We always resync our appearance whenever the config changes. try self.syncAppearance(); + + // Update binds inside the command palette + try self.command_palette.updateConfig(config); } /// Updates appearance based on config settings. Will be called once upon window @@ -600,6 +610,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { self.winproto.deinit(self.app.core_app.alloc); + if (adw_version.supportsDialogs()) self.command_palette.deinit(); if (self.adw_tab_overview_focus_timer) |timer| { _ = glib.Source.remove(timer); @@ -729,6 +740,15 @@ pub fn toggleWindowDecorations(self: *Window) void { }; } +/// Toggle the window decorations for this window. +pub fn toggleCommandPalette(self: *Window) void { + if (adw_version.supportsDialogs()) { + self.command_palette.toggle(); + } else { + log.warn("libadwaita 1.5+ is required for the command palette", .{}); + } +} + /// Grabs focus on the currently selected tab. pub fn focusCurrentTab(self: *Window) void { const tab = self.notebook.currentTab() orelse return; @@ -820,7 +840,7 @@ fn gtkWindowUpdateScaleFactor( } /// Perform a binding action on the window's action surface. -fn performBindingAction(self: *Window, action: input.Binding.Action) void { +pub fn performBindingAction(self: *Window, action: input.Binding.Action) void { const surface = self.actionSurface() orelse return; _ = surface.performBindingAction(action) catch |err| { log.warn("error performing binding action error={}", .{err}); diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index a1db8ac62..45623ab2a 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -63,6 +63,7 @@ pub const blueprint_files = [_]VersionedBlueprint{ .{ .major = 1, .minor = 5, .name = "prompt-title-dialog" }, .{ .major = 1, .minor = 5, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" }, + .{ .major = 1, .minor = 5, .name = "command-palette" }, .{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" }, .{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" }, .{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" }, diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index ecaef6b33..7c4b53d03 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -73,3 +73,19 @@ window.ssd.no-border-radius { filter: blur(5px); transition: filter 0.3s ease; } + +.command-palette-search { + font-size: 1.25rem; + padding: 4px; + -gtk-icon-size: 20px; +} + +.command-palette-search > image:first-child { + margin-left: 8px; + margin-right: 4px; +} + +.command-palette-search > image:last-child { + margin-left: 4px; + margin-right: 8px; +} diff --git a/src/apprt/gtk/ui/1.5/command-palette.blp b/src/apprt/gtk/ui/1.5/command-palette.blp new file mode 100644 index 000000000..76bcc1700 --- /dev/null +++ b/src/apprt/gtk/ui/1.5/command-palette.blp @@ -0,0 +1,106 @@ +using Gtk 4.0; +using Gio 2.0; +using Adw 1; + +Adw.Dialog command-palette { + content-width: 700; + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + [title] + SearchEntry search { + hexpand: true; + placeholder-text: _("Execute a command…"); + + styles [ + "command-palette-search", + ] + } + } + + ScrolledWindow { + min-content-height: 300; + + ListView view { + show-separators: true; + single-click-activate: true; + + model: NoSelection model { + model: FilterListModel { + incremental: true; + + filter: AnyFilter { + StringFilter { + expression: expr item as <$GhosttyCommand>.title; + search: bind search.text; + } + + StringFilter { + expression: expr item as <$GhosttyCommand>.action-key; + search: bind search.text; + } + }; + + model: Gio.ListStore source { + item-type: typeof<$GhosttyCommand>; + }; + }; + }; + + styles [ + "rich-list", + ] + + factory: BuilderListItemFactory { + template ListItem { + child: Box { + orientation: horizontal; + spacing: 10; + tooltip-text: bind template.item as <$GhosttyCommand>.description; + + Box { + orientation: vertical; + hexpand: true; + + Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "title", + ] + + label: bind template.item as <$GhosttyCommand>.title; + } + + Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "subtitle", + "monospace", + ] + + label: bind template.item as <$GhosttyCommand>.action-key; + } + } + + ShortcutLabel { + accelerator: bind template.item as <$GhosttyCommand>.action; + valign: center; + } + }; + } + }; + } + } + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index ca330f8f6..765b63c46 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4736,6 +4736,13 @@ pub const Keybinds = struct { .{ .toggle_split_zoom = {} }, ); + // Toggle command palette, matches VSCode + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'p' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .toggle_command_palette, + ); + // Mac-specific keyboard bindings. if (comptime builtin.target.os.tag.isDarwin()) { try self.set.put( @@ -4908,13 +4915,6 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); - // Toggle command palette, matches VSCode - try self.set.put( - alloc, - .{ .key = .{ .unicode = 'p' }, .mods = .{ .super = true, .shift = true } }, - .{ .toggle_command_palette = {} }, - ); - // Inspector, matching Chromium try self.set.put( alloc, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 89c5e4352..eed137948 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -455,8 +455,6 @@ pub const Action = union(enum) { /// that lets you see what actions you can perform, their associated /// keybindings (if any), a search bar to filter the actions, and /// the ability to then execute the action. - /// - /// This only works on macOS. toggle_command_palette, /// Toggle the "quick" terminal. The quick terminal is a terminal that From 3b013b117487ff4c7b8e3a2a335a1624143a61c0 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 23 Apr 2025 15:55:23 +0800 Subject: [PATCH 044/245] gtk: add command palette to titlebar menu --- src/apprt/gtk/App.zig | 1 + src/apprt/gtk/Window.zig | 9 +++++++++ src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index e29cbc306..4fbdec7a7 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1047,6 +1047,7 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); try self.syncActionAccelerator("win.close", .{ .close_window = {} }); try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index f2dde2ab9..4a5926a97 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -587,6 +587,7 @@ fn initActions(self: *Window) void { .{ "split-left", gtkActionSplitLeft }, .{ "split-up", gtkActionSplitUp }, .{ "toggle-inspector", gtkActionToggleInspector }, + .{ "toggle-command-palette", gtkActionToggleCommandPalette }, .{ "copy", gtkActionCopy }, .{ "paste", gtkActionPaste }, .{ "reset", gtkActionReset }, @@ -1102,6 +1103,14 @@ fn gtkActionToggleInspector( self.performBindingAction(.{ .inspector = .toggle }); } +fn gtkActionToggleCommandPalette( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, +) callconv(.C) void { + self.performBindingAction(.toggle_command_palette); +} + fn gtkActionCopy( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp b/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp index 71e7d060c..3273aa81c 100644 --- a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp +++ b/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp @@ -81,6 +81,11 @@ menu menu { } section { + item { + label: _("Command Palette"); + action: "win.toggle-command-palette"; + } + item { label: _("Terminal Inspector"); action: "win.toggle-inspector"; From e97dfc2e196fc5cedf754f433a48d9e8f4889df6 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 23 Apr 2025 16:03:09 +0800 Subject: [PATCH 045/245] gtk(command_palette): filter out certain actions --- src/apprt/gtk/CommandPalette.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index ce6d035a5..b72eaa8d2 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -102,6 +102,16 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi // TODO: Allow user-configured palette entries for (inputpkg.command.defaults) |command| { + // Filter out actions that are not implemented + // or don't make sense for GTK + switch (command.action) { + .close_all_windows, + .toggle_secure_input, + => continue, + + else => {}, + } + const cmd = try Command.new( self.arena.allocator(), command, From 91f811bfbf1e96cff608e181d327ee1e154e828d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 23 Apr 2025 16:58:45 +0800 Subject: [PATCH 046/245] translations: update --- po/com.mitchellh.ghostty.pot | 42 +++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index 3892d14d8..d6a99d01d 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-22 08:57-0700\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -50,7 +50,7 @@ msgstr "" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 msgid "Reload Configuration" msgstr "" @@ -78,6 +78,10 @@ msgstr "" msgid "Split Right" msgstr "" +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "" + #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 msgid "Copy" @@ -115,7 +119,7 @@ msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:248 +#: src/apprt/gtk/Window.zig:255 msgid "New Tab" msgstr "" @@ -143,20 +147,24 @@ msgid "Config" msgstr "" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 msgid "Open Configuration" msgstr "" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" msgstr "" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:102 -#: src/apprt/gtk/Window.zig:1003 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 msgid "About Ghostty" msgstr "" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 msgid "Quit" msgstr "" @@ -197,31 +205,35 @@ msgid "" "commands may be executed." msgstr "" -#: src/apprt/gtk/Window.zig:201 +#: src/apprt/gtk/Window.zig:208 msgid "Main Menu" msgstr "" -#: src/apprt/gtk/Window.zig:222 +#: src/apprt/gtk/Window.zig:229 msgid "View Open Tabs" msgstr "" -#: src/apprt/gtk/Window.zig:249 +#: src/apprt/gtk/Window.zig:256 msgid "New Split" msgstr "" -#: src/apprt/gtk/Window.zig:312 +#: src/apprt/gtk/Window.zig:319 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "" -#: src/apprt/gtk/Window.zig:744 +#: src/apprt/gtk/Window.zig:765 msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/Window.zig:984 +#: src/apprt/gtk/Window.zig:1005 msgid "Ghostty Developers" msgstr "" +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "" + #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" msgstr "" @@ -261,7 +273,3 @@ msgstr "" #: src/apprt/gtk/Surface.zig:1243 msgid "Copied to clipboard" msgstr "" - -#: src/apprt/gtk/inspector.zig:144 -msgid "Ghostty: Terminal Inspector" -msgstr "" From 7293d91f1095d9ae7f380ff29c073ad3c5618de0 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 24 Apr 2025 12:04:39 +0800 Subject: [PATCH 047/245] translations(zh_CN): update --- po/zh_CN.UTF-8.po | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 80c3766aa..ee2c51362 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-22 08:57-0700\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" "PO-Revision-Date: 2025-02-27 09:16+0100\n" "Last-Translator: Leah \n" "Language-Team: Chinese (simplified) \n" @@ -51,7 +51,7 @@ msgstr "忽略" #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 msgid "Reload Configuration" msgstr "重新加载配置" @@ -79,6 +79,10 @@ msgstr "向左分屏" msgid "Split Right" msgstr "向右分屏" +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "选择要执行的命令……" + #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 msgid "Copy" @@ -116,7 +120,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 -#: src/apprt/gtk/Window.zig:248 +#: src/apprt/gtk/Window.zig:255 msgid "New Tab" msgstr "新建标签页" @@ -144,20 +148,24 @@ msgid "Config" msgstr "配置" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 msgid "Open Configuration" msgstr "打开配置文件" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "命令面板" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" msgstr "终端调试器" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:102 -#: src/apprt/gtk/Window.zig:1003 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 msgid "About Ghostty" msgstr "关于 Ghostty" -#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 msgid "Quit" msgstr "退出" @@ -198,31 +206,35 @@ msgid "" "commands may be executed." msgstr "将以下内容粘贴至终端内将可能执行有害命令。" -#: src/apprt/gtk/Window.zig:201 +#: src/apprt/gtk/Window.zig:208 msgid "Main Menu" msgstr "主菜单" -#: src/apprt/gtk/Window.zig:222 +#: src/apprt/gtk/Window.zig:229 msgid "View Open Tabs" msgstr "浏览标签页" -#: src/apprt/gtk/Window.zig:249 +#: src/apprt/gtk/Window.zig:256 msgid "New Split" -msgstr "" +msgstr "新建分屏" -#: src/apprt/gtk/Window.zig:312 +#: src/apprt/gtk/Window.zig:319 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "⚠️ Ghostty 正在以调试模式运行!性能将大打折扣。" -#: src/apprt/gtk/Window.zig:744 +#: src/apprt/gtk/Window.zig:765 msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/Window.zig:984 +#: src/apprt/gtk/Window.zig:1005 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty 终端调试器" + #: src/apprt/gtk/CloseDialog.zig:47 msgid "Close" msgstr "关闭" @@ -262,7 +274,3 @@ msgstr "分屏内正在运行中的进程将被终止。" #: src/apprt/gtk/Surface.zig:1243 msgid "Copied to clipboard" msgstr "已复制至剪贴板" - -#: src/apprt/gtk/inspector.zig:144 -msgid "Ghostty: Terminal Inspector" -msgstr "Ghostty 终端调试器" From 2800e0c99b8b95c84200c2fba736f9298e77e9ca Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 24 Apr 2025 12:39:36 +0800 Subject: [PATCH 048/245] gtk(command_palette): address feedback related to selections See #7173, #7175 --- src/apprt/gtk/CommandPalette.zig | 9 ++++----- src/apprt/gtk/ui/1.5/command-palette.blp | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index b72eaa8d2..07b63d99c 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -30,7 +30,7 @@ search: *gtk.SearchEntry, view: *gtk.ListView, /// The model that provides filtered data for the view to display. -model: *gio.ListModel, +model: *gtk.SingleSelection, /// The list that serves as the data source of the model. /// This is where all command data is ultimately stored. @@ -50,7 +50,7 @@ pub fn init(self: *CommandPalette, window: *Window) !void { .dialog = builder.getObject(adw.Dialog, "command-palette").?, .search = builder.getObject(gtk.SearchEntry, "search").?, .view = builder.getObject(gtk.ListView, "view").?, - .model = builder.getObject(gio.ListModel, "model").?, + .model = builder.getObject(gtk.SingleSelection, "model").?, .source = builder.getObject(gio.ListStore, "source").?, }; @@ -143,9 +143,8 @@ fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { } fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { - // If Enter is pressed in the search bar, - // then activate the first entry (if any) - self.activated(0); + // If Enter is pressed, activate the selected entry + self.activated(self.model.getSelected()); } fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { diff --git a/src/apprt/gtk/ui/1.5/command-palette.blp b/src/apprt/gtk/ui/1.5/command-palette.blp index 76bcc1700..a84482091 100644 --- a/src/apprt/gtk/ui/1.5/command-palette.blp +++ b/src/apprt/gtk/ui/1.5/command-palette.blp @@ -28,7 +28,7 @@ Adw.Dialog command-palette { show-separators: true; single-click-activate: true; - model: NoSelection model { + model: SingleSelection model { model: FilterListModel { incremental: true; From cc65dfc90ee41e0925c0664466287c3803ddf858 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 15 May 2025 17:58:55 +0200 Subject: [PATCH 049/245] gtk(command_palette): focus fixes --- src/apprt/gtk/CommandPalette.zig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index 07b63d99c..fda2c5ca8 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -93,6 +93,9 @@ pub fn deinit(self: *CommandPalette) void { pub fn toggle(self: *CommandPalette) void { self.dialog.present(self.window.window.as(gtk.Widget)); + + // Focus on the search bar when opening the dialog + self.dialog.setFocus(self.search.as(gtk.Widget)); } pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { @@ -126,6 +129,12 @@ fn activated(self: *CommandPalette, pos: c_uint) void { const object = self.model.as(gio.ListModel).getObject(pos) orelse return; const cmd = gobject.ext.cast(Command, object) orelse return; + // Close before running the action in order to avoid being replaced by another + // dialog (such as the change title dialog). If that occurs then the command + // palette dialog won't be counted as having closed properly and cannot + // receive focus when reopened. + _ = self.dialog.close(); + const action = inputpkg.Binding.Action.parse( std.mem.span(cmd.cmd_c.action_key), ) catch |err| { @@ -134,7 +143,6 @@ fn activated(self: *CommandPalette, pos: c_uint) void { }; self.window.performBindingAction(action); - _ = self.dialog.close(); } fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { From f343e1ba461b00baf380539ac7a8f161d600f60e Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Fri, 16 May 2025 00:40:25 +0800 Subject: [PATCH 050/245] Fix comma typo --- macos/Sources/Ghostty/Ghostty.App.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 7b9e49f4c..6736449a4 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -592,7 +592,7 @@ extension Ghostty { } private static func checkForUpdates( - _ app: ghostty_app_t, + _ app: ghostty_app_t ) { if let appDelegate = NSApplication.shared.delegate as? AppDelegate { appDelegate.checkForUpdates(nil) From 709b0214a022b31c570f883d4d513f79c4b6d520 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 15 May 2025 11:37:27 -0600 Subject: [PATCH 051/245] fix(renderer): Don't force images to grid/cell sizes This problem was introduced by f091a69 (PR #6675). I've gone ahead and overhauled the placement positioning logic as well; it was doing a lot of expensive calls before, I've significantly reduced that. Clipping partially off-screen images is now handled entirely by the renderer, rather than while preparing the placement, and as such the grid position passed to the image shader is now signed. --- src/renderer/Metal.zig | 75 +++------- src/renderer/OpenGL.zig | 79 ++++------- src/renderer/metal/image.zig | 8 +- src/renderer/opengl/ImageProgram.zig | 8 +- src/renderer/opengl/image.zig | 4 +- src/terminal/kitty/graphics_storage.zig | 177 ++++++++++++++++-------- 6 files changed, 174 insertions(+), 177 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index ddc94b1ec..99dbc838e 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1872,6 +1872,8 @@ fn prepKittyGraphics( // points. This lets us determine offsets and containment of placements. const top = t.screen.pages.getTopLeft(.viewport); const bot = t.screen.pages.getBottomRight(.viewport).?; + const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -1903,7 +1905,7 @@ fn prepKittyGraphics( continue; }; - try self.prepKittyPlacement(t, &top, &bot, &image, p); + try self.prepKittyPlacement(t, top_y, bot_y, &image, p); } // If we have virtual placements then we need to scan for placeholders. @@ -2009,8 +2011,8 @@ fn prepKittyVirtualPlacement( fn prepKittyPlacement( self: *Metal, t: *terminal.Terminal, - top: *const terminal.Pin, - bot: *const terminal.Pin, + top_y: u32, + bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, ) !void { @@ -2018,78 +2020,47 @@ fn prepKittyPlacement( // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; + // This is expensive but necessary. + const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + // If the selection isn't within our viewport then skip it. - if (bot.before(rect.top_left)) return; - if (rect.bottom_right.before(top.*)) return; - - // If the top left is outside the viewport we need to calc an offset - // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.before(top.*)) offset_y: { - const vp_y = t.screen.pages.pointFromPin(.screen, top.*).?.screen.y; - const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const offset_cells = vp_y - img_y; - const offset_pixels = offset_cells * self.grid_metrics.cell_height; - break :offset_y @intCast(offset_pixels); - } else 0; - - // Get the grid size that respects aspect ratio - const grid_size = p.gridSize(image.*, t); - - // If we specify `rows` then our offset above is in viewport space - // and not in the coordinate space of the source image. Without `rows` - // that's one and the same. - const source_offset_y: u32 = if (grid_size.rows > 0) source_offset_y: { - // Determine the scale factor to apply for this row height. - const image_height: f64 = @floatFromInt(image.height); - const viewport_height: f64 = @floatFromInt(grid_size.rows * self.grid_metrics.cell_height); - const scale: f64 = image_height / viewport_height; - - // Apply the scale to the offset - const offset_y_f64: f64 = @floatFromInt(offset_y); - const source_offset_y_f64: f64 = offset_y_f64 * scale; - break :source_offset_y @intFromFloat(@round(source_offset_y_f64)); - } else offset_y; + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; // We need to prep this image for upload if it isn't in the cache OR // it is in the cache but the transmit time doesn't match meaning this // image is different. try self.prepKittyImage(image); - // Convert our screen point to a viewport point - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( - .viewport, - rect.top_left, - ) orelse .{ .viewport = .{} }; + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); // Calculate the source rectangle const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y + source_offset_y); + const source_y = @min(image.height, p.source_y); const source_width = if (p.source_width > 0) @min(image.width - source_x, p.source_width) else image.width; const source_height = if (p.source_height > 0) - @min(image.height, p.source_height) + @min(image.height - source_y, p.source_height) else - image.height -| source_y; + image.height; - // Calculate the width/height of our image. - const dest_width = grid_size.cols * self.grid_metrics.cell_width; - const dest_height = if (grid_size.rows > 0) rows: { - // Clip to the viewport to handle scrolling. offset_y is already in - // viewport scale so we can subtract it directly. - break :rows (grid_size.rows * self.grid_metrics.cell_height) - offset_y; - } else source_height; + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); // Accumulate the placement - if (image.width > 0 and image.height > 0) { + if (dest_size.width > 0 and dest_size.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = image.id, .x = @intCast(rect.top_left.x), - .y = @intCast(viewport.viewport.y), + .y = y_pos, .z = p.z, - .width = dest_width, - .height = dest_height, + .width = dest_size.width, + .height = dest_size.height, .cell_offset_x = p.x_offset, .cell_offset_y = p.y_offset, .source_x = source_x, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index a3a2d8f7e..d0222a390 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -913,6 +913,8 @@ fn prepKittyGraphics( // points. This lets us determine offsets and containment of placements. const top = t.screen.pages.getTopLeft(.viewport); const bot = t.screen.pages.getBottomRight(.viewport).?; + const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; // Go through the placements and ensure the image is loaded on the GPU. var it = storage.placements.iterator(); @@ -944,7 +946,7 @@ fn prepKittyGraphics( continue; }; - try self.prepKittyPlacement(t, &top, &bot, &image, p); + try self.prepKittyPlacement(t, top_y, bot_y, &image, p); } // If we have virtual placements then we need to scan for placeholders. @@ -1050,8 +1052,8 @@ fn prepKittyVirtualPlacement( fn prepKittyPlacement( self: *OpenGL, t: *terminal.Terminal, - top: *const terminal.Pin, - bot: *const terminal.Pin, + top_y: u32, + bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, ) !void { @@ -1059,78 +1061,47 @@ fn prepKittyPlacement( // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; + // This is expensive but necessary. + const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + // If the selection isn't within our viewport then skip it. - if (bot.before(rect.top_left)) return; - if (rect.bottom_right.before(top.*)) return; - - // If the top left is outside the viewport we need to calc an offset - // so that we render (0, 0) with some offset for the texture. - const offset_y: u32 = if (rect.top_left.before(top.*)) offset_y: { - const vp_y = t.screen.pages.pointFromPin(.screen, top.*).?.screen.y; - const img_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const offset_cells = vp_y - img_y; - const offset_pixels = offset_cells * self.grid_metrics.cell_height; - break :offset_y @intCast(offset_pixels); - } else 0; - - // Get the grid size that respects aspect ratio - const grid_size = p.gridSize(image.*, t); - - // If we specify `rows` then our offset above is in viewport space - // and not in the coordinate space of the source image. Without `rows` - // that's one and the same. - const source_offset_y: u32 = if (grid_size.rows > 0) source_offset_y: { - // Determine the scale factor to apply for this row height. - const image_height: f64 = @floatFromInt(image.height); - const viewport_height: f64 = @floatFromInt(grid_size.rows * self.grid_metrics.cell_height); - const scale: f64 = image_height / viewport_height; - - // Apply the scale to the offset - const offset_y_f64: f64 = @floatFromInt(offset_y); - const source_offset_y_f64: f64 = offset_y_f64 * scale; - break :source_offset_y @intFromFloat(@round(source_offset_y_f64)); - } else offset_y; + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; // We need to prep this image for upload if it isn't in the cache OR // it is in the cache but the transmit time doesn't match meaning this // image is different. try self.prepKittyImage(image); - // Convert our screen point to a viewport point - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( - .viewport, - rect.top_left, - ) orelse .{ .viewport = .{} }; + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); // Calculate the source rectangle const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y + source_offset_y); + const source_y = @min(image.height, p.source_y); const source_width = if (p.source_width > 0) @min(image.width - source_x, p.source_width) else image.width; const source_height = if (p.source_height > 0) - @min(image.height, p.source_height) + @min(image.height - source_y, p.source_height) else - image.height -| source_y; + image.height; - // Calculate the width/height of our image. - const dest_width = grid_size.cols * self.grid_metrics.cell_width; - const dest_height = if (grid_size.rows > 0) rows: { - // Clip to the viewport to handle scrolling. offset_y is already in - // viewport scale so we can subtract it directly. - break :rows (grid_size.rows * self.grid_metrics.cell_height) - offset_y; - } else source_height; + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); // Accumulate the placement - if (image.width > 0 and image.height > 0) { + if (dest_size.width > 0 and dest_size.height > 0) { try self.image_placements.append(self.alloc, .{ .image_id = image.id, .x = @intCast(rect.top_left.x), - .y = @intCast(viewport.viewport.y), + .y = y_pos, .z = p.z, - .width = dest_width, - .height = dest_height, + .width = dest_size.width, + .height = dest_size.height, .cell_offset_x = p.x_offset, .cell_offset_y = p.y_offset, .source_x = source_x, @@ -2511,8 +2482,8 @@ fn drawImages( // Setup our data try bind.vbo.setData(ImageProgram.Input{ - .grid_col = @intCast(p.x), - .grid_row = @intCast(p.y), + .grid_col = p.x, + .grid_row = p.y, .cell_offset_x = p.cell_offset_x, .cell_offset_y = p.cell_offset_y, .source_x = p.source_x, diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig index cb675404d..ff13a49e8 100644 --- a/src/renderer/metal/image.zig +++ b/src/renderer/metal/image.zig @@ -13,16 +13,16 @@ pub const Placement = struct { image_id: u32, /// The grid x/y where this placement is located. - x: u32, - y: u32, + x: i32, + y: i32, z: i32, /// The width/height of the placed image. width: u32, height: u32, - /// The offset in pixels from the top left of the cell. This is - /// clamped to the size of a cell. + /// The offset in pixels from the top left of the cell. + /// This is clamped to the size of a cell. cell_offset_x: u32, cell_offset_y: u32, diff --git a/src/renderer/opengl/ImageProgram.zig b/src/renderer/opengl/ImageProgram.zig index e53891818..ff6794085 100644 --- a/src/renderer/opengl/ImageProgram.zig +++ b/src/renderer/opengl/ImageProgram.zig @@ -11,8 +11,8 @@ vbo: gl.Buffer, pub const Input = extern struct { /// vec2 grid_coord - grid_col: u16, - grid_row: u16, + grid_col: i32, + grid_row: i32, /// vec2 cell_offset cell_offset_x: u32 = 0, @@ -66,8 +66,8 @@ pub fn init() !ImageProgram { var vbobind = try vbo.bind(.array); defer vbobind.unbind(); var offset: usize = 0; - try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(Input), offset); - offset += 2 * @sizeOf(u16); + try vbobind.attributeAdvanced(0, 2, gl.c.GL_INT, false, @sizeOf(Input), offset); + offset += 2 * @sizeOf(i32); try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); offset += 2 * @sizeOf(u32); try vbobind.attributeAdvanced(2, 4, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig index 85f59f1f3..b22d10ea3 100644 --- a/src/renderer/opengl/image.zig +++ b/src/renderer/opengl/image.zig @@ -11,8 +11,8 @@ pub const Placement = struct { image_id: u32, /// The grid x/y where this placement is located. - x: u32, - y: u32, + x: i32, + y: i32, z: i32, /// The width/height of the placed image. diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 06769dc3c..6e336e785 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -658,6 +658,86 @@ pub const ImageStorage = struct { } } + /// Calculates the size of this placement's image in pixels, + /// taking in to account the specified rows and columns. + pub fn calculatedSize( + self: Placement, + image: Image, + t: *const terminal.Terminal, + ) struct { + width: u32, + height: u32, + } { + // Height / width of the image in px. + const width = if (self.source_width > 0) self.source_width else image.width; + const height = if (self.source_height > 0) self.source_height else image.height; + + // If we don't have any specified cols or rows then the placement + // should be the native size of the image, and doesn't need to be + // re-scaled. + if (self.columns == 0 and self.rows == 0) return .{ + .width = width, + .height = height, + }; + + // We calculate the size of a cell so that we can multiply + // it by the specified cols/rows to get the correct px size. + // + // We assume that the width is divided evenly by the column + // count and the height by the row count, because it should be. + const cell_width: u32 = t.width_px / t.cols; + const cell_height: u32 = t.height_px / t.rows; + + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + + // If we have a specified cols AND rows then we calculate + // the width and height from them directly, we don't need + // to adjust for aspect ratio. + if (self.columns > 0 and self.rows > 0) { + const calc_width = cell_width * self.columns; + const calc_height = cell_height * self.rows; + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Either the columns or the rows were specified, but not both, + // so we need to calculate the other one based on the aspect ratio. + + // If only the columns were specified, we determine + // the height of the image based on the aspect ratio. + if (self.columns > 0) { + const aspect = height_f64 / width_f64; + const calc_width: u32 = cell_width * self.columns; + const calc_height: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_width)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + + // Otherwise, only the rows were specified, so we + // determine the width based on the aspect ratio. + { + const aspect = width_f64 / height_f64; + const calc_height: u32 = cell_height * self.rows; + const calc_width: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(calc_height)) * aspect, + )); + + return .{ + .width = calc_width, + .height = calc_height, + }; + } + } + /// Returns the size in grid cells that this placement takes up. pub fn gridSize( self: Placement, @@ -667,60 +747,29 @@ pub const ImageStorage = struct { cols: u32, rows: u32, } { + // If we have a specified columns and rows then this is trivial. if (self.columns > 0 and self.rows > 0) return .{ .cols = self.columns, .rows = self.rows, }; - // Calculate our cell size. - const terminal_width_f64: f64 = @floatFromInt(t.width_px); - const terminal_height_f64: f64 = @floatFromInt(t.height_px); - const grid_columns_f64: f64 = @floatFromInt(t.cols); - const grid_rows_f64: f64 = @floatFromInt(t.rows); - const cell_width_f64 = terminal_width_f64 / grid_columns_f64; - const cell_height_f64 = terminal_height_f64 / grid_rows_f64; - - // Our image width - const width_px = if (self.source_width > 0) self.source_width else image.width; - const height_px = if (self.source_height > 0) self.source_height else image.height; - - // Calculate our image size in grid cells - const width_f64: f64 = @floatFromInt(width_px); - const height_f64: f64 = @floatFromInt(height_px); - - // If only columns is specified, calculate rows based on aspect ratio - if (self.columns > 0 and self.rows == 0) { - const cols_f64: f64 = @floatFromInt(self.columns); - const cols_px = cols_f64 * cell_width_f64; - const aspect_ratio = height_f64 / width_f64; - const rows_px = cols_px * aspect_ratio; - const rows_cells = rows_px / cell_height_f64; - return .{ - .cols = self.columns, - .rows = @intFromFloat(@ceil(rows_cells)), - }; - } - - // If only rows is specified, calculate columns based on aspect ratio - if (self.rows > 0 and self.columns == 0) { - const rows_f64: f64 = @floatFromInt(self.rows); - const rows_px = rows_f64 * cell_height_f64; - const aspect_ratio = width_f64 / height_f64; - const cols_px = rows_px * aspect_ratio; - const cols_cells = cols_px / cell_width_f64; - return .{ - .cols = @intFromFloat(@ceil(cols_cells)), - .rows = self.rows, - }; - } - - const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); - const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); - + // Otherwise we calculate the pixel size, divide by + // cell size, and round up to the nearest integer. + const calc_size = self.calculatedSize(image, t); return .{ - .cols = width_cells, - .rows = height_cells, + .cols = std.math.divCeil( + u32, + calc_size.width + self.x_offset, + t.width_px / t.cols, + ) catch 0, + .rows = std.math.divCeil( + u32, + calc_size.height + self.y_offset, + t.height_px / t.rows, + ) catch 0, }; + // NOTE: Above `divCeil`s can only fail if the cell size is 0, + // in such a case it seems safe to return 0 for this. } /// Returns a selection of the entire rectangle this placement @@ -1269,36 +1318,42 @@ test "storage: aspect ratio calculation when only columns or rows specified" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); - t.width_px = 100; - t.height_px = 100; + t.width_px = 1000; // 10 px per col + t.height_px = 2000; // 20 px per row // Case 1: Only columns specified { - const image = Image{ .id = 1, .width = 4, .height = 2 }; + const image = Image{ .id = 1, .width = 16, .height = 9 }; var placement = ImageStorage.Placement{ .location = .{ .virtual = {} }, - .columns = 6, + .columns = 10, .rows = 0, }; - const grid_size = placement.gridSize(image, &t); - // 6 columns * (2/4) = 3 rows - try testing.expectEqual(@as(u32, 6), grid_size.cols); - try testing.expectEqual(@as(u32, 3), grid_size.rows); + // Image is 16x9, set to a width of 10 columns, at 10px per column + // that's 100px width. 100px * (9 / 16) = 56.25, which sould round + // to a height of 56px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 100), calc_size.width); + try testing.expectEqual(@as(u32, 56), calc_size.height); } // Case 2: Only rows specified { - const image = Image{ .id = 2, .width = 2, .height = 4 }; + const image = Image{ .id = 2, .width = 16, .height = 9 }; var placement = ImageStorage.Placement{ .location = .{ .virtual = {} }, .columns = 0, - .rows = 6, + .rows = 5, }; - const grid_size = placement.gridSize(image, &t); - // 6 rows * (2/4) = 3 columns - try testing.expectEqual(@as(u32, 3), grid_size.cols); - try testing.expectEqual(@as(u32, 6), grid_size.rows); + // Image is 16x9, set to a height of 5 rows, at 20px per row that's + // 100px height. 100px * (16 / 9) = 177.77..., which should round to + // a width of 178px. + + const calc_size = placement.calculatedSize(image, &t); + try testing.expectEqual(@as(u32, 178), calc_size.width); + try testing.expectEqual(@as(u32, 100), calc_size.height); } } From ed207514e98ff7c810c6fdc1e8905b57f3ecbbb3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 15 May 2025 11:59:17 -0600 Subject: [PATCH 052/245] typo --- src/terminal/kitty/graphics_storage.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 6e336e785..0c3022e4a 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -1331,7 +1331,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" { }; // Image is 16x9, set to a width of 10 columns, at 10px per column - // that's 100px width. 100px * (9 / 16) = 56.25, which sould round + // that's 100px width. 100px * (9 / 16) = 56.25, which should round // to a height of 56px. const calc_size = placement.calculatedSize(image, &t); From e2f3b6211f87ff81c38043d66de21c73e577bebf Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 15 May 2025 12:06:30 -0600 Subject: [PATCH 053/245] fix(Metal): use sRGB texture format for gamma correct interpolation otherwise images will be too dark when scaled --- src/renderer/metal/api.zig | 1 + src/renderer/metal/image.zig | 2 +- src/renderer/shaders/cell.metal | 14 +++++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index 19db17ba4..46cb4f6bc 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -96,6 +96,7 @@ pub const MTLVertexStepFunction = enum(c_ulong) { pub const MTLPixelFormat = enum(c_ulong) { r8unorm = 10, rgba8unorm = 70, + rgba8unorm_srgb = 71, rgba8uint = 73, bgra8unorm = 80, bgra8unorm_srgb = 81, diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig index ff13a49e8..7d2599308 100644 --- a/src/renderer/metal/image.zig +++ b/src/renderer/metal/image.zig @@ -441,7 +441,7 @@ pub const Image = union(enum) { }; // Set our properties - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8unorm)); + desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8unorm_srgb)); desc.setProperty("width", @as(c_ulong, @intCast(p.width))); desc.setProperty("height", @as(c_ulong, @intCast(p.height))); diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 80ffc00de..5b3875221 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -668,12 +668,12 @@ fragment float4 image_fragment( float4 rgba = image.sample(textureSampler, in.tex_coord); - return load_color( - uchar4(rgba * 255.0), - // We assume all images are sRGB regardless of the configured colorspace - // TODO: Maybe support wide gamut images? - false, - uniforms.use_linear_blending - ); + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + + return rgba; } From ea79fdea119b40e4eca875611d4d224bddb963f1 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 15 May 2025 12:22:56 -0600 Subject: [PATCH 054/245] fix(OpenGL): use sRGB texture format for gamma correct interpolation otherwise images will be too dark when scaled --- pkg/opengl/Texture.zig | 3 +++ src/renderer/opengl/image.zig | 4 ++-- src/renderer/shaders/image.f.glsl | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 5804ef538..fa5cf770b 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -70,6 +70,9 @@ pub const InternalFormat = enum(c_int) { rgb = c.GL_RGB, rgba = c.GL_RGBA, + srgb = c.GL_SRGB, + srgba = c.GL_SRGB_ALPHA, + // There are so many more that I haven't filled in. _, }; diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig index b22d10ea3..26cd90736 100644 --- a/src/renderer/opengl/image.zig +++ b/src/renderer/opengl/image.zig @@ -368,8 +368,8 @@ pub const Image = union(enum) { internal: gl.Texture.InternalFormat, format: gl.Texture.Format, } = switch (self.*) { - .pending_rgb, .replace_rgb => .{ .internal = .rgb, .format = .rgb }, - .pending_rgba, .replace_rgba => .{ .internal = .rgba, .format = .rgba }, + .pending_rgb, .replace_rgb => .{ .internal = .srgb, .format = .rgb }, + .pending_rgba, .replace_rgba => .{ .internal = .srgba, .format = .rgba }, else => unreachable, }; diff --git a/src/renderer/shaders/image.f.glsl b/src/renderer/shaders/image.f.glsl index e8c00b271..e4aa9ef8e 100644 --- a/src/renderer/shaders/image.f.glsl +++ b/src/renderer/shaders/image.f.glsl @@ -6,7 +6,24 @@ layout(location = 0) out vec4 out_FragColor; uniform sampler2D image; +// Converts a color from linear to sRGB gamma encoding. +vec4 unlinearize(vec4 linear) { + bvec3 cutoff = lessThan(linear.rgb, vec3(0.0031308)); + vec3 higher = pow(linear.rgb, vec3(1.0/2.4)) * vec3(1.055) - vec3(0.055); + vec3 lower = linear.rgb * vec3(12.92); + + return vec4(mix(higher, lower, cutoff), linear.a); +} + void main() { vec4 color = texture(image, tex_coord); + + // Our texture is stored with an sRGB internal format, + // which means that the values are linearized when we + // sample the texture, but for now we actually want to + // output the color with gamma compression, so we do + // that. + color = unlinearize(color); + out_FragColor = vec4(color.rgb * color.a, color.a); } From 8a0ca1b573a447bed6ccad9c64e31de130f718d4 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 18 May 2025 00:14:40 +0000 Subject: [PATCH 055/245] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 187c67531..796ce1475 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz", - .hash = "N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", + .hash = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 8b29ff0c3..68ec4522a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs": { + "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz", - "hash": "sha256-YIlb2eSviWrRc+hbwgsAHLeCY3JgbYWjd9ZbOpXe1Qg=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", + "hash": "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 056c7b75e..7c3e08d2d 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs"; + name = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz"; - hash = "sha256-YIlb2eSviWrRc+hbwgsAHLeCY3JgbYWjd9ZbOpXe1Qg="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz"; + hash = "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index dd38069c4..0c71c80e4 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 9dbd2c18d..2ee48f269 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/93eb37fadd58231e06ad32ef79205324ea6189c0.tar.gz", - "dest": "vendor/p/N-V-__8AAIcFXgTG-wQvRJNXLt9vV5q3Tq8VryNTjKxTWsMs", - "sha256": "60895bd9e4af896ad173e85bc20b001cb7826372606d85a377d65b3a95ded508" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", + "dest": "vendor/p/N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn", + "sha256": "0ca595531644640f31fb79e33da4ee72bfeefcc9a179fd18c21c0e9ce5a7fcde" }, { "type": "archive", From 54dbd1990a111ae5042d2ae757f2b1f625b5bd00 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 28 Feb 2025 14:43:55 +0100 Subject: [PATCH 056/245] gtk: implement global shortcuts It's been a lot of D-Bus related pain and suffering, but here it is. I'm not sure about how well this is integrated inside App, but I'm fairly proud of the standalone logic. --- src/apprt/gtk/App.zig | 12 + src/apprt/gtk/GlobalShortcuts.zig | 419 ++++++++++++++++++++++++++++++ src/apprt/gtk/key.zig | 59 ++++- 3 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 src/apprt/gtk/GlobalShortcuts.zig diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 06cc41b9d..ab5276915 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -40,6 +40,7 @@ const Window = @import("Window.zig"); const ConfigErrorsDialog = @import("ConfigErrorsDialog.zig"); const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); const CloseDialog = @import("CloseDialog.zig"); +const GlobalShortcuts = @import("GlobalShortcuts.zig"); const Split = @import("Split.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); @@ -95,6 +96,8 @@ css_provider: *gtk.CssProvider, /// Providers for loading custom stylesheets defined by user custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .{}, +global_shortcuts: ?GlobalShortcuts, + /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { off: void, @@ -422,6 +425,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // our "activate" call above will open a window. .running = gio_app.getIsRemote() == 0, .css_provider = css_provider, + .global_shortcuts = .init(core_app.alloc, gio_app), }; } @@ -443,6 +447,8 @@ pub fn terminate(self: *App) void { self.winproto.deinit(self.core_app.alloc); + if (self.global_shortcuts) |*shortcuts| shortcuts.deinit(); + self.config.deinit(); } @@ -1012,6 +1018,12 @@ fn syncConfigChanges(self: *App, window: ?*Window) !void { ConfigErrorsDialog.maybePresent(self, window); try self.syncActionAccelerators(); + if (self.global_shortcuts) |*shortcuts| { + shortcuts.refreshSession(self) catch |err| { + log.warn("failed to refresh global shortcuts={}", .{err}); + }; + } + // Load our runtime and custom CSS. If this fails then our window is just stuck // with the old CSS but we don't want to fail the entire sync operation. self.loadRuntimeCss() catch |err| switch (err) { diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig new file mode 100644 index 000000000..7d960d7bf --- /dev/null +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -0,0 +1,419 @@ +const GlobalShortcuts = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); + +const App = @import("App.zig"); +const configpkg = @import("../../config.zig"); +const Binding = @import("../../input.zig").Binding; +const key = @import("key.zig"); + +const log = std.log.scoped(.global_shortcuts); +const Token = [16]u8; + +app: *App, +arena: std.heap.ArenaAllocator, +dbus: *gio.DBusConnection, + +/// A mapping from a unique ID to an action. +/// Currently the unique ID is simply the serialized representation of the +/// trigger that was used for the action as triggers are unique in the keymap, +/// but this may change in the future. +map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, + +/// The handle of the current global shortcuts portal session, +/// as a D-Bus object path. +handle: ?[:0]const u8 = null, + +/// The D-Bus signal subscription for the response signal on requests. +/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. +response_subscription: c_uint = 0, + +/// The D-Bus signal subscription for the keybind activate signal. +/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. +activate_subscription: c_uint = 0, + +pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts { + const dbus = gio_app.getDbusConnection() orelse return null; + + return .{ + // To be initialized later + .app = undefined, + .arena = .init(alloc), + .dbus = dbus, + }; +} + +pub fn deinit(self: *GlobalShortcuts) void { + self.close(); + self.arena.deinit(); +} + +fn close(self: *GlobalShortcuts) void { + if (self.response_subscription != 0) { + self.dbus.signalUnsubscribe(self.response_subscription); + self.response_subscription = 0; + } + + if (self.activate_subscription != 0) { + self.dbus.signalUnsubscribe(self.activate_subscription); + self.activate_subscription = 0; + } + + if (self.handle) |handle| { + // Close existing session + self.dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + self.handle = null; + } +} + +pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void { + // Ensure we have a valid reference to the app + // (it was left uninitialized in `init`) + self.app = app; + + // Close any existing sessions + self.close(); + + // Update map + var trigger_buf: [256]u8 = undefined; + + self.map.clearRetainingCapacity(); + var it = self.app.config.keybind.set.bindings.iterator(); + + while (it.next()) |entry| { + const leaf = switch (entry.value_ptr.*) { + // Global shortcuts can't have leaders + .leader => continue, + .leaf => |leaf| leaf, + }; + if (!leaf.flags.global) continue; + + const trigger = try key.xdgShortcutFromTrigger( + &trigger_buf, + entry.key_ptr.*, + ) orelse continue; + + try self.map.put( + self.arena.allocator(), + try self.arena.allocator().dupeZ(u8, trigger), + leaf.action, + ); + } + + try self.request(.create_session); +} + +fn shortcutActivated( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, +) callconv(.c) void { + const self: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + + // 2nd value in the tuple is the activated shortcut ID + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated + var shortcut_id: [*:0]const u8 = undefined; + params.getChild(1, "&s", &shortcut_id); + log.debug("activated={s}", .{shortcut_id}); + + const action = self.map.get(std.mem.span(shortcut_id)) orelse return; + + self.app.core_app.performAllAction(self.app, action) catch |err| { + log.err("failed to perform action={}", .{err}); + }; +} + +const Method = enum { + create_session, + bind_shortcuts, + + fn name(self: Method) [:0]const u8 { + return switch (self) { + .create_session => "CreateSession", + .bind_shortcuts => "BindShortcuts", + }; + } + + /// Construct the payload expected by the XDG portal call. + fn makePayload( + self: Method, + shortcuts: *GlobalShortcuts, + request_token: [:0]const u8, + ) ?*glib.Variant { + switch (self) { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + .create_session => { + var session_token: Token = undefined; + return glib.Variant.newParsed( + "({'handle_token': <%s>, 'session_handle_token': <%s>},)", + request_token.ptr, + generateToken(&session_token).ptr, + ); + }, + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + .bind_shortcuts => { + const handle = shortcuts.handle orelse return null; + + const bind_type = glib.VariantType.new("a(sa{sv})"); + defer glib.free(bind_type); + + var binds: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&binds, bind_type); + + var action_buf: [256]u8 = undefined; + + var it = shortcuts.map.iterator(); + while (it.next()) |entry| { + const trigger = entry.key_ptr.*.ptr; + const action = std.fmt.bufPrintZ( + &action_buf, + "{}", + .{entry.value_ptr.*}, + ) catch continue; + + binds.addParsed( + "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", + trigger, + action.ptr, + trigger, + ); + } + + return glib.Variant.newParsed( + "(%o, %*, '', {'handle_token': <%s>})", + handle.ptr, + binds.end(), + request_token.ptr, + ); + }, + } + } + + fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { + switch (self) { + .create_session => { + var handle: ?[*:0]u8 = null; + if (vardict.lookup("session_handle", "&s", &handle) == 0) { + log.err( + "session handle not found in response={s}", + .{vardict.print(@intFromBool(true))}, + ); + return; + } + + shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch { + log.err("out of memory: failed to clone session handle", .{}); + return; + }; + + log.debug("session_handle={?s}", .{handle}); + + // Subscribe to keybind activations + shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe( + null, + "org.freedesktop.portal.GlobalShortcuts", + "Activated", + "/org/freedesktop/portal/desktop", + handle, + .{ .match_arg0_path = true }, + shortcutActivated, + shortcuts, + null, + ); + + shortcuts.request(.bind_shortcuts) catch |err| { + log.err("failed to bind shortcuts={}", .{err}); + return; + }; + }, + .bind_shortcuts => {}, + } + } +}; + +/// Submit a request to the global shortcuts portal. +fn request( + self: *GlobalShortcuts, + comptime method: Method, +) !void { + // NOTE(pluiedev): + // XDG Portals are really, really poorly-designed pieces of hot garbage. + // How the protocol is _initially_ designed to work is as follows: + // + // 1. The client calls a method which returns the path of a Request object; + // 2. The client waits for the Response signal under said object path; + // 3. When the signal arrives, the actual return value and status code + // become available for the client for further processing. + // + // THIS DOES NOT WORK. Once the first two steps are complete, the client + // needs to immediately start listening for the third step, but an overeager + // server implementation could easily send the Response signal before the + // client is even ready, causing communications to break down over a simple + // race condition/two generals' problem that even _TCP_ had figured out + // decades ago. Worse yet, you get exactly _one_ chance to listen for the + // signal, or else your communication attempt so far has all been in vain. + // + // And they know this. Instead of fixing their freaking protocol, they just + // ask clients to manually construct the expected object path and subscribe + // to the request signal beforehand, making the whole response value of + // the original call COMPLETELY MEANINGLESS. + // + // Furthermore, this is _entirely undocumented_ aside from one tiny + // paragraph under the documentation for the Request interface, and + // anyone would be forgiven for missing it without reading the libportal + // source code. + // + // When in Rome, do as the Romans do, I guess...? + + const callbacks = struct { + fn gotResponseHandle( + source: ?*gobject.Object, + res: *gio.AsyncResult, + _: ?*anyopaque, + ) callconv(.c) void { + const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; + + var err: ?*glib.Error = null; + defer if (err) |err_| err_.free(); + + const params_ = dbus_.callFinish(res, &err) orelse { + if (err) |err_| log.err("request failed={s} ({})", .{ + err_.f_message orelse "(unknown)", + err_.f_code, + }); + return; + }; + defer params_.unref(); + + // TODO: XDG recommends updating the signal subscription if the actual + // returned request path is not the same as the expected request + // path, to retain compatibility with older versions of XDG portals. + // Although it suffers from the race condition outlined above, + // we should still implement this at some point. + } + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response + fn responded( + dbus: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params_: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + + // Unsubscribe from the response signal + if (self_.response_subscription != 0) { + dbus.signalUnsubscribe(self_.response_subscription); + self_.response_subscription = 0; + } + + var response: u32 = 0; + var vardict: ?*glib.Variant = null; + params_.get("(u@a{sv})", &response, &vardict); + + switch (response) { + 0 => { + log.debug("request successful", .{}); + method.onResponse(self_, vardict.?); + }, + 1 => log.debug("request was cancelled by user", .{}), + 2 => log.warn("request ended unexpectedly", .{}), + else => log.err("unrecognized response code={}", .{response}), + } + } + }; + + var request_token_buf: Token = undefined; + const request_token = generateToken(&request_token_buf); + + const payload = method.makePayload(self, request_token) orelse return; + const request_path = try self.getRequestPath(request_token); + + self.response_subscription = self.dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + callbacks.responded, + self, + null, + ); + + self.dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + payload, + null, + .{}, + -1, + null, + callbacks.gotResponseHandle, + null, + ); +} + +/// Generate a random token suitable for use in requests. +fn generateToken(buf: *Token) [:0]const u8 { + // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL + // 7 + 8 + 1 = 16 + return std.fmt.bufPrintZ( + buf, + "ghostty_{x:0<7}", + .{std.crypto.random.int(u28)}, + ) catch unreachable; +} + +/// Get the XDG portal request path for the current Ghostty instance. +/// +/// If this sounds like nonsense, see `request` for an explanation as to +/// why we need to do this. +fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + // for the syntax XDG portals expect. + + // `getUniqueName` should never return null here as we're using an ordinary + // message bus connection. If it doesn't, something is very wrong + const unique_name = std.mem.span(self.dbus.getUniqueName().?); + + const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{ + "/org/freedesktop/portal/desktop/request", + unique_name[1..], // Remove leading `:` + token, + }); + + // Sanitize the unique name by replacing every `.` with `_`. + // In effect, this will turn a unique name like `:1.192` into `1_192`. + // Valid D-Bus object path components never contain `.`s anyway, so we're + // free to replace all instances of `.` here and avoid extra allocation. + std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; +} diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index b3330eb40..2376f6bbc 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -20,10 +20,45 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u if (trigger.mods.super) try writer.writeAll(""); // Write our key + if (!try writeTriggerKey(writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = buf_stream.getWritten(); + return slice[0 .. slice.len - 1 :0]; +} + +/// Returns a XDG-compliant shortcuts string from a trigger. +/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ +pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { + var buf_stream = std.io.fixedBufferStream(buf); + const writer = buf_stream.writer(); + + // Modifiers + if (trigger.mods.shift) try writer.writeAll("SHIFT+"); + if (trigger.mods.ctrl) try writer.writeAll("CTRL+"); + if (trigger.mods.alt) try writer.writeAll("ALT+"); + if (trigger.mods.super) try writer.writeAll("LOGO+"); + + // Write our key + // NOTE: While the spec specifies that only libxkbcommon keysyms are + // expected, using GTK's keysyms should still work as they are identical + // to *X11's* keysyms (which I assume is a subset of libxkbcommon's). + // I haven't been able to any evidence to back up that assumption but + // this works for now + if (!try writeTriggerKey(writer, trigger)) return null; + + // We need to make the string null terminated. + try writer.writeByte(0); + const slice = buf_stream.getWritten(); + return slice[0 .. slice.len - 1 :0]; +} + +fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { switch (trigger.key) { .physical => |k| { - const keyval = keyvalFromKey(k) orelse return null; - try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return null)); + const keyval = keyvalFromKey(k) orelse return false; + try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false)); }, .unicode => |cp| { @@ -35,10 +70,7 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u }, } - // We need to make the string null terminated. - try writer.writeByte(0); - const slice = buf_stream.getWritten(); - return slice[0 .. slice.len - 1 :0]; + return true; } pub fn translateMods(state: gdk.ModifierType) input.Mods { @@ -208,6 +240,21 @@ test "accelFromTrigger" { })).?); } +test "xdgShortcutFromTrigger" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .super = true }, + .key = .{ .translated = .q }, + })).?); + + try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })).?); +} + /// A raw entry in the keymap. Our keymap contains mappings between /// GDK keys and our own key enum. const RawEntry = struct { c_uint, input.Key }; From 6827dc096437fd2cfdfc376dd760e6815b996f75 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 14 Apr 2025 16:12:56 +0800 Subject: [PATCH 057/245] config: document `global:` support on Linux Compiling this list of known supported and unsupported platforms has been amazingly painful. Never change, Linux desktop. --- src/config/Config.zig | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index ca330f8f6..0c4e07f06 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1110,12 +1110,33 @@ class: ?[:0]const u8 = null, /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global /// and not consume the input to reload the config. /// -/// Note: `global:` is only supported on macOS. On macOS, -/// this feature requires accessibility permissions to be granted to Ghostty. -/// When a `global:` keybind is specified and Ghostty is launched or reloaded, -/// Ghostty will attempt to request these permissions. If the permissions are -/// not granted, the keybind will not work. On macOS, you can find these -/// permissions in System Preferences -> Privacy & Security -> Accessibility. +/// Note: `global:` is only supported on macOS and certain Linux platforms. +/// +/// On macOS, this feature requires accessibility permissions to be granted +/// to Ghostty. When a `global:` keybind is specified and Ghostty is launched +/// or reloaded, Ghostty will attempt to request these permissions. +/// If the permissions are not granted, the keybind will not work. On macOS, +/// you can find these permissions in System Preferences -> Privacy & Security +/// -> Accessibility. +/// +/// On Linux, you need a desktop environment that implements the +/// [Global Shortcuts](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html) +/// protocol as a part of its XDG desktop protocol implementation. +/// Desktop environments that are known to support (or not support) +/// global shortcuts include: +/// +/// - Users using KDE Plasma (since [5.27](https://kde.org/announcements/plasma/5/5.27.0/#wayland)) +/// and GNOME (since [48](https://release.gnome.org/48/#and-thats-not-all)) should be able +/// to use global shortcuts with little to no configuration. +/// +/// - Some manual configuration is required on Hyprland. Consult the steps +/// outlined on the [Hyprland Wiki](https://wiki.hyprland.org/Configuring/Binds/#dbus-global-shortcuts) +/// to set up global shortcuts correctly. +/// (Important: [`xdg-desktop-portal-hyprland`](https://wiki.hyprland.org/Hypr-Ecosystem/xdg-desktop-portal-hyprland/) +/// must also be installed!) +/// +/// - Notably, global shortcuts have not been implemented on wlroots-based +/// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)). keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells From ac6aa8d395658041d6777e13d74d17baeaffc073 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 May 2025 13:56:22 -0700 Subject: [PATCH 058/245] Add `selection-clear-on-typing` Fixes #7392 Docs: > Whether to clear selected text when typing. This defaults to `true`. > This is typical behavior for most terminal emulators as well as > text input fields. If you set this to `false`, then the selected text > will not be cleared when typing. > > "Typing" is specifically defined as any non-modifier (shift, control, > alt, etc.) keypress that produces data to be sent to the application > running within the terminal (e.g. the shell). Additionally, selection > is cleared when any preedit or composition state is started (e.g. > when typing languages such as Japanese). > > If this is `false`, then the selection can still be manually > cleared by clicking once or by pressing `escape`. --- src/Surface.zig | 14 ++++++++++++-- src/config/Config.zig | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 138cd4839..f9e232340 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -253,6 +253,7 @@ const DerivedConfig = struct { mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: ?configpkg.OptionAsAlt, + selection_clear_on_typing: bool, vt_kam_allowed: bool, window_padding_top: u32, window_padding_bottom: u32, @@ -316,6 +317,7 @@ const DerivedConfig = struct { .mouse_shift_capture = config.@"mouse-shift-capture", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", .macos_option_as_alt = config.@"macos-option-as-alt", + .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", .window_padding_top = config.@"window-padding-y".top_left, .window_padding_bottom = config.@"window-padding-y".bottom_right, @@ -1687,7 +1689,9 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { if (self.renderer_state.preedit != null or preedit_ != null) { - self.setSelection(null) catch {}; + if (self.config.selection_clear_on_typing) { + self.setSelection(null) catch {}; + } } // We always clear our prior preedit @@ -1930,7 +1934,13 @@ pub fn keyCallback( if (!event.key.modifier()) { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - try self.setSelection(null); + + if (self.config.selection_clear_on_typing or + event.key == .escape) + { + try self.setSelection(null); + } + try self.io.terminal.scrollViewport(.{ .bottom = {} }); try self.queueRender(); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 81291b4e5..6f1e89d41 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -474,6 +474,21 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// selection color will vary across the selection. @"selection-invert-fg-bg": bool = false, +/// Whether to clear selected text when typing. This defaults to `true`. +/// This is typical behavior for most terminal emulators as well as +/// text input fields. If you set this to `false`, then the selected text +/// will not be cleared when typing. +/// +/// "Typing" is specifically defined as any non-modifier (shift, control, +/// alt, etc.) keypress that produces data to be sent to the application +/// running within the terminal (e.g. the shell). Additionally, selection +/// is cleared when any preedit or composition state is started (e.g. +/// when typing languages such as Japanese). +/// +/// If this is `false`, then the selection can still be manually +/// cleared by clicking once or by pressing `escape`. +@"selection-clear-on-typing": bool = true, + /// The minimum contrast ratio between the foreground and background colors. /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no /// contrast (e.g. black on black). This value is the contrast ratio as defined From 9ad0e4675bf27f7df78fbabb58533502931b5a45 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 19 May 2025 18:42:16 -0500 Subject: [PATCH 059/245] nix: keep symbols if we're building a debug package also add CI tests to make sure debug symbols exist Co-authored-by: Mitchell Hashimoto --- .github/workflows/test.yml | 19 +++++++++++++++++-- nix/package.nix | 3 +++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6e3a77a0..b32eda0f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -211,8 +211,23 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Test NixOS package build - run: nix build .#ghostty + - name: Test release NixOS package build + run: nix build .#ghostty-releasefast + + - name: Check version + run: result/bin/ghostty +version | grep -q 'builtin.OptimizeMode.ReleaseFast' + + - name: Check to see if the binary has been stripped + run: nm result/bin/.ghostty-wrapped 2>&1 | grep -q 'no symbols' + + - name: Test debug NixOS package build + run: nix build .#ghostty-debug + + - name: Check version + run: result/bin/ghostty +version | grep -q 'builtin.OptimizeMode.Debug' + + - name: Check to see if the binary has not been stripped + run: nm result/bin/.ghostty-wrapped 2>&1 | grep -q 'main_ghostty.main' build-dist: runs-on: namespace-profile-ghostty-md diff --git a/nix/package.nix b/nix/package.nix index a39f5b835..08dfd710b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -36,6 +36,7 @@ buildInputs = import ./build-support/build-inputs.nix { inherit pkgs lib stdenv enableX11 enableWayland; }; + strip = optimize != "Debug" && optimize != "ReleaseSafe"; in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; @@ -87,6 +88,7 @@ in buildInputs = buildInputs; dontConfigure = true; + dontStrip = !strip; GI_TYPELIB_PATH = gi_typelib_path; @@ -96,6 +98,7 @@ in "-Dversion-string=${finalAttrs.version}-${revision}-nix" "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" + "-Dstrip=${lib.boolToString strip}" ]; outputs = [ From 3d2bc3dca14573817661649aab438b87a741e478 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 May 2025 17:05:46 -0700 Subject: [PATCH 060/245] build: add unwind tables and frame pointers to debug/test builds This fixes an issue where stack traces were unreliable on some platforms (namely aarch64-linux in a MacOS VM). I'm unsure if this is a bug in Zig (defaults should be changed?) or what, because this isn't necessary on other platforms, but this works around the issue. I've unconditionally enabled this for all platforms, depending on build mode (debug/test) and not the target. This is because I don't think there is a downside for other platforms but if thats wrong we can fix that quickly. Some binaries have this unconditionally enabled regardless of build mode (e.g. the Unicode tables generator) because having symbols in those cases is always useful. Some unrelated GTK test fix is also included here. I'm not sure why CI didn't catch this (perhaps we only run tests for none-runtime) but all tests pass locally and we can look into that elsewhere. --- build.zig | 12 +++++++++--- src/apprt/gtk/key.zig | 2 +- src/build/GhosttyBench.zig | 10 ++++++---- src/build/GhosttyDocs.zig | 9 +++++++-- src/build/GhosttyExe.zig | 12 ++++++++---- src/build/GhosttyFrameData.zig | 9 +++++++-- src/build/GhosttyWebdata.zig | 9 +++++++-- src/build/HelpStrings.zig | 9 +++++++-- src/build/UnicodeTables.zig | 9 +++++++-- 9 files changed, 59 insertions(+), 22 deletions(-) diff --git a/build.zig b/build.zig index 0751bab51..80af88488 100644 --- a/build.zig +++ b/build.zig @@ -110,9 +110,15 @@ pub fn build(b: *std.Build) !void { const test_exe = b.addTest(.{ .name = "ghostty-test", - .root_source_file = b.path("src/main.zig"), - .target = config.target, - .filter = test_filter, + .filters = if (test_filter) |v| &.{v} else &.{}, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = config.target, + .optimize = .Debug, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); { diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 2376f6bbc..3dcfaed98 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -246,7 +246,7 @@ test "xdgShortcutFromTrigger" { try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ .mods = .{ .super = true }, - .key = .{ .translated = .q }, + .key = .{ .unicode = 'q' }, })).?); try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index 27f40abff..9e93a3b85 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -36,11 +36,13 @@ pub fn init( const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); const c_exe = b.addExecutable(.{ .name = bin_name, - .root_source_file = b.path("src/main.zig"), - .target = deps.config.target, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = deps.config.target, - // We always want our benchmarks to be in release mode. - .optimize = .ReleaseFast, + // We always want our benchmarks to be in release mode. + .optimize = .ReleaseFast, + }), }); c_exe.linkLibC(); diff --git a/src/build/GhosttyDocs.zig b/src/build/GhosttyDocs.zig index d6ebe30eb..4b5dbfd92 100644 --- a/src/build/GhosttyDocs.zig +++ b/src/build/GhosttyDocs.zig @@ -26,8 +26,13 @@ pub fn init( inline for (manpages) |manpage| { const generate_markdown = b.addExecutable(.{ .name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, - .root_source_file = b.path("src/main.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); deps.help_strings.addImport(generate_markdown); diff --git a/src/build/GhosttyExe.zig b/src/build/GhosttyExe.zig index e251e7b45..083aecdb5 100644 --- a/src/build/GhosttyExe.zig +++ b/src/build/GhosttyExe.zig @@ -13,10 +13,14 @@ install_step: *std.Build.Step.InstallArtifact, pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty { const exe: *std.Build.Step.Compile = b.addExecutable(.{ .name = "ghostty", - .root_source_file = b.path("src/main.zig"), - .target = cfg.target, - .optimize = cfg.optimize, - .strip = cfg.strip, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = cfg.target, + .optimize = cfg.optimize, + .strip = cfg.strip, + .omit_frame_pointer = cfg.strip, + .unwind_tables = if (cfg.strip) .none else .sync, + }), }); const install_step = b.addInstallArtifact(exe, .{}); diff --git a/src/build/GhosttyFrameData.zig b/src/build/GhosttyFrameData.zig index b07e7333f..3dc638a05 100644 --- a/src/build/GhosttyFrameData.zig +++ b/src/build/GhosttyFrameData.zig @@ -15,8 +15,13 @@ output: std.Build.LazyPath, pub fn init(b: *std.Build) !GhosttyFrameData { const exe = b.addExecutable(.{ .name = "framegen", - .root_source_file = b.path("src/build/framegen/main.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/build/framegen/main.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); const run = b.addRunArtifact(exe); diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig index fef08434f..b0201c3ff 100644 --- a/src/build/GhosttyWebdata.zig +++ b/src/build/GhosttyWebdata.zig @@ -18,8 +18,13 @@ pub fn init( { const webgen_config = b.addExecutable(.{ .name = "webgen_config", - .root_source_file = b.path("src/main.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); deps.help_strings.addImport(webgen_config); diff --git a/src/build/HelpStrings.zig b/src/build/HelpStrings.zig index d088e6c3e..04ae629b7 100644 --- a/src/build/HelpStrings.zig +++ b/src/build/HelpStrings.zig @@ -12,8 +12,13 @@ output: std.Build.LazyPath, pub fn init(b: *std.Build, cfg: *const Config) !HelpStrings { const exe = b.addExecutable(.{ .name = "helpgen", - .root_source_file = b.path("src/helpgen.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/helpgen.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); const help_config = config: { diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index 58af17a6e..5bba2341b 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -12,8 +12,13 @@ output: std.Build.LazyPath, pub fn init(b: *std.Build) !UnicodeTables { const exe = b.addExecutable(.{ .name = "unigen", - .root_source_file = b.path("src/unicode/props.zig"), - .target = b.graph.host, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unicode/props.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), }); if (b.lazyDependency("ziglyph", .{ From ae095d2262230cd23d8c23c16115900156641984 Mon Sep 17 00:00:00 2001 From: Liam Hupfer Date: Mon, 19 May 2025 22:01:33 -0500 Subject: [PATCH 061/245] flatpak: Add --device=all permission Without --device=all, the sandbox gets a dedicated PTY namespace. Commands run on the host via the HostCommand D-Bus interface receive the file descriptors from the namespaced PTY but cannot determine its path via ttyname(3). This breaks commands like tty(1), ps(1) and emacsclient(1). Add --device=all so the host PTY namespace is used when allocating TTYs. Applications with access to org.freedesktop.Flatpak can already give themselves arbitrary permissions, so the sandboxing benefits of restricted device access are limited. For terminal emulators, the primary benefit of Flatpak is the predictability of the distro-independent target runtime rather than sandboxing. --- flatpak/com.mitchellh.ghostty.Devel.yml | 2 ++ flatpak/com.mitchellh.ghostty.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty.Devel.yml index 244c3987f..fe24a7c56 100644 --- a/flatpak/com.mitchellh.ghostty.Devel.yml +++ b/flatpak/com.mitchellh.ghostty.Devel.yml @@ -14,6 +14,8 @@ desktop-file-name-suffix: " (Devel)" finish-args: # 3D rendering - --device=dri + # use host PTS namespace + - --device=all # Windowing - --share=ipc - --socket=fallback-x11 diff --git a/flatpak/com.mitchellh.ghostty.yml b/flatpak/com.mitchellh.ghostty.yml index 17c92633f..1b119c11b 100644 --- a/flatpak/com.mitchellh.ghostty.yml +++ b/flatpak/com.mitchellh.ghostty.yml @@ -9,6 +9,8 @@ command: ghostty finish-args: # 3D rendering - --device=dri + # use host PTS namespace + - --device=all # Windowing - --share=ipc - --socket=fallback-x11 From 81647bfae6883fb759a6fb005a55eddfc2cae50a Mon Sep 17 00:00:00 2001 From: Emir SARI Date: Wed, 21 May 2025 20:06:07 +0300 Subject: [PATCH 062/245] Update Turkish translations --- po/tr_TR.UTF-8.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/tr_TR.UTF-8.po b/po/tr_TR.UTF-8.po index ac1bfdfc7..3de70d61c 100644 --- a/po/tr_TR.UTF-8.po +++ b/po/tr_TR.UTF-8.po @@ -216,7 +216,7 @@ msgstr "Açık Sekmeleri Görüntüle" #: src/apprt/gtk/Window.zig:249 msgid "New Split" -msgstr "" +msgstr "Yeni Bölme" #: src/apprt/gtk/Window.zig:312 msgid "" From f1c42c9f8c4e1ebc6352fd58e6c20ec0ae9a2b63 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 May 2025 10:14:39 -0700 Subject: [PATCH 063/245] synthetic package This introduces a new package `src/synthetic` for generating synthetic data, currently primarily for benchmarking but other use cases can emerge. The synthetic package exports a runtime-dispatched type `Generator` that can generate data of various types. To start, we have a bytes, utf8, and OSC generator. The goal of each generator is to expose knobs to tune the probabilities of various outcomes. For example, the UTF-8 generator has a knob to tune the probability of generating 1, 2, 3, or 4-byte UTF-8 sequences. Ultimately, the goal is to be able to collect probability data empirically that we can then use for benchmarks so we can optimize various parts of the codebase on real-world data shape distributions. --- src/bench/stream.zig | 129 +++++++++------------ src/bench/synth/main.zig | 15 --- src/bench/synth/osc.zig | 197 -------------------------------- src/main_ghostty.zig | 2 +- src/synthetic/Bytes.zig | 53 +++++++++ src/synthetic/Generator.zig | 42 +++++++ src/synthetic/Osc.zig | 221 ++++++++++++++++++++++++++++++++++++ src/synthetic/Utf8.zig | 103 +++++++++++++++++ src/synthetic/main.zig | 23 ++++ 9 files changed, 497 insertions(+), 288 deletions(-) delete mode 100644 src/bench/synth/main.zig delete mode 100644 src/bench/synth/osc.zig create mode 100644 src/synthetic/Bytes.zig create mode 100644 src/synthetic/Generator.zig create mode 100644 src/synthetic/Osc.zig create mode 100644 src/synthetic/Utf8.zig create mode 100644 src/synthetic/main.zig diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 0c7d421cc..6309c9e7f 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -12,10 +12,9 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const ziglyph = @import("ziglyph"); const cli = @import("../cli.zig"); const terminal = @import("../terminal/main.zig"); -const synth = @import("synth/main.zig"); +const synthetic = @import("../synthetic/main.zig"); const Args = struct { mode: Mode = .noop, @@ -102,16 +101,57 @@ pub fn main() !void { const writer = std.io.getStdOut().writer(); const buf = try alloc.alloc(u8, args.@"buffer-size"); + // Build our RNG const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); + var prng = std.Random.DefaultPrng.init(seed); + const rand = prng.random(); // Handle the modes that do not depend on terminal state first. switch (args.mode) { - .@"gen-ascii" => try genAscii(writer, seed), - .@"gen-utf8" => try genUtf8(writer, seed), - .@"gen-rand" => try genRand(writer, seed), - .@"gen-osc" => try genOsc(writer, seed, 0.5), - .@"gen-osc-valid" => try genOsc(writer, seed, 1.0), - .@"gen-osc-invalid" => try genOsc(writer, seed, 0.0), + .@"gen-ascii" => { + var gen: synthetic.Bytes = .{ + .rand = rand, + .alphabet = synthetic.Bytes.Alphabet.ascii, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-utf8" => { + var gen: synthetic.Utf8 = .{ + .rand = rand, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-rand" => { + var gen: synthetic.Bytes = .{ .rand = rand }; + try generate(writer, gen.generator()); + }, + + .@"gen-osc" => { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = 0.5, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-osc-valid" => { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = 1.0, + }; + try generate(writer, gen.generator()); + }, + + .@"gen-osc-invalid" => { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = 0.0, + }; + try generate(writer, gen.generator()); + }, + .noop => try benchNoop(reader, buf), // Handle the ones that depend on terminal state next @@ -145,75 +185,14 @@ pub fn main() !void { } } -/// Generates an infinite stream of random printable ASCII characters. -/// This has no control characters in it at all. -fn genAscii(writer: anytype, seed: u64) !void { - const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; - try genData(writer, alphabet, seed); -} - -/// Generates an infinite stream of bytes from the given alphabet. -fn genData(writer: anytype, alphabet: []const u8, seed: u64) !void { - var prng = std.Random.DefaultPrng.init(seed); - const rnd = prng.random(); +fn generate( + writer: anytype, + gen: synthetic.Generator, +) !void { var buf: [1024]u8 = undefined; while (true) { - for (&buf) |*c| { - const idx = rnd.uintLessThanBiased(usize, alphabet.len); - c.* = alphabet[idx]; - } - - writer.writeAll(&buf) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, - }; - } -} - -fn genUtf8(writer: anytype, seed: u64) !void { - var prng = std.Random.DefaultPrng.init(seed); - const rnd = prng.random(); - var buf: [1024]u8 = undefined; - while (true) { - var i: usize = 0; - while (i <= buf.len - 4) { - const cp: u18 = while (true) { - const cp = rnd.int(u18); - if (ziglyph.isPrint(cp)) break cp; - }; - - i += try std.unicode.utf8Encode(cp, buf[i..]); - } - - writer.writeAll(buf[0..i]) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, - }; - } -} - -fn genOsc(writer: anytype, seed: u64, p_valid: f64) !void { - var prng = std.Random.DefaultPrng.init(seed); - const gen: synth.OSC = .{ .rand = prng.random(), .p_valid = p_valid }; - - var buf: [1024]u8 = undefined; - while (true) { - const seq = try gen.next(&buf); - writer.writeAll(seq) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, - }; - } -} - -fn genRand(writer: anytype, seed: u64) !void { - var prng = std.Random.DefaultPrng.init(seed); - const rnd = prng.random(); - var buf: [1024]u8 = undefined; - while (true) { - rnd.bytes(&buf); - - writer.writeAll(&buf) catch |err| switch (err) { + const data = try gen.next(&buf); + writer.writeAll(data) catch |err| switch (err) { error.BrokenPipe => return, // stdout closed else => return err, }; diff --git a/src/bench/synth/main.zig b/src/bench/synth/main.zig deleted file mode 100644 index eda2dec28..000000000 --- a/src/bench/synth/main.zig +++ /dev/null @@ -1,15 +0,0 @@ -//! Package synth contains functions for generating synthetic data for -//! the purpose of benchmarking, primarily. This can also probably be used -//! for testing and fuzzing (probably generating a corpus rather than -//! directly fuzzing) and more. -//! -//! The synthetic data generators in this package are usually not performant -//! enough to be streamed in real time. They should instead be used to -//! generate a large amount of data in a single go and then streamed -//! from there. - -pub const OSC = @import("osc.zig").Generator; - -test { - @import("std").testing.refAllDecls(@This()); -} diff --git a/src/bench/synth/osc.zig b/src/bench/synth/osc.zig deleted file mode 100644 index 61f168b58..000000000 --- a/src/bench/synth/osc.zig +++ /dev/null @@ -1,197 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -/// Synthetic OSC request generator. -/// -/// I tried to balance generality and practicality. I implemented mainly -/// all I need at the time of writing this, but I think this can be iterated -/// over time to be a general purpose OSC generator with a lot of -/// configurability. I limited the configurability to what I need but still -/// tried to lay out the code in a way that it can be extended easily. -pub const Generator = struct { - /// Random number generator. - rand: std.Random, - - /// Probability of a valid OSC sequence being generated. - p_valid: f64 = 1.0, - - pub const Error = error{NoSpaceLeft}; - - /// We use a FBS as a direct parameter below in non-pub functions, - /// but we should probably just switch to `[]u8`. - const FBS = std.io.FixedBufferStream([]u8); - - /// Get the next OSC request in bytes. The generated OSC request will - /// have the prefix `ESC ]` and the terminator `BEL` (0x07). - /// - /// This will generate both valid and invalid OSC requests (based on - /// the `p_valid` probability value). Invalid requests still have the - /// prefix and terminator, but the content in between is not a valid - /// OSC request. - /// - /// The buffer must be at least 3 bytes long to accommodate the - /// prefix and terminator. - pub fn next(self: *const Generator, buf: []u8) Error![]const u8 { - assert(buf.len >= 3); - var fbs: FBS = std.io.fixedBufferStream(buf); - const writer = fbs.writer(); - - // Start OSC (ESC ]) - try writer.writeAll("\x1b]"); - - // Determine if we are generating a valid or invalid OSC request. - switch (self.chooseValidity()) { - .valid => try self.nextValid(&fbs), - .invalid => try self.nextInvalid(&fbs), - } - - // Terminate OSC - try writer.writeAll("\x07"); - return fbs.getWritten(); - } - - fn nextValid(self: *const Generator, fbs: *FBS) Error!void { - try self.nextValidExact(fbs, self.rand.enumValue(ValidKind)); - } - - fn nextValidExact(self: *const Generator, fbs: *FBS, k: ValidKind) Error!void { - switch (k) { - .change_window_title => { - try fbs.writer().writeAll("0;"); // Set window title - try self.randomBytes(fbs, 1, fbs.buffer.len); - }, - - .prompt_start => { - try fbs.writer().writeAll("133;A"); // Start prompt - - // aid - if (self.rand.boolean()) { - try fbs.writer().writeAll(";aid="); - try self.randomBytes(fbs, 1, 16); - } - - // redraw - if (self.rand.boolean()) { - try fbs.writer().writeAll(";redraw="); - if (self.rand.boolean()) { - try fbs.writer().writeAll("1"); - } else { - try fbs.writer().writeAll("0"); - } - } - }, - - .prompt_end => try fbs.writer().writeAll("133;B"), // End prompt - } - } - - fn nextInvalid(self: *const Generator, fbs: *FBS) Error!void { - switch (self.rand.enumValue(InvalidKind)) { - .random => try self.randomBytes(fbs, 1, fbs.buffer.len), - .good_prefix => { - try fbs.writer().writeAll("133;"); - try self.randomBytes(fbs, 2, fbs.buffer.len); - }, - } - } - - /// Generate a random string of bytes up to `max_len` bytes or - /// until we run out of space in the buffer, whichever is - /// smaller. - /// - /// This will avoid the terminator characters (0x1B and 0x07) and - /// replace them by incrementing them by one. - fn randomBytes( - self: *const Generator, - fbs: *FBS, - min_len: usize, - max_len: usize, - ) Error!void { - const len = @min( - self.rand.intRangeAtMostBiased(usize, min_len, max_len), - fbs.buffer.len - fbs.pos - 1, // leave space for terminator - ); - var rem: usize = len; - var buf: [1024]u8 = undefined; - while (rem > 0) { - self.rand.bytes(&buf); - std.mem.replaceScalar(u8, &buf, 0x1B, 0x1C); - std.mem.replaceScalar(u8, &buf, 0x07, 0x08); - - const n = @min(rem, buf.len); - try fbs.writer().writeAll(buf[0..n]); - rem -= n; - } - } - - /// Choose whether to generate a valid or invalid OSC request based - /// on the validity probability. - fn chooseValidity(self: *const Generator) Validity { - return if (self.rand.float(f64) > self.p_valid) - .invalid - else - .valid; - } - - const Validity = enum { valid, invalid }; - - const ValidKind = enum { - change_window_title, - prompt_start, - prompt_end, - }; - - const InvalidKind = enum { - /// Literally random bytes. Might even be valid, but probably not. - random, - - /// A good prefix, but ultimately invalid format. - good_prefix, - }; -}; - -/// A fixed seed we can use for our tests to avoid flakes. -const test_seed = 0xC0FFEEEEEEEEEEEE; - -test "OSC generator" { - var prng = std.Random.DefaultPrng.init(test_seed); - var buf: [4096]u8 = undefined; - const gen: Generator = .{ .rand = prng.random() }; - for (0..50) |_| _ = try gen.next(&buf); -} - -test "OSC generator valid" { - const testing = std.testing; - const terminal = @import("../../terminal/main.zig"); - - var prng = std.Random.DefaultPrng.init(test_seed); - var buf: [256]u8 = undefined; - const gen: Generator = .{ - .rand = prng.random(), - .p_valid = 1.0, - }; - for (0..50) |_| { - const seq = try gen.next(&buf); - var parser: terminal.osc.Parser = .{}; - for (seq[2 .. seq.len - 1]) |c| parser.next(c); - try testing.expect(parser.end(null) != null); - } -} - -test "OSC generator invalid" { - const testing = std.testing; - const terminal = @import("../../terminal/main.zig"); - - var prng = std.Random.DefaultPrng.init(test_seed); - var buf: [256]u8 = undefined; - const gen: Generator = .{ - .rand = prng.random(), - .p_valid = 0.0, - }; - for (0..50) |_| { - const seq = try gen.next(&buf); - var parser: terminal.osc.Parser = .{}; - for (seq[2 .. seq.len - 1]) |c| parser.next(c); - try testing.expect(parser.end(null) == null); - } -} diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 4a9f2b138..985c6c9bd 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -182,12 +182,12 @@ test { _ = @import("surface_mouse.zig"); // Libraries - _ = @import("bench/synth/main.zig"); _ = @import("crash/main.zig"); _ = @import("datastruct/main.zig"); _ = @import("inspector/main.zig"); _ = @import("terminal/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.zig"); + _ = @import("synthetic/main.zig"); _ = @import("unicode/main.zig"); } diff --git a/src/synthetic/Bytes.zig b/src/synthetic/Bytes.zig new file mode 100644 index 000000000..8a8207ba9 --- /dev/null +++ b/src/synthetic/Bytes.zig @@ -0,0 +1,53 @@ +/// Generates bytes. +const Bytes = @This(); + +const std = @import("std"); +const Generator = @import("Generator.zig"); + +/// Random number generator. +rand: std.Random, + +/// The minimum and maximum length of the generated bytes. The maximum +/// length will be capped to the length of the buffer passed in if the +/// buffer length is smaller. +min_len: usize = 1, +max_len: usize = std.math.maxInt(usize), + +/// The possible bytes that can be generated. If a byte is duplicated +/// in the alphabet, it will be more likely to be generated. That's a +/// side effect of the generator, not an intended use case. +alphabet: ?[]const u8 = null, + +/// Predefined alphabets. +pub const Alphabet = struct { + pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; +}; + +pub fn generator(self: *Bytes) Generator { + return .init(self, next); +} + +pub fn next(self: *Bytes, buf: []u8) Generator.Error![]const u8 { + const len = @min( + self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), + buf.len, + ); + + const result = buf[0..len]; + self.rand.bytes(result); + if (self.alphabet) |alphabet| { + for (result) |*byte| byte.* = alphabet[byte.* % alphabet.len]; + } + + return result; +} + +test "bytes" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0); + var buf: [256]u8 = undefined; + var v: Bytes = .{ .rand = prng.random() }; + const gen = v.generator(); + const result = try gen.next(&buf); + try testing.expect(result.len > 0); +} diff --git a/src/synthetic/Generator.zig b/src/synthetic/Generator.zig new file mode 100644 index 000000000..7478a54c3 --- /dev/null +++ b/src/synthetic/Generator.zig @@ -0,0 +1,42 @@ +/// A common interface for all generators. +const Generator = @This(); + +const std = @import("std"); +const assert = std.debug.assert; + +/// For generators, this is the only error that is allowed to be +/// returned by the next function. +pub const Error = error{NoSpaceLeft}; + +/// The vtable for the generator. +ptr: *anyopaque, +nextFn: *const fn (ptr: *anyopaque, buf: []u8) Error![]const u8, + +/// Create a new generator from a pointer and a function pointer. +/// This usually is only called by generator implementations, not +/// generator users. +pub fn init( + pointer: anytype, + comptime nextFn: fn (ptr: @TypeOf(pointer), buf: []u8) Error![]const u8, +) Generator { + const Ptr = @TypeOf(pointer); + assert(@typeInfo(Ptr) == .pointer); // Must be a pointer + assert(@typeInfo(Ptr).pointer.size == .one); // Must be a single-item pointer + assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a struct + const gen = struct { + fn next(ptr: *anyopaque, buf: []u8) Error![]const u8 { + const self: Ptr = @ptrCast(@alignCast(ptr)); + return try nextFn(self, buf); + } + }; + + return .{ + .ptr = pointer, + .nextFn = gen.next, + }; +} + +/// Get the next value from the generator. Returns the data written. +pub fn next(self: Generator, buf: []u8) Error![]const u8 { + return try self.nextFn(self.ptr, buf); +} diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig new file mode 100644 index 000000000..e0a6b42a0 --- /dev/null +++ b/src/synthetic/Osc.zig @@ -0,0 +1,221 @@ +/// Generates random terminal OSC requests. +const Osc = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Generator = @import("Generator.zig"); +const Bytes = @import("Bytes.zig"); + +/// Valid OSC request kinds that can be generated. +pub const ValidKind = enum { + change_window_title, + prompt_start, + prompt_end, +}; + +/// Invalid OSC request kinds that can be generated. +pub const InvalidKind = enum { + /// Literally random bytes. Might even be valid, but probably not. + random, + + /// A good prefix, but ultimately invalid format. + good_prefix, +}; + +/// Random number generator. +rand: std.Random, + +/// Probability of a valid OSC sequence being generated. +p_valid: f64 = 1.0, + +/// Probabilities of specific valid or invalid OSC request kinds. +/// The probabilities are weighted relative to each other, so they +/// can sum greater than 1.0. A kind of weight 1.0 and a kind of +/// weight 2.0 will have a 2:1 chance of the latter being selected. +p_valid_kind: std.enums.EnumArray(ValidKind, f64) = .initFill(1.0), +p_invalid_kind: std.enums.EnumArray(InvalidKind, f64) = .initFill(1.0), + +/// The alphabet for random bytes (omitting 0x1B and 0x07). +const bytes_alphabet: []const u8 = alphabet: { + var alphabet: [256]u8 = undefined; + for (0..alphabet.len) |i| { + if (i == 0x1B or i == 0x07) { + alphabet[i] = @intCast(i + 1); + } else { + alphabet[i] = @intCast(i); + } + } + const result = alphabet; + break :alphabet &result; +}; + +pub fn generator(self: *Osc) Generator { + return .init(self, next); +} + +/// Get the next OSC request in bytes. The generated OSC request will +/// have the prefix `ESC ]` and the terminator `BEL` (0x07). +/// +/// This will generate both valid and invalid OSC requests (based on +/// the `p_valid` probability value). Invalid requests still have the +/// prefix and terminator, but the content in between is not a valid +/// OSC request. +/// +/// The buffer must be at least 3 bytes long to accommodate the +/// prefix and terminator. +pub fn next(self: *Osc, buf: []u8) Generator.Error![]const u8 { + if (buf.len < 3) return error.NoSpaceLeft; + const unwrapped = try self.nextUnwrapped(buf[2 .. buf.len - 1]); + buf[0] = 0x1B; // ESC + buf[1] = ']'; + buf[unwrapped.len + 2] = 0x07; // BEL + return buf[0 .. unwrapped.len + 3]; +} + +fn nextUnwrapped(self: *Osc, buf: []u8) Generator.Error![]const u8 { + return switch (self.chooseValidity()) { + .valid => valid: { + const Indexer = @TypeOf(self.p_valid_kind).Indexer; + const idx = self.rand.weightedIndex(f64, &self.p_valid_kind.values); + break :valid try self.nextUnwrappedValidExact( + buf, + Indexer.keyForIndex(idx), + ); + }, + + .invalid => invalid: { + const Indexer = @TypeOf(self.p_invalid_kind).Indexer; + const idx = self.rand.weightedIndex(f64, &self.p_invalid_kind.values); + break :invalid try self.nextUnwrappedInvalidExact( + buf, + Indexer.keyForIndex(idx), + ); + }, + }; +} + +fn nextUnwrappedValidExact(self: *const Osc, buf: []u8, k: ValidKind) Generator.Error![]const u8 { + var fbs = std.io.fixedBufferStream(buf); + switch (k) { + .change_window_title => { + try fbs.writer().writeAll("0;"); // Set window title + var bytes_gen = self.bytes(); + const title = try bytes_gen.next(fbs.buffer[fbs.pos..]); + try fbs.seekBy(@intCast(title.len)); + }, + + .prompt_start => { + try fbs.writer().writeAll("133;A"); // Start prompt + + // aid + if (self.rand.boolean()) { + var bytes_gen = self.bytes(); + bytes_gen.max_len = 16; + try fbs.writer().writeAll(";aid="); + const aid = try bytes_gen.next(fbs.buffer[fbs.pos..]); + try fbs.seekBy(@intCast(aid.len)); + } + + // redraw + if (self.rand.boolean()) { + try fbs.writer().writeAll(";redraw="); + if (self.rand.boolean()) { + try fbs.writer().writeAll("1"); + } else { + try fbs.writer().writeAll("0"); + } + } + }, + + .prompt_end => try fbs.writer().writeAll("133;B"), // End prompt + } + + return fbs.getWritten(); +} + +fn nextUnwrappedInvalidExact( + self: *const Osc, + buf: []u8, + k: InvalidKind, +) Generator.Error![]const u8 { + switch (k) { + .random => { + var bytes_gen = self.bytes(); + return try bytes_gen.next(buf); + }, + + .good_prefix => { + var fbs = std.io.fixedBufferStream(buf); + try fbs.writer().writeAll("133;"); + var bytes_gen = self.bytes(); + const data = try bytes_gen.next(fbs.buffer[fbs.pos..]); + try fbs.seekBy(@intCast(data.len)); + return fbs.getWritten(); + }, + } +} + +fn bytes(self: *const Osc) Bytes { + return .{ + .rand = self.rand, + .alphabet = bytes_alphabet, + }; +} + +/// Choose whether to generate a valid or invalid OSC request based +/// on the validity probability. +fn chooseValidity(self: *const Osc) Validity { + return if (self.rand.float(f64) > self.p_valid) + .invalid + else + .valid; +} + +const Validity = enum { valid, invalid }; + +/// A fixed seed we can use for our tests to avoid flakes. +const test_seed = 0xC0FFEEEEEEEEEEEE; + +test "OSC generator" { + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [4096]u8 = undefined; + var v: Osc = .{ .rand = prng.random() }; + const gen = v.generator(); + for (0..50) |_| _ = try gen.next(&buf); +} + +test "OSC generator valid" { + const testing = std.testing; + const terminal = @import("../terminal/main.zig"); + + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [256]u8 = undefined; + var gen: Osc = .{ + .rand = prng.random(), + .p_valid = 1.0, + }; + for (0..50) |_| { + const seq = try gen.next(&buf); + var parser: terminal.osc.Parser = .{}; + for (seq[2 .. seq.len - 1]) |c| parser.next(c); + try testing.expect(parser.end(null) != null); + } +} + +test "OSC generator invalid" { + const testing = std.testing; + const terminal = @import("../terminal/main.zig"); + + var prng = std.Random.DefaultPrng.init(test_seed); + var buf: [256]u8 = undefined; + var gen: Osc = .{ + .rand = prng.random(), + .p_valid = 0.0, + }; + for (0..50) |_| { + const seq = try gen.next(&buf); + var parser: terminal.osc.Parser = .{}; + for (seq[2 .. seq.len - 1]) |c| parser.next(c); + try testing.expect(parser.end(null) == null); + } +} diff --git a/src/synthetic/Utf8.zig b/src/synthetic/Utf8.zig new file mode 100644 index 000000000..c3ace6505 --- /dev/null +++ b/src/synthetic/Utf8.zig @@ -0,0 +1,103 @@ +/// Generates UTF-8. +/// +/// This doesn't yet generate multi-codepoint graphemes, but it +/// has the ability to generate a custom distribution of UTF-8 +/// encoding lengths (1, 2, 3, or 4 bytes). +const Utf8 = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Generator = @import("Generator.zig"); + +/// Possible UTF-8 encoding lengths. +pub const Utf8Len = enum(u3) { + one = 1, + two = 2, + three = 3, + four = 4, +}; + +/// Random number generator. +rand: std.Random, + +/// The minimum and maximum length of the generated bytes. The maximum +/// length will be capped to the length of the buffer passed in if the +/// buffer length is smaller. +min_len: usize = 1, +max_len: usize = std.math.maxInt(usize), + +/// Probability of a specific UTF-8 encoding length being generated. +/// The probabilities are weighted relative to each other, so they +/// can sum greater than 1.0. A length of weight 1.0 and a length +/// of weight 2.0 will have a 2:1 chance of the latter being +/// selected. +/// +/// If a UTF-8 encoding of a chosen length can't fit into the remaining +/// buffer, a smaller length will be chosen. For small buffers this may +/// skew the distribution of lengths. +p_length: std.enums.EnumArray(Utf8Len, f64) = .initFill(1.0), + +pub fn generator(self: *Utf8) Generator { + return .init(self, next); +} + +pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { + const len = @min( + self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), + buf.len, + ); + + const result = buf[0..len]; + var rem: usize = len; + while (rem > 0) { + // Pick a utf8 byte count to generate. + const utf8_len: Utf8Len = len: { + const Indexer = @TypeOf(self.p_length).Indexer; + const idx = self.rand.weightedIndex(f64, &self.p_length.values); + var utf8_len = Indexer.keyForIndex(idx); + assert(rem > 0); + while (@intFromEnum(utf8_len) > rem) { + // If the chosen length can't fit into the remaining buffer, + // choose a smaller length. + utf8_len = @enumFromInt(@intFromEnum(utf8_len) - 1); + } + break :len utf8_len; + }; + + // Generate a UTF-8 sequence that encodes to this length. + const cp: u21 = switch (utf8_len) { + .one => self.rand.intRangeAtMostBiased(u21, 0x00, 0x7F), + .two => self.rand.intRangeAtMostBiased(u21, 0x80, 0x7FF), + .three => self.rand.intRangeAtMostBiased(u21, 0x800, 0xFFFF), + .four => self.rand.intRangeAtMostBiased(u21, 0x10000, 0x10FFFF), + }; + + assert(std.unicode.utf8CodepointSequenceLength( + cp, + ) catch unreachable == @intFromEnum(utf8_len)); + rem -= std.unicode.utf8Encode( + cp, + result[result.len - rem ..], + ) catch |err| switch (err) { + // Impossible because our generation above is hardcoded to + // produce a valid range. If not, a bug. + error.CodepointTooLarge => unreachable, + + // Possible, in which case we redo the loop and encode nothing. + error.Utf8CannotEncodeSurrogateHalf => continue, + }; + } + + return result; +} + +test "utf8" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0); + var buf: [256]u8 = undefined; + var v: Utf8 = .{ .rand = prng.random() }; + const gen = v.generator(); + const result = try gen.next(&buf); + try testing.expect(result.len > 0); + try testing.expect(std.unicode.utf8ValidateSlice(result)); +} diff --git a/src/synthetic/main.zig b/src/synthetic/main.zig new file mode 100644 index 000000000..67cd47054 --- /dev/null +++ b/src/synthetic/main.zig @@ -0,0 +1,23 @@ +//! The synthetic package contains an abstraction for generating +//! synthetic data. The motivating use case for this package is to +//! generate synthetic data for benchmarking, but it may also expand +//! to other use cases such as fuzzing (e.g. to generate a corpus +//! rather than directly fuzzing). +//! +//! The generators in this package are typically not performant +//! enough to be streamed in real time. They should instead be +//! used to generate a large amount of data in a single go +//! and then streamed from there. +//! +//! The generators are aimed for terminal emulation, but the package +//! is not limited to that and we may want to extract this to a +//! standalone package one day. + +pub const Generator = @import("Generator.zig"); +pub const Bytes = @import("Bytes.zig"); +pub const Utf8 = @import("Utf8.zig"); +pub const Osc = @import("Osc.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} From adbf834c36e9d780aa7da855b3f278ba7231a0e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 17:40:25 +0000 Subject: [PATCH 064/245] build(deps): bump cachix/install-nix-action from 30 to 31 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 30 to 31. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/install-nix-action/compare/v30...v31) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: '31' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-pr.yml | 4 +-- .github/workflows/release-tag.yml | 4 +-- .github/workflows/release-tip.yml | 8 ++--- .github/workflows/test.yml | 42 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ec55f2dff..f87f27c5a 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -42,7 +42,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@v30 + uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index ced497997..62ec4ff7c 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -209,7 +209,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ab103d6df..7400e1d40 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable @@ -130,7 +130,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index d23787743..7510a8b52 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -112,7 +112,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -164,7 +164,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -379,7 +379,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -554,7 +554,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b32eda0f6..b09a6d095 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,7 +74,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -105,7 +105,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -141,7 +141,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -170,7 +170,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -203,7 +203,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -247,7 +247,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -276,7 +276,7 @@ jobs: uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -316,7 +316,7 @@ jobs: uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -499,7 +499,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -530,7 +530,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -575,7 +575,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -614,7 +614,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -634,7 +634,7 @@ jobs: uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -667,7 +667,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -694,7 +694,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -721,7 +721,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -748,7 +748,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -775,7 +775,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -802,7 +802,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -839,7 +839,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 + - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -896,7 +896,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@v30 + uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index fed6d2db7..1481c3a86 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@v30 + uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 From 56fb1cbaaf6cfca40fa5130d6c0511e65a36ce19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 17:40:28 +0000 Subject: [PATCH 065/245] build(deps): bump namespacelabs/nscloud-cache-action from 1.2.0 to 1.2.7 Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.0 to 1.2.7. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/v1.2.0...v1.2.7) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/test.yml | 38 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ec55f2dff..e28d71daf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ab103d6df..7b2b4ae9f 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index d23787743..bad9c40bf 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b32eda0f6..bacfbc42a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -98,7 +98,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -134,7 +134,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -163,7 +163,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -196,7 +196,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -240,7 +240,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -382,7 +382,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -492,7 +492,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -523,7 +523,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -568,7 +568,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -607,7 +607,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -662,7 +662,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -689,7 +689,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -716,7 +716,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -743,7 +743,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -770,7 +770,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -797,7 +797,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -832,7 +832,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix @@ -890,7 +890,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index fed6d2db7..0eef9d9c9 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 + uses: namespacelabs/nscloud-cache-action@v1.2.7 with: path: | /nix From 907956130035eb086ea7428ce100f9b32f3f4600 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 21:15:50 +0000 Subject: [PATCH 066/245] build(deps): bump cachix/cachix-action from 15 to 16 Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 15 to 16. - [Release notes](https://github.com/cachix/cachix-action/releases) - [Commits](https://github.com/cachix/cachix-action/compare/v15...v16) --- updated-dependencies: - dependency-name: cachix/cachix-action dependency-version: '16' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-pr.yml | 4 +-- .github/workflows/release-tag.yml | 4 +-- .github/workflows/release-tip.yml | 8 ++--- .github/workflows/test.yml | 42 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f87f27c5a..7a64ae605 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -45,7 +45,7 @@ jobs: uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 62ec4ff7c..574b1ab73 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -60,7 +60,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -212,7 +212,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 7400e1d40..26a08be89 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -93,7 +93,7 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -133,7 +133,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 7510a8b52..84ba3d3de 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -115,7 +115,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -167,7 +167,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -382,7 +382,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -557,7 +557,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b09a6d095..2016fd41c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -108,7 +108,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -144,7 +144,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -173,7 +173,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -206,7 +206,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -250,7 +250,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -279,7 +279,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -319,7 +319,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -502,7 +502,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -533,7 +533,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -578,7 +578,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -617,7 +617,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -637,7 +637,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -670,7 +670,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -697,7 +697,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -724,7 +724,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -751,7 +751,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -778,7 +778,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -805,7 +805,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -842,7 +842,7 @@ jobs: - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -899,7 +899,7 @@ jobs: uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 1481c3a86..855e7f637 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -32,7 +32,7 @@ jobs: uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" From ab25600b2dd63d877b3ed56e58f8350dd30dd700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoffer=20T=C3=B8nnessen?= Date: Fri, 23 May 2025 10:46:24 +0200 Subject: [PATCH 067/245] Add new and update Norwegian split translations This change changes the wording on the split pane functionality. The new wording is taken from the macOS terminal app when the whole system is translated to Norwegian. macOS uses "Del opp vindu" and "Lukk delt vindu" for "Split Pane" and "Close Split Pane". So instead of using "split" the verb in question is always "del". Personally I find this translation to be better rooted in Norwegian. When looking at the German translation, which is often a good indicator for Norwegian as well, one can see the same wording being used. --- po/nb_NO.UTF-8.po | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index ad76eea3d..2685d67bb 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -63,25 +63,25 @@ msgstr "Last konfigurasjon på nytt" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 msgid "Split Up" -msgstr "Splitt opp" +msgstr "Del oppover" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 msgid "Split Down" -msgstr "Splitt ned" +msgstr "Del nedover" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 msgid "Split Left" -msgstr "Splitt venstre" +msgstr "Del til venstre" #: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 msgid "Split Right" -msgstr "Splitt høyre" +msgstr "Del til høyre" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -107,7 +107,7 @@ msgstr "Nullstill" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 msgid "Split" -msgstr "Splitt" +msgstr "Del vindu" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 @@ -218,7 +218,7 @@ msgstr "Se åpne faner" #: src/apprt/gtk/Window.zig:249 msgid "New Split" -msgstr "" +msgstr "Del opp vindu" #: src/apprt/gtk/Window.zig:312 msgid "" @@ -251,7 +251,7 @@ msgstr "Lukk fane?" #: src/apprt/gtk/CloseDialog.zig:90 msgid "Close Split?" -msgstr "Lukk splitt?" +msgstr "Lukk delt vindu?" #: src/apprt/gtk/CloseDialog.zig:96 msgid "All terminal sessions will be terminated." From a8651882a752ecba3d3ad2177d7b288969d4a31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Sat, 24 May 2025 00:29:53 +0200 Subject: [PATCH 068/245] add cut/copy/paste keys The origin of these keys are old sun keyboards. They are getting picked up by the custom (progammable) keyboard scene (see https://github.com/manna-harbour/miryoku for a popular layout). Support in ghosty is quite handy because it allows to bind copy/paste in a way that doesn't overlap with ctrl-c/ctrl-v, which can have special bindings in some terminal applications. --- include/ghostty.h | 5 +++++ src/apprt/gtk/key.zig | 4 ++++ src/input/key.zig | 8 ++++++++ src/input/keycodes.zig | 3 +++ 4 files changed, 20 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 941223943..950f5ef80 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -292,6 +292,11 @@ typedef enum { GHOSTTY_KEY_AUDIO_VOLUME_MUTE, GHOSTTY_KEY_AUDIO_VOLUME_UP, GHOSTTY_KEY_WAKE_UP, + + // "Legacy, Non-standard, and Special Keys" § 3.7 + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, } ghostty_input_key_e; typedef struct { diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 3dcfaed98..fc3296366 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -388,6 +388,10 @@ const keymap: []const RawEntry = &.{ .{ gdk.KEY_KP_Delete, .numpad_delete }, .{ gdk.KEY_KP_Begin, .numpad_begin }, + .{ gdk.KEY_Copy, .copy }, + .{ gdk.KEY_Cut, .cut }, + .{ gdk.KEY_Paste, .paste }, + .{ gdk.KEY_Shift_L, .shift_left }, .{ gdk.KEY_Control_L, .control_left }, .{ gdk.KEY_Alt_L, .alt_left }, diff --git a/src/input/key.zig b/src/input/key.zig index 9dad37d78..28aa3ccf4 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -454,6 +454,11 @@ pub const Key = enum(c_int) { audio_volume_up, wake_up, + // "Legacy, Non-standard, and Special Keys" § 3.7 + copy, + cut, + paste, + /// Converts an ASCII character to a key, if possible. This returns /// null if the character is unknown. /// @@ -797,6 +802,9 @@ pub const Key = enum(c_int) { .audio_volume_up, .wake_up, .help, + .copy, + .cut, + .paste, => null, .unidentified, diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig index b4004088e..a85f36d31 100644 --- a/src/input/keycodes.zig +++ b/src/input/keycodes.zig @@ -130,6 +130,9 @@ const code_to_key = code_to_key: { .{ "PageUp", .page_up }, .{ "Delete", .delete }, .{ "End", .end }, + .{ "Copy", .copy }, + .{ "Cut", .cut }, + .{ "Paste", .paste }, .{ "PageDown", .page_down }, .{ "ArrowRight", .arrow_right }, .{ "ArrowLeft", .arrow_left }, From b94d2da56745eca0544012aa443ea71a54a195b6 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 25 May 2025 00:15:05 +0000 Subject: [PATCH 069/245] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 796ce1475..3c6ed95ed 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", - .hash = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz", + .hash = "N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 68ec4522a..b1d919f3a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn": { + "N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", - "hash": "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz", + "hash": "sha256-2AsOCV9RymfDbhFFRdNVE+GYCAmE713tM27TBPKxAW0=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 7c3e08d2d..ce4a656c7 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn"; + name = "N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz"; - hash = "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz"; + hash = "sha256-2AsOCV9RymfDbhFFRdNVE+GYCAmE713tM27TBPKxAW0="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 0c71c80e4..cb8195752 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 2ee48f269..d56e6d121 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz", - "dest": "vendor/p/N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn", - "sha256": "0ca595531644640f31fb79e33da4ee72bfeefcc9a179fd18c21c0e9ce5a7fcde" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz", + "dest": "vendor/p/N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj", + "sha256": "d80b0e095f51ca67c36e114545d35513e198080984ef5ded336ed304f2b1016d" }, { "type": "archive", From 0415a65083fa64b89b9b2048e1b8d02e1411c2f3 Mon Sep 17 00:00:00 2001 From: alex-huff Date: Sun, 25 May 2025 11:43:40 -0500 Subject: [PATCH 070/245] gtk: improve app id validation 'g_application_id_is_valid' doesn't allow empty elements or elements that start with digits. This commit updates 'isValidAppId' to be more consistant with 'g_application_id_is_valid' avoiding the app id defaulting to 'GTK Application' for app ids like '0foo.bar' or 'foo..bar'. --- src/apprt/gtk/App.zig | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index da828b973..9e5037d5c 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1690,30 +1690,33 @@ fn initActions(self: *App) void { } fn isValidAppId(app_id: [:0]const u8) bool { - if (app_id.len > 255 or app_id.len == 0) return false; - if (app_id[0] == '.') return false; - if (app_id[app_id.len - 1] == '.') return false; + if (app_id.len > 255) return false; - var hasDot = false; + var hasSep = false; + var lastWasSep = true; for (app_id) |char| { switch (char) { - 'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => {}, - '.' => hasDot = true, + 'a'...'z', 'A'...'Z', '_', '-' => {}, + '0'...'9', '.' => if (lastWasSep) return false, else => return false, } + lastWasSep = char == '.'; + hasSep = hasSep or lastWasSep; } - if (!hasDot) return false; - - return true; + return hasSep and !lastWasSep; } test "isValidAppId" { try testing.expect(isValidAppId("foo.bar")); try testing.expect(isValidAppId("foo.bar.baz")); + try testing.expect(isValidAppId("f00.bar")); + try testing.expect(isValidAppId("foo-bar._baz")); try testing.expect(!isValidAppId("foo")); try testing.expect(!isValidAppId("foo.bar?")); try testing.expect(!isValidAppId("foo.")); try testing.expect(!isValidAppId(".foo")); try testing.expect(!isValidAppId("")); try testing.expect(!isValidAppId("foo" ** 86)); + try testing.expect(!isValidAppId("foo..bar")); + try testing.expect(!isValidAppId("0foo.bar")); } From 113c196078bc21c0dfd6d15d04203a255839a091 Mon Sep 17 00:00:00 2001 From: alex-huff Date: Sun, 25 May 2025 13:20:29 -0500 Subject: [PATCH 071/245] gtk: use 'gio.Application.idIsValid' instead of 'isValidAppId' --- src/apprt/gtk/App.zig | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 9e5037d5c..55c0be5e0 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -288,7 +288,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // can develop Ghostty in Ghostty. const app_id: [:0]const u8 = app_id: { if (config.class) |class| { - if (isValidAppId(class)) { + if (gio.Application.idIsValid(class) != 0) { break :app_id class; } else { log.warn("invalid 'class' in config, ignoring", .{}); @@ -1688,35 +1688,3 @@ fn initActions(self: *App) void { action_map.addAction(action.as(gio.Action)); } } - -fn isValidAppId(app_id: [:0]const u8) bool { - if (app_id.len > 255) return false; - - var hasSep = false; - var lastWasSep = true; - for (app_id) |char| { - switch (char) { - 'a'...'z', 'A'...'Z', '_', '-' => {}, - '0'...'9', '.' => if (lastWasSep) return false, - else => return false, - } - lastWasSep = char == '.'; - hasSep = hasSep or lastWasSep; - } - return hasSep and !lastWasSep; -} - -test "isValidAppId" { - try testing.expect(isValidAppId("foo.bar")); - try testing.expect(isValidAppId("foo.bar.baz")); - try testing.expect(isValidAppId("f00.bar")); - try testing.expect(isValidAppId("foo-bar._baz")); - try testing.expect(!isValidAppId("foo")); - try testing.expect(!isValidAppId("foo.bar?")); - try testing.expect(!isValidAppId("foo.")); - try testing.expect(!isValidAppId(".foo")); - try testing.expect(!isValidAppId("")); - try testing.expect(!isValidAppId("foo" ** 86)); - try testing.expect(!isValidAppId("foo..bar")); - try testing.expect(!isValidAppId("0foo.bar")); -} From 19db2e2755c9abb4946fbb2dbadea85516703138 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 25 May 2025 22:25:23 -0600 Subject: [PATCH 072/245] CircBuf: non-allocating rotateToZero We can call `std.mem.rotate` for this. --- src/datastruct/circ_buf.zig | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 065bf6a1d..646a00940 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -152,7 +152,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// If larger, new values will be set to the default value. pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void { // Rotate to zero so it is aligned. - try self.rotateToZero(alloc); + try self.rotateToZero(); // Reallocate, this adds to the end so we're ready to go. const prev_len = self.len(); @@ -173,29 +173,16 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { } /// Rotate the data so that it is zero-aligned. - fn rotateToZero(self: *Self, alloc: Allocator) Allocator.Error!void { - // TODO: this does this in the worst possible way by allocating. - // rewrite to not allocate, its possible, I'm just lazy right now. - + fn rotateToZero(self: *Self) Allocator.Error!void { // If we're already at zero then do nothing. if (self.tail == 0) return; - var buf = try alloc.alloc(T, self.storage.len); - defer { - self.head = if (self.full) 0 else self.len(); - self.tail = 0; - alloc.free(self.storage); - self.storage = buf; - } + // We use std.mem.rotate to rotate our storage in-place. + std.mem.rotate(T, self.storage, self.tail); - if (!self.full and self.head >= self.tail) { - fastmem.copy(T, buf, self.storage[self.tail..self.head]); - return; - } - - const middle = self.storage.len - self.tail; - fastmem.copy(T, buf, self.storage[self.tail..]); - fastmem.copy(T, buf[middle..], self.storage[0..self.head]); + // Then fix up our head and tail. + self.head = self.len() % self.storage.len; + self.tail = 0; } /// Returns if the buffer is currently empty. To check if its @@ -589,7 +576,7 @@ test "CircBuf rotateToZero" { defer buf.deinit(alloc); _ = buf.getPtrSlice(0, 11); - try buf.rotateToZero(alloc); + try buf.rotateToZero(); } test "CircBuf rotateToZero offset" { @@ -611,7 +598,7 @@ test "CircBuf rotateToZero offset" { try testing.expect(buf.tail > 0 and buf.head >= buf.tail); // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 1), buf.head); } @@ -645,7 +632,7 @@ test "CircBuf rotateToZero wraps" { } // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 3), buf.head); { @@ -681,7 +668,7 @@ test "CircBuf rotateToZero full no wrap" { } // Rotate to zero - try buf.rotateToZero(alloc); + try buf.rotateToZero(); try testing.expect(buf.full); try testing.expectEqual(@as(usize, 0), buf.tail); try testing.expectEqual(@as(usize, 0), buf.head); From 25a708ed9831083db9555da16137df7d98c38de3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 25 May 2025 22:51:14 -0600 Subject: [PATCH 073/245] terminal/style: compare packed styles directly, no cast needed Woohoo, Zig 0.14! --- src/terminal/style.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 7f176561b..34b07772a 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -87,10 +87,9 @@ pub const Style = struct { /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { - const packed_self = PackedStyle.fromStyle(self); - const packed_other = PackedStyle.fromStyle(other); - // TODO: in Zig 0.14, equating packed structs is allowed. Remove this work around. - return @as(u128, @bitCast(packed_self)) == @as(u128, @bitCast(packed_other)); + // We convert the styles to packed structs and compare as integers + // because this is much faster than comparing each field separately. + return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other); } /// Returns the bg color for a cell with this style given the cell From 98309e3226dd7589b70bc974cd6ba6efd60954c8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 26 May 2025 10:47:43 -0500 Subject: [PATCH 074/245] nix: update to Nix 25.05 and Zig 0.14.1 Update to Nix 25.05 which gets us GTK 4.18, libadwaita 1.7, and Zig 0.14.1. Since Nix updated to Zig 0.14.1, the devshell has been switched to Zig 0.14.1 from zig-overlay as well. Fixes #7305 --- flake.lock | 54 +++++++++++++++++------------------------------------- flake.nix | 42 +++++++++++++++++------------------------- 2 files changed, 34 insertions(+), 62 deletions(-) diff --git a/flake.lock b/flake.lock index df09a9666..4b8ce405c 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1733328505, - "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", "owner": "edolstra", "repo": "flake-compat", - "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", "type": "github" }, "original": { @@ -34,44 +34,24 @@ "type": "github" } }, - "nixpkgs-stable": { + "nixpkgs": { "locked": { - "lastModified": 1741992157, - "narHash": "sha256-nlIfTsTrMSksEJc1f7YexXiPVuzD1gOfeN1ggwZyUoc=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "da4b122f63095ca1199bd4d526f9e26426697689", - "type": "github" + "lastModified": 1748189127, + "narHash": "sha256-zRDR+EbbeObu4V2X5QCd2Bk5eltfDlCr5yvhBwUT6pY=", + "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.802491.7c43f080a7f2/nixexprs.tar.xz" }, "original": { - "owner": "nixos", - "ref": "release-24.11", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-unstable": { - "locked": { - "lastModified": 1741865919, - "narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" + "type": "tarball", + "url": "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz" } }, "root": { "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", - "nixpkgs-stable": "nixpkgs-stable", - "nixpkgs-unstable": "nixpkgs-unstable", + "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" } @@ -98,15 +78,15 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs-stable" + "nixpkgs" ] }, "locked": { - "lastModified": 1741825901, - "narHash": "sha256-aeopo+aXg5I2IksOPFN79usw7AeimH1+tjfuMzJHFdk=", + "lastModified": 1748261582, + "narHash": "sha256-3i0IL3s18hdDlbsf0/E+5kyPRkZwGPbSFngq5eToiAA=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "0b14285e283f5a747f372fb2931835dd937c4383", + "rev": "aafb1b093fb838f7a02613b719e85ec912914221", "type": "github" }, "original": { @@ -121,7 +101,7 @@ "flake-utils" ], "nixpkgs": [ - "nixpkgs-unstable" + "nixpkgs" ] }, "locked": { diff --git a/flake.nix b/flake.nix index d4c6aa6ca..6794afb11 100644 --- a/flake.nix +++ b/flake.nix @@ -2,12 +2,10 @@ description = "👻"; inputs = { - nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable"; - # We want to stay as up to date as possible but need to be careful that the # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. - nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11"; + nixpkgs.url = "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; # Used for shell.nix @@ -19,7 +17,7 @@ zig = { url = "github:mitchellh/zig-overlay"; inputs = { - nixpkgs.follows = "nixpkgs-stable"; + nixpkgs.follows = "nixpkgs"; flake-utils.follows = "flake-utils"; flake-compat.follows = ""; }; @@ -28,7 +26,7 @@ zon2nix = { url = "github:jcollie/zon2nix?ref=56c159be489cc6c0e73c3930bd908ddc6fe89613"; inputs = { - nixpkgs.follows = "nixpkgs-unstable"; + nixpkgs.follows = "nixpkgs"; flake-utils.follows = "flake-utils"; }; }; @@ -36,24 +34,19 @@ outputs = { self, - nixpkgs-unstable, - nixpkgs-stable, + nixpkgs, zig, zon2nix, ... }: - builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} ( + builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( builtins.map ( system: let - pkgs-stable = nixpkgs-stable.legacyPackages.${system}; - pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; + pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.14.0"; - wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; - uv = pkgs-unstable.uv; - # remove once blueprint-compiler 0.16.0 is in the stable nixpkgs - blueprint-compiler = pkgs-unstable.blueprint-compiler; + devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.14.1"; + wraptest = pkgs.callPackage ./nix/wraptest.nix {}; zon2nix = zon2nix; }; @@ -64,30 +57,29 @@ revision = self.shortRev or self.dirtyShortRev or "dirty"; }; in rec { - deps = pkgs-unstable.callPackage ./build.zig.zon.nix {}; - ghostty-debug = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "Debug"); - ghostty-releasesafe = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); - ghostty-releasefast = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); + deps = pkgs.callPackage ./build.zig.zon.nix {}; + ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug"); + ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); + ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); ghostty = ghostty-releasefast; default = ghostty; }; - formatter.${system} = pkgs-stable.alejandra; + formatter.${system} = pkgs.alejandra; apps.${system} = let runVM = ( module: let vm = import ./nix/vm/create.nix { - inherit system module; - nixpkgs = nixpkgs-unstable; + inherit system module nixpkgs; overlay = self.overlays.debug; }; - program = pkgs-unstable.writeShellScript "run-ghostty-vm" '' + program = pkgs.writeShellScript "run-ghostty-vm" '' SHARED_DIR=$(pwd) export SHARED_DIR - ${pkgs-unstable.lib.getExe vm.config.system.build.vm} "$@" + ${pkgs.lib.getExe vm.config.system.build.vm} "$@" ''; in { type = "app"; From 48b6807ac979324292c8d36aa837b7352da627ba Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 26 May 2025 11:12:30 -0500 Subject: [PATCH 075/245] nix: fix typos --- macos/Sources/Ghostty/Ghostty.App.swift | 4 ++-- typos.toml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 6736449a4..d8fdaa3ec 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -745,7 +745,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let mode = FullscreenMode.from(ghostty: raw) else { - Ghostty.logger.warning("unknow fullscreen mode raw=\(raw.rawValue)") + Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)") return } NotificationCenter.default.post( @@ -1082,7 +1082,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } guard let window = surfaceView.window as? TerminalWindow else { return } - + switch (mode) { case .on: window.level = .floating diff --git a/typos.toml b/typos.toml index 4f4bf7ee7..fafc38858 100644 --- a/typos.toml +++ b/typos.toml @@ -49,6 +49,8 @@ grey = "gray" greyscale = "grayscale" DECID = "DECID" flate = "flate" +typ = "typ" +kend = "kend" [type.po] extend-glob = ["*.po"] From 695e0b3e5780919a6549c827bb282c1a8d516253 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 26 May 2025 11:43:52 -0500 Subject: [PATCH 076/245] nix: temporarily remove snapcraft from the devshell --- nix/devShell.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index b87c23dd1..f4ea62235 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -16,7 +16,7 @@ python3, qemu, scdoc, - snapcraft, + # snapcraft, valgrind, #, vulkan-loader # unused vttest, @@ -134,7 +134,7 @@ in appstream flatpak-builder gdb - snapcraft + # snapcraft valgrind wraptest From 2905b4727980b8d1109f333e0d78db7674eb0e48 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 26 May 2025 19:39:39 -0600 Subject: [PATCH 077/245] font: use labeled switch continue pattern for feature string parser In this case it does result in a little repeated code for reading bytes, but I find the control flow easier to follow, so it's worth it IMO. --- src/font/shaper/feature.zig | 300 ++++++++++++++++-------------------- 1 file changed, 133 insertions(+), 167 deletions(-) diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 8e70d51da..c2d49234d 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -35,190 +35,156 @@ pub const Feature = struct { /// /// 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 tag_buf: [4]u8 = undefined; + var tag: []u8 = tag_buf[0..0]; 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) { + state: switch ((enum { /// Initial state. - start: void, - /// Parsing the tag, data is index. - tag: u2, + start, + /// Parsing the tag. + tag, /// In the space between the tag and the value. - space: void, + space, /// Parsing an integer parameter directly in to `value`. - int: void, + int, /// Parsing a boolean keyword parameter ("on"/"off"). - bool: void, + bool, /// 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; - }, + err, + /// Done parsing feature, skip whitespace until end. + done, + }).start) { + // If we're done then we skip whitespace until we see a ','. + .done => while (true) switch (reader.readByte() catch ',') { + ' ', '\t' => continue, + ',' => break, + // If we see something other than whitespace or a ',' + // then this is an error since the intent is unclear. + else => continue :state .err, + }, + + // If we're fast-forwarding from an error we just wanna + // stop at the first boundary and ignore all other bytes. + .err => { + reader.skipUntilDelimiterOrEof(',') catch {}; + return null; + }, + + .start => while (true) switch (reader.readByte() catch ',') { + // Ignore leading whitespace. + ' ', '\t' => continue, + // Empty feature string. + ',' => return null, + // '+' prefix to explicitly enable feature. + '+' => { + value = 1; + continue :state .tag; }, + // '-' prefix to explicitly disable feature. + '-' => { + value = 0; + continue :state .tag; + }, + // Quote mark introducing a tag. + '"', '\'' => { + continue :state .tag; + }, + // First letter of tag. + else => |byte| { + tag.len = 1; + tag[0] = byte; + continue :state .tag; + }, + }, - // 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, + .tag => while (true) switch (reader.readByte() catch ',') { + // If the tag is interrupted by a comma it's invalid. + ',' => return null, + // Ignore quote marks. This does technically ignore cases like + // "'k'e'r'n' = 0", but it's unambiguous so if someone really + // wants to do that in their config then... sure why not. + '"', '\'' => continue, + // In all other cases we add the byte to our tag. + else => |byte| { + tag.len += 1; + tag[tag.len - 1] = byte; + if (tag.len == 4) continue :state .space; + }, + }, - .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. - '-' => { + .space => while (true) switch (reader.readByte() catch ',') { + ' ', '\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) continue :state .err, + ',' => { + // Specifying only a tag turns a feature on. + if (value == null) value = 1; + break; + }, + '0'...'9' => |byte| { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) continue :state .err; + value = byte - '0'; + continue :state .int; + }, + 'o', 'O' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) continue :state .err; + continue :state .bool; + }, + else => continue :state .err, + }, + + .int => while (true) switch (reader.readByte() catch ',') { + ',' => break, + '0'...'9' => |byte| { + // If our value gets too big while + // parsing we consider it an error. + value = std.math.mul(u32, value.?, 10) catch { + continue :state .err; + }; + value.? += byte - '0'; + }, + else => continue :state .err, + }, + + .bool => while (true) switch (reader.readByte() catch ',') { + ',' => return null, + 'n', 'N' => { + // "ofn" + if (value != null) { + assert(value == 0); + continue :state .err; + } + value = 1; + continue :state .done; + }, + 'f', 'F' => { + // To make sure we consume two 'f's. + if (value == null) { 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; - }, + } else { + assert(value == 0); + continue :state .done; + } }, - - .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; - }, - }, - } + else => continue :state .err, + }, } assert(value != null); + assert(tag.len == 4); return .{ - .tag = tag, + .tag = tag_buf, .value = value.?, }; } From 2fe2ccdbde58557f17ba1787551dfb0666b6a147 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 26 May 2025 19:56:35 -0600 Subject: [PATCH 078/245] font/sprite: use decl literals in box drawing code Cleaner and less visual noise, easy change to make, there are many other areas in the code which would benefit from decl literals as well, but this is an area that benefits a lot from them and is self-contained. --- src/font/sprite/Box.zig | 310 ++++++++++++++++++++-------------------- 1 file changed, 155 insertions(+), 155 deletions(-) diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index 68acdabe5..b1ebfe3a9 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -516,40 +516,40 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void 0x257f => self.draw_lines(canvas, .{ .up = .heavy, .down = .light }), // '▀' UPPER HALF BLOCK - 0x2580 => self.draw_block(canvas, Alignment.upper, 1, half), + 0x2580 => self.draw_block(canvas, .upper, 1, half), // '▁' LOWER ONE EIGHTH BLOCK - 0x2581 => self.draw_block(canvas, Alignment.lower, 1, one_eighth), + 0x2581 => self.draw_block(canvas, .lower, 1, one_eighth), // '▂' LOWER ONE QUARTER BLOCK - 0x2582 => self.draw_block(canvas, Alignment.lower, 1, one_quarter), + 0x2582 => self.draw_block(canvas, .lower, 1, one_quarter), // '▃' LOWER THREE EIGHTHS BLOCK - 0x2583 => self.draw_block(canvas, Alignment.lower, 1, three_eighths), + 0x2583 => self.draw_block(canvas, .lower, 1, three_eighths), // '▄' LOWER HALF BLOCK - 0x2584 => self.draw_block(canvas, Alignment.lower, 1, half), + 0x2584 => self.draw_block(canvas, .lower, 1, half), // '▅' LOWER FIVE EIGHTHS BLOCK - 0x2585 => self.draw_block(canvas, Alignment.lower, 1, five_eighths), + 0x2585 => self.draw_block(canvas, .lower, 1, five_eighths), // '▆' LOWER THREE QUARTERS BLOCK - 0x2586 => self.draw_block(canvas, Alignment.lower, 1, three_quarters), + 0x2586 => self.draw_block(canvas, .lower, 1, three_quarters), // '▇' LOWER SEVEN EIGHTHS BLOCK - 0x2587 => self.draw_block(canvas, Alignment.lower, 1, seven_eighths), + 0x2587 => self.draw_block(canvas, .lower, 1, seven_eighths), // '█' FULL BLOCK 0x2588 => self.draw_full_block(canvas), // '▉' LEFT SEVEN EIGHTHS BLOCK - 0x2589 => self.draw_block(canvas, Alignment.left, seven_eighths, 1), + 0x2589 => self.draw_block(canvas, .left, seven_eighths, 1), // '▊' LEFT THREE QUARTERS BLOCK - 0x258a => self.draw_block(canvas, Alignment.left, three_quarters, 1), + 0x258a => self.draw_block(canvas, .left, three_quarters, 1), // '▋' LEFT FIVE EIGHTHS BLOCK - 0x258b => self.draw_block(canvas, Alignment.left, five_eighths, 1), + 0x258b => self.draw_block(canvas, .left, five_eighths, 1), // '▌' LEFT HALF BLOCK - 0x258c => self.draw_block(canvas, Alignment.left, half, 1), + 0x258c => self.draw_block(canvas, .left, half, 1), // '▍' LEFT THREE EIGHTHS BLOCK - 0x258d => self.draw_block(canvas, Alignment.left, three_eighths, 1), + 0x258d => self.draw_block(canvas, .left, three_eighths, 1), // '▎' LEFT ONE QUARTER BLOCK - 0x258e => self.draw_block(canvas, Alignment.left, one_quarter, 1), + 0x258e => self.draw_block(canvas, .left, one_quarter, 1), // '▏' LEFT ONE EIGHTH BLOCK - 0x258f => self.draw_block(canvas, Alignment.left, one_eighth, 1), + 0x258f => self.draw_block(canvas, .left, one_eighth, 1), // '▐' RIGHT HALF BLOCK - 0x2590 => self.draw_block(canvas, Alignment.right, half, 1), + 0x2590 => self.draw_block(canvas, .right, half, 1), // '░' 0x2591 => self.draw_light_shade(canvas), // '▒' @@ -557,9 +557,9 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void // '▓' 0x2593 => self.draw_dark_shade(canvas), // '▔' UPPER ONE EIGHTH BLOCK - 0x2594 => self.draw_block(canvas, Alignment.upper, 1, one_eighth), + 0x2594 => self.draw_block(canvas, .upper, 1, one_eighth), // '▕' RIGHT ONE EIGHTH BLOCK - 0x2595 => self.draw_block(canvas, Alignment.right, one_eighth, 1), + 0x2595 => self.draw_block(canvas, .right, one_eighth, 1), // '▖' 0x2596 => self.draw_quadrant(canvas, .{ .bl = true }), // '▗' @@ -588,35 +588,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void octant_min...octant_max => self.draw_octant(canvas, cp), // '🬼' - 0x1fb3c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3c => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\#.. \\##. )), // '🬽' - 0x1fb3d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3d => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\#\. \\### )), // '🬾' - 0x1fb3e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3e => try self.draw_smooth_mosaic(canvas, .from( \\... \\#.. \\#\. \\##. )), // '🬿' - 0x1fb3f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb3f => try self.draw_smooth_mosaic(canvas, .from( \\... \\#.. \\##. \\### )), // '🭀' - 0x1fb40 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb40 => try self.draw_smooth_mosaic(canvas, .from( \\#.. \\#.. \\##. @@ -624,42 +624,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭁' - 0x1fb41 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb41 => try self.draw_smooth_mosaic(canvas, .from( \\/## \\### \\### \\### )), // '🭂' - 0x1fb42 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb42 => try self.draw_smooth_mosaic(canvas, .from( \\./# \\### \\### \\### )), // '🭃' - 0x1fb43 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb43 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\### \\### )), // '🭄' - 0x1fb44 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb44 => try self.draw_smooth_mosaic(canvas, .from( \\..# \\.## \\### \\### )), // '🭅' - 0x1fb45 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb45 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\.## \\### )), // '🭆' - 0x1fb46 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb46 => try self.draw_smooth_mosaic(canvas, .from( \\... \\./# \\### @@ -667,35 +667,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭇' - 0x1fb47 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb47 => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\..# \\.## )), // '🭈' - 0x1fb48 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb48 => try self.draw_smooth_mosaic(canvas, .from( \\... \\... \\./# \\### )), // '🭉' - 0x1fb49 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb49 => try self.draw_smooth_mosaic(canvas, .from( \\... \\..# \\./# \\.## )), // '🭊' - 0x1fb4a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4a => try self.draw_smooth_mosaic(canvas, .from( \\... \\..# \\.## \\### )), // '🭋' - 0x1fb4b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4b => try self.draw_smooth_mosaic(canvas, .from( \\..# \\..# \\.## @@ -703,42 +703,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭌' - 0x1fb4c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4c => try self.draw_smooth_mosaic(canvas, .from( \\##\ \\### \\### \\### )), // '🭍' - 0x1fb4d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4d => try self.draw_smooth_mosaic(canvas, .from( \\#\. \\### \\### \\### )), // '🭎' - 0x1fb4e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4e => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\### \\### )), // '🭏' - 0x1fb4f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb4f => try self.draw_smooth_mosaic(canvas, .from( \\#.. \\##. \\### \\### )), // '🭐' - 0x1fb50 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb50 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\##. \\### )), // '🭑' - 0x1fb51 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb51 => try self.draw_smooth_mosaic(canvas, .from( \\... \\#\. \\### @@ -746,35 +746,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭒' - 0x1fb52 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb52 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\\## )), // '🭓' - 0x1fb53 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb53 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\.\# )), // '🭔' - 0x1fb54 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb54 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.## \\.## )), // '🭕' - 0x1fb55 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb55 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.## \\..# )), // '🭖' - 0x1fb56 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb56 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.## \\.## @@ -782,35 +782,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭗' - 0x1fb57 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb57 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\#.. \\... \\... )), // '🭘' - 0x1fb58 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb58 => try self.draw_smooth_mosaic(canvas, .from( \\### \\#/. \\... \\... )), // '🭙' - 0x1fb59 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb59 => try self.draw_smooth_mosaic(canvas, .from( \\##. \\#/. \\#.. \\... )), // '🭚' - 0x1fb5a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5a => try self.draw_smooth_mosaic(canvas, .from( \\### \\##. \\#.. \\... )), // '🭛' - 0x1fb5b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5b => try self.draw_smooth_mosaic(canvas, .from( \\##. \\##. \\#.. @@ -818,42 +818,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭜' - 0x1fb5c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5c => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\#/. \\... )), // '🭝' - 0x1fb5d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5d => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\##/ )), // '🭞' - 0x1fb5e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5e => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\### \\#/. )), // '🭟' - 0x1fb5f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb5f => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\##. \\##. )), // '🭠' - 0x1fb60 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb60 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\##. \\#.. )), // '🭡' - 0x1fb61 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb61 => try self.draw_smooth_mosaic(canvas, .from( \\### \\##. \\##. @@ -861,42 +861,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void )), // '🭢' - 0x1fb62 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb62 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\..# \\... \\... )), // '🭣' - 0x1fb63 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb63 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.\# \\... \\... )), // '🭤' - 0x1fb64 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb64 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.\# \\..# \\... )), // '🭥' - 0x1fb65 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb65 => try self.draw_smooth_mosaic(canvas, .from( \\### \\.## \\..# \\... )), // '🭦' - 0x1fb66 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb66 => try self.draw_smooth_mosaic(canvas, .from( \\.## \\.## \\..# \\..# )), // '🭧' - 0x1fb67 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from( + 0x1fb67 => try self.draw_smooth_mosaic(canvas, .from( \\### \\### \\.\# @@ -959,79 +959,79 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void 0x1fb7b => self.draw_horizontal_one_eighth_block_n(canvas, 6), // '🮂' UPPER ONE QUARTER BLOCK - 0x1fb82 => self.draw_block(canvas, Alignment.upper, 1, one_quarter), + 0x1fb82 => self.draw_block(canvas, .upper, 1, one_quarter), // '🮃' UPPER THREE EIGHTHS BLOCK - 0x1fb83 => self.draw_block(canvas, Alignment.upper, 1, three_eighths), + 0x1fb83 => self.draw_block(canvas, .upper, 1, three_eighths), // '🮄' UPPER FIVE EIGHTHS BLOCK - 0x1fb84 => self.draw_block(canvas, Alignment.upper, 1, five_eighths), + 0x1fb84 => self.draw_block(canvas, .upper, 1, five_eighths), // '🮅' UPPER THREE QUARTERS BLOCK - 0x1fb85 => self.draw_block(canvas, Alignment.upper, 1, three_quarters), + 0x1fb85 => self.draw_block(canvas, .upper, 1, three_quarters), // '🮆' UPPER SEVEN EIGHTHS BLOCK - 0x1fb86 => self.draw_block(canvas, Alignment.upper, 1, seven_eighths), + 0x1fb86 => self.draw_block(canvas, .upper, 1, seven_eighths), // '🭼' LEFT AND LOWER ONE EIGHTH BLOCK 0x1fb7c => { - self.draw_block(canvas, Alignment.left, one_eighth, 1); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .left, one_eighth, 1); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🭽' LEFT AND UPPER ONE EIGHTH BLOCK 0x1fb7d => { - self.draw_block(canvas, Alignment.left, one_eighth, 1); - self.draw_block(canvas, Alignment.upper, 1, one_eighth); + self.draw_block(canvas, .left, one_eighth, 1); + self.draw_block(canvas, .upper, 1, one_eighth); }, // '🭾' RIGHT AND UPPER ONE EIGHTH BLOCK 0x1fb7e => { - self.draw_block(canvas, Alignment.right, one_eighth, 1); - self.draw_block(canvas, Alignment.upper, 1, one_eighth); + self.draw_block(canvas, .right, one_eighth, 1); + self.draw_block(canvas, .upper, 1, one_eighth); }, // '🭿' RIGHT AND LOWER ONE EIGHTH BLOCK 0x1fb7f => { - self.draw_block(canvas, Alignment.right, one_eighth, 1); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .right, one_eighth, 1); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🮀' UPPER AND LOWER ONE EIGHTH BLOCK 0x1fb80 => { - self.draw_block(canvas, Alignment.upper, 1, one_eighth); - self.draw_block(canvas, Alignment.lower, 1, one_eighth); + self.draw_block(canvas, .upper, 1, one_eighth); + self.draw_block(canvas, .lower, 1, one_eighth); }, // '🮁' 0x1fb81 => self.draw_horizontal_one_eighth_1358_block(canvas), // '🮇' RIGHT ONE QUARTER BLOCK - 0x1fb87 => self.draw_block(canvas, Alignment.right, one_quarter, 1), + 0x1fb87 => self.draw_block(canvas, .right, one_quarter, 1), // '🮈' RIGHT THREE EIGHTHS BLOCK - 0x1fb88 => self.draw_block(canvas, Alignment.right, three_eighths, 1), + 0x1fb88 => self.draw_block(canvas, .right, three_eighths, 1), // '🮉' RIGHT FIVE EIGHTHS BLOCK - 0x1fb89 => self.draw_block(canvas, Alignment.right, five_eighths, 1), + 0x1fb89 => self.draw_block(canvas, .right, five_eighths, 1), // '🮊' RIGHT THREE QUARTERS BLOCK - 0x1fb8a => self.draw_block(canvas, Alignment.right, three_quarters, 1), + 0x1fb8a => self.draw_block(canvas, .right, three_quarters, 1), // '🮋' RIGHT SEVEN EIGHTHS BLOCK - 0x1fb8b => self.draw_block(canvas, Alignment.right, seven_eighths, 1), + 0x1fb8b => self.draw_block(canvas, .right, seven_eighths, 1), // '🮌' - 0x1fb8c => self.draw_block_shade(canvas, Alignment.left, half, 1, .medium), + 0x1fb8c => self.draw_block_shade(canvas, .left, half, 1, .medium), // '🮍' - 0x1fb8d => self.draw_block_shade(canvas, Alignment.right, half, 1, .medium), + 0x1fb8d => self.draw_block_shade(canvas, .right, half, 1, .medium), // '🮎' - 0x1fb8e => self.draw_block_shade(canvas, Alignment.upper, 1, half, .medium), + 0x1fb8e => self.draw_block_shade(canvas, .upper, 1, half, .medium), // '🮏' - 0x1fb8f => self.draw_block_shade(canvas, Alignment.lower, 1, half, .medium), + 0x1fb8f => self.draw_block_shade(canvas, .lower, 1, half, .medium), // '🮐' 0x1fb90 => self.draw_medium_shade(canvas), // '🮑' 0x1fb91 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.upper, 1, half); + self.draw_block(canvas, .upper, 1, half); }, // '🮒' 0x1fb92 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.lower, 1, half); + self.draw_block(canvas, .lower, 1, half); }, // '🮔' 0x1fb94 => { self.draw_medium_shade(canvas); - self.draw_block(canvas, Alignment.right, half, 1); + self.draw_block(canvas, .right, half, 1); }, // '🮕' 0x1fb95 => self.draw_checkerboard_fill(canvas, 0), @@ -1117,194 +1117,194 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void }, // '🯎' - 0x1fbce => self.draw_block(canvas, Alignment.left, two_thirds, 1), + 0x1fbce => self.draw_block(canvas, .left, two_thirds, 1), // '🯏' - 0x1fbcf => self.draw_block(canvas, Alignment.left, one_third, 1), + 0x1fbcf => self.draw_block(canvas, .left, one_third, 1), // '🯐' 0x1fbd0 => self.draw_cell_diagonal( canvas, - Alignment.middle_right, - Alignment.lower_left, + .middle_right, + .lower_left, ), // '🯑' 0x1fbd1 => self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_left, + .upper_right, + .middle_left, ), // '🯒' 0x1fbd2 => self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_right, + .upper_left, + .middle_right, ), // '🯓' 0x1fbd3 => self.draw_cell_diagonal( canvas, - Alignment.middle_left, - Alignment.lower_right, + .middle_left, + .lower_right, ), // '🯔' 0x1fbd4 => self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.lower_center, + .upper_left, + .lower_center, ), // '🯕' 0x1fbd5 => self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_right, + .upper_center, + .lower_right, ), // '🯖' 0x1fbd6 => self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.lower_center, + .upper_right, + .lower_center, ), // '🯗' 0x1fbd7 => self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_left, + .upper_center, + .lower_left, ), // '🯘' 0x1fbd8 => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_center, + .upper_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.upper_right, + .middle_center, + .upper_right, ); }, // '🯙' 0x1fbd9 => { self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_center, + .upper_right, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_right, + .middle_center, + .lower_right, ); }, // '🯚' 0x1fbda => { self.draw_cell_diagonal( canvas, - Alignment.lower_left, - Alignment.middle_center, + .lower_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_right, + .middle_center, + .lower_right, ); }, // '🯛' 0x1fbdb => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_center, + .upper_left, + .middle_center, ); self.draw_cell_diagonal( canvas, - Alignment.middle_center, - Alignment.lower_left, + .middle_center, + .lower_left, ); }, // '🯜' 0x1fbdc => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.lower_center, + .upper_left, + .lower_center, ); self.draw_cell_diagonal( canvas, - Alignment.lower_center, - Alignment.upper_right, + .lower_center, + .upper_right, ); }, // '🯝' 0x1fbdd => { self.draw_cell_diagonal( canvas, - Alignment.upper_right, - Alignment.middle_left, + .upper_right, + .middle_left, ); self.draw_cell_diagonal( canvas, - Alignment.middle_left, - Alignment.lower_right, + .middle_left, + .lower_right, ); }, // '🯞' 0x1fbde => { self.draw_cell_diagonal( canvas, - Alignment.lower_left, - Alignment.upper_center, + .lower_left, + .upper_center, ); self.draw_cell_diagonal( canvas, - Alignment.upper_center, - Alignment.lower_right, + .upper_center, + .lower_right, ); }, // '🯟' 0x1fbdf => { self.draw_cell_diagonal( canvas, - Alignment.upper_left, - Alignment.middle_right, + .upper_left, + .middle_right, ); self.draw_cell_diagonal( canvas, - Alignment.middle_right, - Alignment.lower_left, + .middle_right, + .lower_left, ); }, // '🯠' - 0x1fbe0 => self.draw_circle(canvas, Alignment.top, false), + 0x1fbe0 => self.draw_circle(canvas, .top, false), // '🯡' - 0x1fbe1 => self.draw_circle(canvas, Alignment.right, false), + 0x1fbe1 => self.draw_circle(canvas, .right, false), // '🯢' - 0x1fbe2 => self.draw_circle(canvas, Alignment.bottom, false), + 0x1fbe2 => self.draw_circle(canvas, .bottom, false), // '🯣' - 0x1fbe3 => self.draw_circle(canvas, Alignment.left, false), + 0x1fbe3 => self.draw_circle(canvas, .left, false), // '🯤' - 0x1fbe4 => self.draw_block(canvas, Alignment.upper_center, 0.5, 0.5), + 0x1fbe4 => self.draw_block(canvas, .upper_center, 0.5, 0.5), // '🯥' - 0x1fbe5 => self.draw_block(canvas, Alignment.lower_center, 0.5, 0.5), + 0x1fbe5 => self.draw_block(canvas, .lower_center, 0.5, 0.5), // '🯦' - 0x1fbe6 => self.draw_block(canvas, Alignment.middle_left, 0.5, 0.5), + 0x1fbe6 => self.draw_block(canvas, .middle_left, 0.5, 0.5), // '🯧' - 0x1fbe7 => self.draw_block(canvas, Alignment.middle_right, 0.5, 0.5), + 0x1fbe7 => self.draw_block(canvas, .middle_right, 0.5, 0.5), // '🯨' - 0x1fbe8 => self.draw_circle(canvas, Alignment.top, true), + 0x1fbe8 => self.draw_circle(canvas, .top, true), // '🯩' - 0x1fbe9 => self.draw_circle(canvas, Alignment.right, true), + 0x1fbe9 => self.draw_circle(canvas, .right, true), // '🯪' - 0x1fbea => self.draw_circle(canvas, Alignment.bottom, true), + 0x1fbea => self.draw_circle(canvas, .bottom, true), // '🯫' - 0x1fbeb => self.draw_circle(canvas, Alignment.left, true), + 0x1fbeb => self.draw_circle(canvas, .left, true), // '🯬' - 0x1fbec => self.draw_circle(canvas, Alignment.top_right, true), + 0x1fbec => self.draw_circle(canvas, .top_right, true), // '🯭' - 0x1fbed => self.draw_circle(canvas, Alignment.bottom_left, true), + 0x1fbed => self.draw_circle(canvas, .bottom_left, true), // '🯮' - 0x1fbee => self.draw_circle(canvas, Alignment.bottom_right, true), + 0x1fbee => self.draw_circle(canvas, .bottom_right, true), // '🯯' - 0x1fbef => self.draw_circle(canvas, Alignment.top_left, true), + 0x1fbef => self.draw_circle(canvas, .top_left, true), // (Below:) // Branch drawing character set, used for drawing git-like From 2384bd69cc25db7228dcb2e90ea1d296bbf0ba84 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 26 May 2025 21:39:15 -0600 Subject: [PATCH 079/245] style: use decl literals This commit changes a LOT of areas of the code to use decl literals instead of redundantly referring to the type. These changes were mostly driven by some regex searches and then manual adjustment on a case-by-case basis. I almost certainly missed quite a few places where decl literals could be used, but this is a good first step in converting things, and other instances can be addressed when they're discovered. I tested GLFW+Metal and building the framework on macOS and tested a GTK build on Linux, so I'm 99% sure I didn't introduce any syntax errors or other problems with this. (fingers crossed) --- pkg/fontconfig/pattern.zig | 4 +- pkg/glfw/Monitor.zig | 2 +- pkg/glfw/opengl.zig | 2 +- src/Command.zig | 2 +- src/Surface.zig | 2 +- src/apprt/embedded.zig | 12 +++--- src/apprt/gtk/ClipboardConfirmationWindow.zig | 14 +++---- src/apprt/gtk/ConfigErrorsDialog.zig | 6 +-- src/apprt/gtk/ResizeOverlay.zig | 4 +- src/apprt/gtk/Split.zig | 2 +- src/apprt/gtk/Surface.zig | 2 +- src/apprt/gtk/Window.zig | 8 ++-- src/apprt/gtk/inspector.zig | 2 +- src/apprt/gtk/winproto/x11.zig | 2 +- src/build/SharedDeps.zig | 8 ++-- src/cli/args.zig | 4 +- src/config/Config.zig | 8 ++-- src/config/formatter.zig | 2 +- src/crash/sentry_envelope.zig | 2 +- src/font/CodepointResolver.zig | 16 +++---- src/font/Collection.zig | 18 ++++---- src/font/DeferredFace.zig | 2 +- src/font/SharedGrid.zig | 2 +- src/font/SharedGridSet.zig | 16 +++---- src/font/face/coretext.zig | 2 +- src/font/face/freetype_convert.zig | 2 +- src/font/shaper/coretext.zig | 10 ++--- src/font/shaper/feature.zig | 2 +- src/font/shaper/harfbuzz.zig | 8 ++-- src/font/sprite/canvas.zig | 2 +- src/global.zig | 2 +- src/input/Binding.zig | 4 +- src/input/KeyEncoder.zig | 2 +- src/inspector/termio.zig | 2 +- src/os/args.zig | 2 +- src/renderer/Thread.zig | 2 +- src/renderer/cursor.zig | 2 +- src/renderer/link.zig | 2 +- src/renderer/metal/cell.zig | 2 +- src/renderer/size.zig | 4 +- src/terminal/PageList.zig | 12 +++--- src/terminal/Parser.zig | 4 +- src/terminal/Screen.zig | 12 +++--- src/terminal/Selection.zig | 8 ++-- src/terminal/StringMap.zig | 2 +- src/terminal/Terminal.zig | 10 ++--- src/terminal/bitmap_allocator.zig | 8 ++-- src/terminal/hash_map.zig | 42 +++++++++---------- src/terminal/kitty/graphics_command.zig | 18 ++++---- src/terminal/kitty/graphics_exec.zig | 2 +- src/terminal/osc.zig | 4 +- src/terminal/page.zig | 20 ++++----- src/terminal/search.zig | 4 +- src/terminal/sgr.zig | 4 +- src/terminal/style.zig | 8 ++-- src/terminal/x11_color.zig | 2 +- src/unicode/props.zig | 2 +- 57 files changed, 177 insertions(+), 177 deletions(-) diff --git a/pkg/fontconfig/pattern.zig b/pkg/fontconfig/pattern.zig index e0ec27a69..3a623e223 100644 --- a/pkg/fontconfig/pattern.zig +++ b/pkg/fontconfig/pattern.zig @@ -44,7 +44,7 @@ pub const Pattern = opaque { &val, ))).toError(); - return Value.init(&val); + return .init(&val); } pub fn delete(self: *Pattern, prop: Property) bool { @@ -138,7 +138,7 @@ pub const Pattern = opaque { return Entry{ .result = @enumFromInt(result), .binding = @enumFromInt(binding), - .value = Value.init(&value), + .value = .init(&value), }; } }; diff --git a/pkg/glfw/Monitor.zig b/pkg/glfw/Monitor.zig index 4accb23cd..3b194965a 100644 --- a/pkg/glfw/Monitor.zig +++ b/pkg/glfw/Monitor.zig @@ -281,7 +281,7 @@ pub inline fn setGamma(self: Monitor, gamma: f32) void { /// see also: monitor_gamma pub inline fn getGammaRamp(self: Monitor) ?GammaRamp { internal_debug.assertInitialized(); - if (c.glfwGetGammaRamp(self.handle)) |ramp| return GammaRamp.fromC(ramp.*); + if (c.glfwGetGammaRamp(self.handle)) |ramp| return .fromC(ramp.*); return null; } diff --git a/pkg/glfw/opengl.zig b/pkg/glfw/opengl.zig index 04bc3a65c..8fe2efbed 100644 --- a/pkg/glfw/opengl.zig +++ b/pkg/glfw/opengl.zig @@ -47,7 +47,7 @@ pub inline fn makeContextCurrent(window: ?Window) void { /// see also: context_current, glfwMakeContextCurrent pub inline fn getCurrentContext() ?Window { internal_debug.assertInitialized(); - if (c.glfwGetCurrentContext()) |handle| return Window.from(handle); + if (c.glfwGetCurrentContext()) |handle| return .from(handle); return null; } diff --git a/src/Command.zig b/src/Command.zig index e17c1b370..281dcce40 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -370,7 +370,7 @@ pub fn wait(self: Command, block: bool) !Exit { } }; - return Exit.init(res.status); + return .init(res.status); } /// Sets command->data to data. diff --git a/src/Surface.zig b/src/Surface.zig index f9e232340..32f7487d3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -463,7 +463,7 @@ pub fn init( // Create our terminal grid with the initial size const app_mailbox: App.Mailbox = .{ .rt_app = rt_app, .mailbox = &app.mailbox }; var renderer_impl = try Renderer.init(alloc, .{ - .config = try Renderer.DerivedConfig.init(alloc, config), + .config = try .init(alloc, config), .font_grid = font_grid, .size = size, .surface_mailbox = .{ .surface = self, .app = app_mailbox }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 7bc84bcad..97466e9b5 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -423,7 +423,7 @@ pub const Surface = struct { pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, - .platform = try Platform.init(opts.platform_tag, opts.platform), + .platform = try .init(opts.platform_tag, opts.platform), .userdata = opts.userdata, .core_surface = undefined, .content_scale = .{ @@ -522,7 +522,7 @@ pub const Surface = struct { const alloc = self.app.core_app.alloc; const inspector = try alloc.create(Inspector); errdefer alloc.destroy(inspector); - inspector.* = try Inspector.init(self); + inspector.* = try .init(self); self.inspector = inspector; return inspector; } @@ -1180,7 +1180,7 @@ pub const CAPI = struct { // Create our runtime app var app = try global.alloc.create(App); errdefer global.alloc.destroy(app); - app.* = try App.init(core_app, config, opts.*); + app.* = try .init(core_app, config, opts.*); errdefer app.terminate(); return app; @@ -1949,7 +1949,7 @@ pub const CAPI = struct { } export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool { - return ptr.initMetal(objc.Object.fromId(device)); + return ptr.initMetal(.fromId(device)); } export fn ghostty_inspector_metal_render( @@ -1958,8 +1958,8 @@ pub const CAPI = struct { descriptor: objc.c.id, ) void { return ptr.renderMetal( - objc.Object.fromId(command_buffer), - objc.Object.fromId(descriptor), + .fromId(command_buffer), + .fromId(descriptor), ) catch |err| { log.err("error rendering inspector err={}", .{err}); return; diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index f10fc79ac..fab1aa893 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -69,16 +69,16 @@ fn init( request: apprt.ClipboardRequest, is_secure_input: bool, ) !void { - var builder = switch (DialogType) { + var builder: Builder = switch (DialogType) { adw.AlertDialog => switch (request) { - .osc_52_read => Builder.init("ccw-osc-52-read", 1, 5), - .osc_52_write => Builder.init("ccw-osc-52-write", 1, 5), - .paste => Builder.init("ccw-paste", 1, 5), + .osc_52_read => .init("ccw-osc-52-read", 1, 5), + .osc_52_write => .init("ccw-osc-52-write", 1, 5), + .paste => .init("ccw-paste", 1, 5), }, adw.MessageDialog => switch (request) { - .osc_52_read => Builder.init("ccw-osc-52-read", 1, 2), - .osc_52_write => Builder.init("ccw-osc-52-write", 1, 2), - .paste => Builder.init("ccw-paste", 1, 2), + .osc_52_read => .init("ccw-osc-52-read", 1, 2), + .osc_52_write => .init("ccw-osc-52-write", 1, 2), + .paste => .init("ccw-paste", 1, 2), }, else => unreachable, }; diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig index ccc5599ad..da70ccce1 100644 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ b/src/apprt/gtk/ConfigErrorsDialog.zig @@ -32,9 +32,9 @@ pub fn maybePresent(app: *App, window: ?*Window) void { const config_errors_dialog = config_errors_dialog: { if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog; - var builder = switch (DialogType) { - adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5), - adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2), + var builder: Builder = switch (DialogType) { + adw.AlertDialog => .init("config-errors-dialog", 1, 5), + adw.MessageDialog => .init("config-errors-dialog", 1, 2), else => unreachable, }; diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig index 767cf097d..2ab59624a 100644 --- a/src/apprt/gtk/ResizeOverlay.zig +++ b/src/apprt/gtk/ResizeOverlay.zig @@ -50,12 +50,12 @@ first: bool = true, pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void { self.* = .{ .surface = surface, - .config = DerivedConfig.init(config), + .config = .init(config), }; } pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void { - self.config = DerivedConfig.init(config); + self.config = .init(config); } /// De-initialize the ResizeOverlay. This removes any pending idlers/timers that diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 9caa9ab56..fb719c3c9 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -138,7 +138,7 @@ pub fn init( .container = container, .top_left = .{ .surface = tl }, .bottom_right = .{ .surface = br }, - .orientation = Orientation.fromDirection(direction), + .orientation = .fromDirection(direction), }; // Replace the previous containers element with our split. This allows a diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index bcb78e087..30a3d28f7 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1191,7 +1191,7 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { return; } - self.url_widget = URLWidget.init(self.overlay, uriZ); + self.url_widget = .init(self.overlay, uriZ); } pub fn supportsClipboard( diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 4a5926a97..aa1f0a4b1 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -136,7 +136,7 @@ pub fn init(self: *Window, app: *App) !void { self.* = .{ .app = app, .last_config = @intFromPtr(&app.config), - .config = DerivedConfig.init(&app.config), + .config = .init(&app.config), .window = undefined, .headerbar = undefined, .tab_overview = null, @@ -148,7 +148,7 @@ pub fn init(self: *Window, app: *App) !void { }; // Create the window - self.window = adw.ApplicationWindow.new(app.app.as(gtk.Application)); + self.window = .new(app.app.as(gtk.Application)); const gtk_window = self.window.as(gtk.Window); const gtk_widget = self.window.as(gtk.Widget); errdefer gtk_window.destroy(); @@ -333,7 +333,7 @@ pub fn init(self: *Window, app: *App) !void { } // Setup our toast overlay if we have one - self.toast_overlay = adw.ToastOverlay.new(); + self.toast_overlay = .new(); self.toast_overlay.setChild(self.notebook.asWidget()); box.append(self.toast_overlay.as(gtk.Widget)); @@ -463,7 +463,7 @@ pub fn updateConfig( if (self.last_config == this_config) return; self.last_config = this_config; - self.config = DerivedConfig.init(config); + self.config = .init(config); // We always resync our appearance whenever the config changes. try self.syncAppearance(); diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index e3e61e258..3adeb9711 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -138,7 +138,7 @@ const Window = struct { }; // Create the window - self.window = gtk.ApplicationWindow.new(inspector.surface.app.app.as(gtk.Application)); + self.window = .new(inspector.surface.app.app.as(gtk.Application)); errdefer self.window.as(gtk.Window).destroy(); self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector")); diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index c2b6bf416..387905b18 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -109,7 +109,7 @@ pub const App = struct { return .{ .display = xlib_display, .base_event_code = base_event_code, - .atoms = Atoms.init(gdk_x11_display), + .atoms = .init(gdk_x11_display), }; } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 0df261600..512975ac0 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -24,9 +24,9 @@ pub const LazyPathList = std.ArrayList(std.Build.LazyPath); pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { var result: SharedDeps = .{ .config = cfg, - .help_strings = try HelpStrings.init(b, cfg), - .unicode_tables = try UnicodeTables.init(b), - .framedata = try GhosttyFrameData.init(b), + .help_strings = try .init(b, cfg), + .unicode_tables = try .init(b), + .framedata = try .init(b), // Setup by retarget .options = undefined, @@ -72,7 +72,7 @@ fn initTarget( target: std.Build.ResolvedTarget, ) !void { // Update our metallib - self.metallib = MetallibStep.create(b, .{ + self.metallib = .create(b, .{ .name = "Ghostty", .target = target, .sources = &.{b.path("src/renderer/shaders/cell.metal")}, diff --git a/src/cli/args.zig b/src/cli/args.zig index 4860cdd74..68972a622 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -84,7 +84,7 @@ pub fn parse( // If the arena is unset, we create it. We mark that we own it // only so that we can clean it up on error. if (dst._arena == null) { - dst._arena = ArenaAllocator.init(alloc); + dst._arena = .init(alloc); arena_owned = true; } @@ -481,7 +481,7 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { // Keep track of which fields were set so we can error if a required // field was not set. const FieldSet = std.StaticBitSet(info.fields.len); - var fields_set: FieldSet = FieldSet.initEmpty(); + var fields_set: FieldSet = .initEmpty(); // We split each value by "," var iter = std.mem.splitSequence(u8, v, ","); diff --git a/src/config/Config.zig b/src/config/Config.zig index 6f1e89d41..8d08113bc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2531,7 +2531,7 @@ pub fn load(alloc_gpa: Allocator) !Config { pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Build up our basic config var result: Config = .{ - ._arena = ArenaAllocator.init(alloc_gpa), + ._arena = .init(alloc_gpa), }; errdefer result.deinit(); const alloc = result._arena.?.allocator(); @@ -3332,7 +3332,7 @@ pub fn parseManuallyHook( /// be deallocated while shallow clones exist. pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config { var result = self.*; - result._arena = ArenaAllocator.init(alloc_gpa); + result._arena = .init(alloc_gpa); return result; } @@ -5975,7 +5975,7 @@ pub const QuickTerminalSize = struct { it.next() orelse return error.ValueRequired, cli.args.whitespace, ); - self.primary = try Size.parse(primary); + self.primary = try .parse(primary); self.secondary = secondary: { const secondary = std.mem.trim( @@ -5983,7 +5983,7 @@ pub const QuickTerminalSize = struct { it.next() orelse break :secondary null, cli.args.whitespace, ); - break :secondary try Size.parse(secondary); + break :secondary try .parse(secondary); }; if (it.next()) |_| return error.TooManyArguments; diff --git a/src/config/formatter.zig b/src/config/formatter.zig index ca3da1d91..cabf80953 100644 --- a/src/config/formatter.zig +++ b/src/config/formatter.zig @@ -153,7 +153,7 @@ pub const FileFormatter = struct { // If we're change-tracking then we need the default config to // compare against. var default: ?Config = if (self.changed) - try Config.default(self.alloc) + try .default(self.alloc) else null; defer if (default) |*v| v.deinit(); diff --git a/src/crash/sentry_envelope.zig b/src/crash/sentry_envelope.zig index 70eb99f51..6b675554c 100644 --- a/src/crash/sentry_envelope.zig +++ b/src/crash/sentry_envelope.zig @@ -331,7 +331,7 @@ pub const Item = union(enum) { // Decode the item. self.* = switch (encoded.type) { - .attachment => .{ .attachment = try Attachment.decode( + .attachment => .{ .attachment = try .decode( alloc, encoded, ) }, diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index 37093b59a..16536300c 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -37,7 +37,7 @@ collection: Collection, /// The set of statuses and whether they're enabled or not. This defaults /// to true. This can be changed at runtime with no ill effect. -styles: StyleStatus = StyleStatus.initFill(true), +styles: StyleStatus = .initFill(true), /// If discovery is available, we'll look up fonts where we can't find /// the codepoint. This can be set after initialization. @@ -140,7 +140,7 @@ pub fn getIndex( // handle this. if (self.sprite) |sprite| { if (sprite.hasCodepoint(cp, p)) { - return Collection.Index.initSpecial(.sprite); + return .initSpecial(.sprite); } } @@ -388,7 +388,7 @@ test getIndex { { errdefer c.deinit(alloc); - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -398,7 +398,7 @@ test getIndex { _ = try c.add( alloc, .regular, - .{ .loaded = try Face.init( + .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -408,7 +408,7 @@ test getIndex { _ = try c.add( alloc, .regular, - .{ .loaded = try Face.init( + .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -467,17 +467,17 @@ test "getIndex disabled font style" { var c = Collection.init(); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, ) }); - _ = try c.add(alloc, .bold, .{ .loaded = try Face.init( + _ = try c.add(alloc, .bold, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, ) }); - _ = try c.add(alloc, .italic, .{ .loaded = try Face.init( + _ = try c.add(alloc, .italic, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 59f89d402..8533331bc 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -55,7 +55,7 @@ load_options: ?LoadOptions = null, pub fn init() Collection { // Initialize our styles array, preallocating some space that is // likely to be used. - return .{ .faces = StyleArray.initFill(.{}) }; + return .{ .faces = .initFill(.{}) }; } pub fn deinit(self: *Collection, alloc: Allocator) void { @@ -707,7 +707,7 @@ test "add full" { defer c.deinit(alloc); for (0..Index.Special.start - 1) |_| { - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -755,7 +755,7 @@ test getFace { var c = init(); defer c.deinit(alloc); - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -779,7 +779,7 @@ test getIndex { var c = init(); defer c.deinit(alloc); - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -811,7 +811,7 @@ test completeStyles { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -838,7 +838,7 @@ test setSize { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -861,7 +861,7 @@ test hasCodepoint { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -885,7 +885,7 @@ test "hasCodepoint emoji default graphical" { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init( + const idx = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, @@ -908,7 +908,7 @@ test "metrics" { defer c.deinit(alloc); c.load_options = .{ .library = lib }; - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 8794ccea9..f9ce0bff5 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -254,7 +254,7 @@ fn loadWebCanvas( opts: font.face.Options, ) !Face { const wc = self.wc.?; - return try Face.initNamed(wc.alloc, wc.font_str, opts, wc.presentation); + return try .initNamed(wc.alloc, wc.font_str, opts, wc.presentation); } /// Returns true if this face can satisfy the given codepoint and diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 72e97fad8..35770f920 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -319,7 +319,7 @@ fn testGrid(mode: TestMode, alloc: Allocator, lib: Library) !SharedGrid { switch (mode) { .normal => { - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 8ad30629e..858d7930f 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -126,7 +126,7 @@ pub fn ref( .ref = 1, }; - grid.* = try SharedGrid.init(self.alloc, resolver: { + grid.* = try .init(self.alloc, resolver: { // Build our collection. This is the expensive operation that // involves finding fonts, loading them (maybe, some are deferred), // etc. @@ -258,7 +258,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.regular, load_options.faceOptions(), @@ -267,7 +267,7 @@ fn collection( _ = try c.add( self.alloc, .bold, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.bold, load_options.faceOptions(), @@ -276,7 +276,7 @@ fn collection( _ = try c.add( self.alloc, .italic, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.italic, load_options.faceOptions(), @@ -285,7 +285,7 @@ fn collection( _ = try c.add( self.alloc, .bold_italic, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.bold_italic, load_options.faceOptions(), @@ -318,7 +318,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.emoji, load_options.faceOptions(), @@ -327,7 +327,7 @@ fn collection( _ = try c.add( self.alloc, .regular, - .{ .fallback_loaded = try Face.init( + .{ .fallback_loaded = try .init( self.font_lib, font.embedded.emoji_text, load_options.faceOptions(), @@ -391,7 +391,7 @@ fn discover(self: *SharedGridSet) !?*Discover { // If we initialized, use it if (self.font_discover) |*v| return v; - self.font_discover = Discover.init(); + self.font_discover = .init(); return &self.font_discover.?; } diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 639eae43c..06bba661f 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -97,7 +97,7 @@ pub const Face = struct { errdefer if (comptime harfbuzz_shaper) hb_font.destroy(); const color: ?ColorState = if (traits.color_glyphs) - try ColorState.init(ct_font) + try .init(ct_font) else null; errdefer if (color) |v| v.deinit(); diff --git a/src/font/face/freetype_convert.zig b/src/font/face/freetype_convert.zig index 6df350bfa..3a7cf8c98 100644 --- a/src/font/face/freetype_convert.zig +++ b/src/font/face/freetype_convert.zig @@ -30,7 +30,7 @@ fn genMap() Map { // Initialize to no converter var i: usize = 0; while (i < freetype.c.FT_PIXEL_MODE_MAX) : (i += 1) { - result[i] = AtlasArray.initFill(null); + result[i] = .initFill(null); } // Map our converters diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index f2ac5b85d..8e2c45c69 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -191,7 +191,7 @@ pub const Shaper = struct { // Create the CF release thread. var cf_release_thread = try alloc.create(CFReleaseThread); errdefer alloc.destroy(cf_release_thread); - cf_release_thread.* = try CFReleaseThread.init(alloc); + cf_release_thread.* = try .init(alloc); errdefer cf_release_thread.deinit(); // Start the CF release thread. @@ -1768,7 +1768,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { c.load_options = .{ .library = lib }; // Setup group - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -1776,7 +1776,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { if (font.options.backend != .coretext) { // Coretext doesn't support Noto's format - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -1795,7 +1795,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { errdefer face.deinit(); _ = try c.add(alloc, .regular, .{ .deferred = face }); } - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -1803,7 +1803,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const grid_ptr = try alloc.create(SharedGrid); errdefer alloc.destroy(grid_ptr); - grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c }); + grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); var shaper = try Shaper.init(alloc, .{}); diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index c2d49234d..66d0cb1f7 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -21,7 +21,7 @@ pub const Feature = struct { pub fn fromString(str: []const u8) ?Feature { var fbs = std.io.fixedBufferStream(str); const reader = fbs.reader(); - return Feature.fromReader(reader); + return .fromReader(reader); } /// Parse a single font feature setting from a std.io.Reader, with a version diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index eb8130f79..361cbbe93 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1227,7 +1227,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { c.load_options = .{ .library = lib }; // Setup group - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testFont, .{ .size = .{ .points = 12 } }, @@ -1235,7 +1235,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { if (comptime !font.options.backend.hasCoretext()) { // Coretext doesn't support Noto's format - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmoji, .{ .size = .{ .points = 12 } }, @@ -1254,7 +1254,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { errdefer face.deinit(); _ = try c.add(alloc, .regular, .{ .deferred = face }); } - _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + _ = try c.add(alloc, .regular, .{ .loaded = try .init( lib, testEmojiText, .{ .size = .{ .points = 12 } }, @@ -1262,7 +1262,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const grid_ptr = try alloc.create(SharedGrid); errdefer alloc.destroy(grid_ptr); - grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c }); + grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); var shaper = try Shaper.init(alloc, .{}); diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index ed00aef12..a5ca7b290 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -150,7 +150,7 @@ pub const Canvas = struct { /// Acquires a z2d drawing context, caller MUST deinit context. pub fn getContext(self: *Canvas) z2d.Context { - return z2d.Context.init(self.alloc, &self.sfc); + return .init(self.alloc, &self.sfc); } /// Draw and fill a single pixel diff --git a/src/global.zig b/src/global.zig index 375c10538..d11dd775b 100644 --- a/src/global.zig +++ b/src/global.zig @@ -139,7 +139,7 @@ pub const GlobalState = struct { std.log.info("libxev default backend={s}", .{@tagName(xev.backend)}); // As early as possible, initialize our resource limits. - self.rlimits = ResourceLimits.init(); + self.rlimits = .init(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 59adc7149..3818d99a6 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -71,7 +71,7 @@ pub const Parser = struct { // parse the action now. return .{ .trigger_it = .{ .input = input[0..eql_idx] }, - .action = try Action.parse(input[eql_idx + 1 ..]), + .action = try .parse(input[eql_idx + 1 ..]), .flags = flags, }; } @@ -158,7 +158,7 @@ const SequenceIterator = struct { const rem = self.input[self.i..]; const idx = std.mem.indexOf(u8, rem, ">") orelse rem.len; defer self.i += idx + 1; - return try Trigger.parse(rem[0..idx]); + return try .parse(rem[0..idx]); } /// Returns true if there are no more triggers to parse. diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 41634f2f1..b5f18b5a2 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -164,7 +164,7 @@ fn kitty( var seq: KittySequence = .{ .key = entry.code, .final = entry.final, - .mods = KittyMods.fromInput( + .mods = .fromInput( self.event.action, self.event.key, all_mods, diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 6aa6628ab..5ab9d3cd4 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -308,7 +308,7 @@ pub const VTHandler = struct { current_seq: usize = 1, /// Exclude certain actions by tag. - filter_exclude: ActionTagSet = ActionTagSet.initMany(&.{.print}), + filter_exclude: ActionTagSet = .initMany(&.{.print}), filter_text: *cimgui.c.ImGuiTextFilter, const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); diff --git a/src/os/args.zig b/src/os/args.zig index 9f7401c94..a531a418b 100644 --- a/src/os/args.zig +++ b/src/os/args.zig @@ -12,7 +12,7 @@ const macos = @import("macos"); /// but handles macOS using NSProcessInfo instead of libc argc/argv. pub fn iterator(allocator: Allocator) ArgIterator.InitError!ArgIterator { //if (true) return try std.process.argsWithAllocator(allocator); - return ArgIterator.initWithAllocator(allocator); + return .initWithAllocator(allocator); } /// Duck-typed to std.process.ArgIterator diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 46ef8609b..1e9c29b26 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -155,7 +155,7 @@ pub fn init( return .{ .alloc = alloc, - .config = DerivedConfig.init(config), + .config = .init(config), .loop = loop, .wakeup = wakeup_h, .stop = stop_h, diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index d8769d9e2..287b83450 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -62,7 +62,7 @@ pub fn style( } // Otherwise, we use whatever style the terminal wants. - return Style.fromTerminal(state.terminal.screen.cursor.cursor_style); + return .fromTerminal(state.terminal.screen.cursor.cursor_style); } test "cursor: default uses configured style" { diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 994190ec8..410fb8632 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -179,7 +179,7 @@ pub const Set = struct { if (current) |*sel| { sel.endPtr().* = cell_pin; } else { - current = terminal.Selection.init( + current = .init( cell_pin, cell_pin, false, diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig index 61b8887fd..e1bcb7b9f 100644 --- a/src/renderer/metal/cell.zig +++ b/src/renderer/metal/cell.zig @@ -44,7 +44,7 @@ fn ArrayListPool(comptime T: type) type { }; for (self.lists) |*list| { - list.* = try ArrayListT.initCapacity(alloc, initial_capacity); + list.* = try .initCapacity(alloc, initial_capacity); } return self; diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 83e921a26..b26c1581e 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -22,7 +22,7 @@ pub const Size = struct { /// taking the screen size, removing padding, and dividing by the cell /// dimensions. pub fn grid(self: Size) GridSize { - return GridSize.init(self.screen.subPadding(self.padding), self.cell); + return .init(self.screen.subPadding(self.padding), self.cell); } /// The size of the terminal. This is the same as the screen without @@ -39,7 +39,7 @@ pub const Size = struct { self.padding = explicit; // Now we can calculate the balanced padding - self.padding = Padding.balanced( + self.padding = .balanced( self.screen, self.grid(), self.cell, diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 95519fe99..300af8e13 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -287,8 +287,8 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = Page.initBuf( - OffsetBuf.init(page_buf), + .data = .initBuf( + .init(page_buf), Page.layout(cap), ), }; @@ -472,7 +472,7 @@ pub fn clone( }; // Setup our pools - break :alloc try MemoryPool.init( + break :alloc try .init( alloc, std.heap.page_allocator, page_count, @@ -1201,7 +1201,7 @@ const ReflowCursor = struct { node.data.size.rows = 1; list.pages.insertAfter(self.node, node); - self.* = ReflowCursor.init(node); + self.* = .init(node); self.new_rows = new_rows; } @@ -1817,7 +1817,7 @@ pub fn grow(self: *PageList) !?*List.Node { @memset(buf, 0); // Initialize our new page and reinsert it as the last - first.data = Page.initBuf(OffsetBuf.init(buf), layout); + first.data = .initBuf(.init(buf), layout); first.data.size.rows = 1; self.pages.insertAfter(last, first); @@ -1989,7 +1989,7 @@ fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) }; + page.* = .{ .data = .initBuf(.init(page_buf), layout) }; page.data.size.rows = 0; if (total_size) |v| { diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 4e74f04ba..14ed6d6df 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -217,7 +217,7 @@ intermediates_idx: u8 = 0, /// Param tracking, building params: [MAX_PARAMS]u16 = undefined, -params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(), +params_sep: Action.CSI.SepList = .initEmpty(), params_idx: u8 = 0, param_acc: u16 = 0, param_acc_idx: u8 = 0, @@ -395,7 +395,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { pub fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; - self.params_sep = Action.CSI.SepList.initEmpty(); + self.params_sep = .initEmpty(); self.param_acc = 0; self.param_acc_idx = 0; } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9ab4b23e2..2688b03a7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -171,7 +171,7 @@ pub const SavedCursor = struct { /// State required for all charset operations. pub const CharsetState = struct { /// The list of graphical charsets by slot - charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), + charsets: CharsetArray = .initFill(charsets.Charset.utf8), /// GL is the slot to use when using a 7-bit printable char (up to 127) /// GR used for 8-bit printable chars. @@ -2433,7 +2433,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { return null; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Return the selection for all contents on the screen. Surrounding @@ -2489,7 +2489,7 @@ pub fn selectAll(self: *Screen) ?Selection { return null; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Select the nearest word to start point that is between start_pt and @@ -2624,7 +2624,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { break :start prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Select the command output under the given point. The limits of the output @@ -2724,7 +2724,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { break :boundary it_prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -2805,7 +2805,7 @@ pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { break :end it_prev; }; - return Selection.init(start, end, false); + return .init(start, end, false); } pub const LineIterator = struct { diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index a90595d20..267f223d5 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -228,7 +228,7 @@ pub fn order(self: Selection, s: *const Screen) Order { /// Note that only forward and reverse are useful desired orders for this /// function. All other orders act as if forward order was desired. pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { - if (self.order(s) == desired) return Selection.init( + if (self.order(s) == desired) return .init( self.start(), self.end(), self.rectangle, @@ -237,9 +237,9 @@ pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { const tl = self.topLeft(s); const br = self.bottomRight(s); return switch (desired) { - .forward => Selection.init(tl, br, self.rectangle), - .reverse => Selection.init(br, tl, self.rectangle), - else => Selection.init(tl, br, self.rectangle), + .forward => .init(tl, br, self.rectangle), + .reverse => .init(br, tl, self.rectangle), + else => .init(tl, br, self.rectangle), }; } diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 9892c13df..dde69d25e 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -80,7 +80,7 @@ pub const Match = struct { const end_idx: usize = @intCast(self.region.ends()[0] - 1); const start_pt = self.map.map[self.offset + start_idx]; const end_pt = self.map.map[self.offset + end_idx]; - return Selection.init(start_pt, end_pt, false); + return .init(start_pt, end_pt, false); } }; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index efb9684eb..595fee1ba 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -79,7 +79,7 @@ default_palette: color.Palette = color.default, color_palette: struct { const Mask = std.StaticBitSet(@typeInfo(color.Palette).array.len); colors: color.Palette = color.default, - mask: Mask = Mask.initEmpty(), + mask: Mask = .initEmpty(), } = .{}, /// The previous printed character. This is used for the repeat previous @@ -210,9 +210,9 @@ pub fn init( .cols = cols, .rows = rows, .active_screen = .primary, - .screen = try Screen.init(alloc, cols, rows, opts.max_scrollback), - .secondary_screen = try Screen.init(alloc, cols, rows, 0), - .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), + .screen = try .init(alloc, cols, rows, opts.max_scrollback), + .secondary_screen = try .init(alloc, cols, rows, 0), + .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, .bottom = rows - 1, @@ -2454,7 +2454,7 @@ pub fn resize( // Resize our tabstops if (self.cols != cols) { self.tabstops.deinit(alloc); - self.tabstops = try Tabstops.init(alloc, cols, 8); + self.tabstops = try .init(alloc, cols, 8); } // If we're making the screen smaller, dealloc the unused items. diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index f96d39831..68d968768 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -403,7 +403,7 @@ test "BitmapAllocator alloc sequentially" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u8, buf, 1); ptr[0] = 'A'; @@ -429,7 +429,7 @@ test "BitmapAllocator alloc non-byte" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u21, buf, 1); ptr[0] = 'A'; @@ -453,7 +453,7 @@ test "BitmapAllocator alloc non-byte multi-chunk" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u21, buf, 6); try testing.expectEqual(@as(usize, 6), ptr.len); for (ptr) |*v| v.* = 'A'; @@ -478,7 +478,7 @@ test "BitmapAllocator alloc large" { const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); defer alloc.free(buf); - var bm = Alloc.init(OffsetBuf.init(buf), layout); + var bm = Alloc.init(.init(buf), layout); const ptr = try bm.alloc(u8, buf, 129); ptr[0] = 'A'; bm.free(buf, ptr); diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 0cc17a747..9a16be3b2 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -893,7 +893,7 @@ test "HashMap basic usage" { const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const count = 5; var i: u32 = 0; @@ -927,7 +927,7 @@ test "HashMap ensureTotalCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const initial_capacity = map.capacity(); try testing.expect(initial_capacity >= 20); @@ -947,7 +947,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: i32 = 0; while (i < 100) : (i += 1) { @@ -965,7 +965,7 @@ test "HashMap clearRetainingCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); map.clearRetainingCapacity(); @@ -996,7 +996,7 @@ test "HashMap ensureTotalCapacity with existing elements" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.put(0, 0); try expectEqual(map.count(), 1); @@ -1015,7 +1015,7 @@ test "HashMap remove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1053,7 +1053,7 @@ test "HashMap reverse removes" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1081,7 +1081,7 @@ test "HashMap multiple removes on same metadata" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1124,7 +1124,7 @@ test "HashMap put and remove loop in random order" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var keys = std.ArrayList(u32).init(alloc); defer keys.deinit(); @@ -1162,7 +1162,7 @@ test "HashMap put" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 16) : (i += 1) { @@ -1193,7 +1193,7 @@ test "HashMap put full load" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); for (0..cap) |i| try map.put(i, i); for (0..cap) |i| try expectEqual(map.get(i).?, i); @@ -1209,7 +1209,7 @@ test "HashMap putAssumeCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 20) : (i += 1) { @@ -1244,7 +1244,7 @@ test "HashMap repeat putAssumeCapacity/remove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); const limit = cap; @@ -1280,7 +1280,7 @@ test "HashMap getOrPut" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: u32 = 0; while (i < 10) : (i += 1) { @@ -1309,7 +1309,7 @@ test "HashMap basic hash map usage" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try testing.expect((try map.fetchPut(1, 11)) == null); try testing.expect((try map.fetchPut(2, 22)) == null); @@ -1360,7 +1360,7 @@ test "HashMap ensureUnusedCapacity" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.ensureUnusedCapacity(32); try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1)); @@ -1374,7 +1374,7 @@ test "HashMap removeByPtr" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); var i: i32 = undefined; i = 0; @@ -1405,7 +1405,7 @@ test "HashMap removeByPtr 0 sized key" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); try map.put(0, 0); @@ -1429,7 +1429,7 @@ test "HashMap repeat fetchRemove" { const layout = Map.layoutForCapacity(cap); const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); defer alloc.free(buf); - var map = Map.init(OffsetBuf.init(buf), layout); + var map = Map.init(.init(buf), layout); map.putAssumeCapacity(0, {}); map.putAssumeCapacity(1, {}); @@ -1457,7 +1457,7 @@ test "OffsetHashMap basic usage" { const layout = OffsetMap.layout(cap); const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var offset_map = OffsetMap.init(.init(buf), layout); var map = offset_map.map(buf.ptr); const count = 5; @@ -1492,7 +1492,7 @@ test "OffsetHashMap remake map" { const layout = OffsetMap.layout(cap); const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size); defer alloc.free(buf); - var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout); + var offset_map = OffsetMap.init(.init(buf), layout); { var map = offset_map.map(buf.ptr); diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 61ba33a4d..adc6edafe 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -155,17 +155,17 @@ pub const Parser = struct { break :action c; }; const control: Command.Control = switch (action) { - 'q' => .{ .query = try Transmission.parse(self.kv) }, - 't' => .{ .transmit = try Transmission.parse(self.kv) }, + 'q' => .{ .query = try .parse(self.kv) }, + 't' => .{ .transmit = try .parse(self.kv) }, 'T' => .{ .transmit_and_display = .{ - .transmission = try Transmission.parse(self.kv), - .display = try Display.parse(self.kv), + .transmission = try .parse(self.kv), + .display = try .parse(self.kv), } }, - 'p' => .{ .display = try Display.parse(self.kv) }, - 'd' => .{ .delete = try Delete.parse(self.kv) }, - 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) }, - 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) }, - 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) }, + 'p' => .{ .display = try .parse(self.kv) }, + 'd' => .{ .delete = try .parse(self.kv) }, + 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, + 'a' => .{ .control_animation = try .parse(self.kv) }, + 'c' => .{ .compose_animation = try .parse(self.kv) }, else => return error.InvalidFormat, }; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 25c819b10..f917c104a 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -324,7 +324,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try LoadingImage.init(alloc, cmd); + } else try .init(alloc, cmd); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index ce7afdf64..932964137 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1354,8 +1354,8 @@ pub const Parser = struct { } switch (self.command) { - .report_color => |*c| c.terminator = Terminator.init(terminator_ch), - .kitty_color_protocol => |*c| c.terminator = Terminator.init(terminator_ch), + .report_color => |*c| c.terminator = .init(terminator_ch), + .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch), else => {}, } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index acb757592..d7f252af1 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -241,23 +241,23 @@ pub const Page = struct { l.styles_layout, .{}, ), - .string_alloc = StringAlloc.init( + .string_alloc = .init( buf.add(l.string_alloc_start), l.string_alloc_layout, ), - .grapheme_alloc = GraphemeAlloc.init( + .grapheme_alloc = .init( buf.add(l.grapheme_alloc_start), l.grapheme_alloc_layout, ), - .grapheme_map = GraphemeMap.init( + .grapheme_map = .init( buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), - .hyperlink_map = hyperlink.Map.init( + .hyperlink_map = .init( buf.add(l.hyperlink_map_start), l.hyperlink_map_layout, ), - .hyperlink_set = hyperlink.Set.init( + .hyperlink_set = .init( buf.add(l.hyperlink_set_start), l.hyperlink_set_layout, .{}, @@ -280,7 +280,7 @@ pub const Page = struct { // We zero the page memory as u64 instead of u8 because // we can and it's empirically quite a bit faster. @memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0); - self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity)); + self.* = initBuf(.init(self.memory), layout(self.capacity)); } pub const IntegrityError = error{ @@ -2260,7 +2260,7 @@ test "Page appendGrapheme small" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); // One try page.appendGrapheme(rac.row, rac.cell, 0x0A); @@ -2289,7 +2289,7 @@ test "Page appendGrapheme larger than chunk" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); const count = grapheme_chunk_len * 10; for (0..count) |i| { @@ -2312,11 +2312,11 @@ test "Page clearGrapheme not all cells" { defer page.deinit(); const rac = page.getRowAndCell(0, 0); - rac.cell.* = Cell.init(0x09); + rac.cell.* = .init(0x09); try page.appendGrapheme(rac.row, rac.cell, 0x0A); const rac2 = page.getRowAndCell(1, 0); - rac2.cell.* = Cell.init(0x09); + rac2.cell.* = .init(0x09); try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); // Clear it diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 56b181c48..2f87f894b 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -365,7 +365,7 @@ const SlidingWindow = struct { } self.assertIntegrity(); - return Selection.init(tl, br, false); + return .init(tl, br, false); } /// Convert a data index into a pin. @@ -417,7 +417,7 @@ const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, - .cell_map = Page.CellMap.init(alloc), + .cell_map = .init(alloc), }; errdefer meta.deinit(); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 2bc32c5f9..e4b85fbdd 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -98,7 +98,7 @@ pub const Attribute = union(enum) { /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { params: []const u16, - params_sep: SepList = SepList.initEmpty(), + params_sep: SepList = .initEmpty(), idx: usize = 0, /// Next returns the next attribute or null if there are no more attributes. @@ -376,7 +376,7 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .params_sep = SepList.initFull() }; + var p: Parser = .{ .params = params, .params_sep = .initFull() }; return p.next().?; } diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 34b07772a..f35a4e1f7 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -302,9 +302,9 @@ pub const Style = struct { .underline = std.meta.activeTag(style.underline_color), }, .data = .{ - .fg = Data.fromColor(style.fg_color), - .bg = Data.fromColor(style.bg_color), - .underline = Data.fromColor(style.underline_color), + .fg = .fromColor(style.fg_color), + .bg = .fromColor(style.bg_color), + .underline = .fromColor(style.underline_color), }, .flags = style.flags, }; @@ -349,7 +349,7 @@ test "Set basic usage" { const style: Style = .{ .flags = .{ .bold = true } }; const style2: Style = .{ .flags = .{ .italic = true } }; - var set = Set.init(OffsetBuf.init(buf), layout, .{}); + var set = Set.init(.init(buf), layout, .{}); // Add style const id = try set.add(buf, style); diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 88bc30f09..977cd4538 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -33,7 +33,7 @@ fn colorMap() !ColorMap { } assert(i == len); - return ColorMap.initComptime(kvs); + return .initComptime(kvs); } /// This is the rgb.txt file from the X11 project. This was last sourced diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 8c7621b79..99c57aa0a 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -125,7 +125,7 @@ pub fn get(cp: u21) Properties { return .{ .width = @intCast(@min(2, @max(0, zg_width))), - .grapheme_boundary_class = GraphemeBoundaryClass.init(cp), + .grapheme_boundary_class = .init(cp), }; } From 468bfce09167eefc63721959369435d5e7f9d479 Mon Sep 17 00:00:00 2001 From: Kat <65649991+00-kat@users.noreply.github.com> Date: Tue, 27 May 2025 22:40:01 +1000 Subject: [PATCH 080/245] Correct `$XDG_CONFIG_DIR` to `$XDG_CONFIG_HOME` in theme documentation. --- src/cli/list_themes.zig | 2 +- src/config/Config.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 54f4c0969..4bb8a74eb 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -77,7 +77,7 @@ const ThemeListElement = struct { /// Two different directories will be searched for themes. /// /// The first directory is the `themes` subdirectory of your Ghostty -/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or /// `~/.config/ghostty/themes`. /// /// The second directory is the `themes` subdirectory of the Ghostty resources diff --git a/src/config/Config.zig b/src/config/Config.zig index 6f1e89d41..4eb419f87 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -410,7 +410,7 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ /// include path separators unless it is an absolute pathname. /// /// The first directory is the `themes` subdirectory of your Ghostty -/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or +/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or /// `~/.config/ghostty/themes`. /// /// The second directory is the `themes` subdirectory of the Ghostty resources From 1ce654494504dedde11b0bbe7ae1493a9db45c4d Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 25 May 2025 19:00:28 -0600 Subject: [PATCH 081/245] Wrap comment at 80 cols --- src/terminal/point.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 12b71014b..f2544f90c 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -3,10 +3,12 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const size = @import("size.zig"); -/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple -/// things: it is in the current visible viewport? the current active -/// area of the screen where the cursor is? the entire scrollback history? -/// etc. This tag is used to differentiate those cases. +/// The possible reference locations for a point. When someone says "(42, 80)" +/// in the context of a terminal, that could mean multiple things: it is in the +/// current visible viewport? the current active area of the screen where the +/// cursor is? the entire scrollback history? etc. +/// +/// This tag is used to differentiate those cases. pub const Tag = enum { /// Top-left is part of the active area where a running program can /// jump the cursor and make changes. The active area is the "editable" From 58592d3f65aff9d055ce264fac5e50776233aa52 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 25 May 2025 19:01:39 -0600 Subject: [PATCH 082/245] GTK: Don't clamp cursorpos, allow negative values Other apprts don't do this, so this should be consistent. --- src/apprt/gtk/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 30a3d28f7..1ee00ff1b 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1563,7 +1563,7 @@ fn gtkMouseMotion( const scaled = self.scaledCoordinates(x, y); const pos: apprt.CursorPos = .{ - .x = @floatCast(@max(0, scaled.x)), + .x = @floatCast(scaled.x), .y = @floatCast(scaled.y), }; From ecdac8c8c1993b9daaf27745a8ed5583e4ac57d1 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Sun, 25 May 2025 19:24:29 -0600 Subject: [PATCH 083/245] terminal: rework selection logic in core surface This logic is cleaner and produces better behavior when selecting by dragging the mouse outside the bounds of the surface, previously when doing this on the left side of the surface selections would include the first cell of the next row, this is no longer the case. This introduces methods on PageList.Pin which move a pin left or right while wrapping to the prev/next row, or clamping to the ends of the row. These need unit tests. --- src/Surface.zig | 274 +++++++++++++++++++------------------- src/terminal/PageList.zig | 68 ++++++++++ 2 files changed, 205 insertions(+), 137 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 32f7487d3..3726c37c7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3678,163 +3678,163 @@ fn dragLeftClickSingle( drag_pin: terminal.Pin, xpos: f64, ) !void { - // NOTE(mitchellh): This logic super sucks. There has to be an easier way - // to calculate this, but this is good for a v1. Selection isn't THAT - // common so its not like this performance heavy code is running that - // often. - // TODO: unit test this, this logic sucks + // TODO: Unit tests for this logic, maybe extract it out to a pure + // function so that it can be tested without mocking state. - // If we were selecting, and we switched directions, then we restart - // calculations because it forces us to reconsider if the first cell is - // selected. - self.checkResetSelSwitch(drag_pin); - - // Our logic for determining if the starting cell is selected: + // Explanation: // - // - The "xboundary" is 60% the width of a cell from the left. We choose - // 60% somewhat arbitrarily based on feeling. - // - If we started our click left of xboundary, backwards selections - // can NEVER select the current char. - // - If we started our click right of xboundary, backwards selections - // ALWAYS selected the current char, but we must move the cursor - // left of the xboundary. - // - Inverted logic for forwards selections. + // # Normal selections // + // ## Left-to-right selections + // - The clicked cell is included if it was clicked to the left of its + // threshold point and the drag location is right of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is right of its threshold point. + // + // ## Right-to-left selections + // - The clicked cell is included if it was clicked to the right of its + // threshold point and the drag location is left of the threshold point. + // - The cell under the cursor (the "drag cell") is included if the drag + // location is left of its threshold point. + // + // # Rectangular selections + // + // Rectangular selections are handled similarly, except that + // entire columns are considered rather than individual cells. - // Our clicking point const click_pin = self.mouse.left_click_pin.?.*; - // the boundary point at which we consider selection or non-selection - const cell_width_f64: f64 = @floatFromInt(self.size.cell.width); - const cell_xboundary = cell_width_f64 * 0.6; + // We only include cells in the selection if the threshold point lies + // between the start and end points of the selection. A threshold of + // 60% of the cell width was chosen empirically because it felt good. + const threshold_point: u32 = @intFromFloat( + @as(f64, @floatFromInt(self.size.cell.width)) * 0.6, + ); - // first xpos of the clicked cell adjusted for padding - const left_padding_f64: f64 = @as(f64, @floatFromInt(self.size.padding.left)); - const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64; - const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64; + // We use this to clamp the pixel positions below. + const max_x = self.size.grid().columns * self.size.cell.width - 1; - // If this is the same cell, then we only start the selection if weve - // moved past the boundary point the opposite direction from where we - // started. - if (click_pin.eql(drag_pin)) { - // Ensuring to adjusting the cursor position for padding - const cell_xpos = xpos - cell_xstart - left_padding_f64; - const selected: bool = if (cell_start_xpos < cell_xboundary) - cell_xpos >= cell_xboundary - else - cell_xpos < cell_xboundary; + // We need to know how far across in the cell the drag pos is, so + // we subtract the padding and then take it modulo the cell width. + const drag_x = @min( + max_x, + @as(u32, @intFromFloat(@max(0.0, xpos))) -| self.size.padding.left, + ); + const drag_x_frac = drag_x % self.size.cell.width; - try self.setSelection(if (selected) terminal.Selection.init( - drag_pin, - drag_pin, - SurfaceMouse.isRectangleSelectState(self.mouse.mods), - ) else null); + // We figure out the fractional part of the click x position similarly. + // + // NOTE: This click_x position may be incorrect for the current location + // of the click pin, since it's a tracked pin that can move, so we + // should only use this for the fractional position not absolute. + const click_x = @min( + max_x, + @as(u32, @intFromFloat(@max(0.0, self.mouse.left_click_xpos))) -| + self.size.padding.left, + ); + const click_x_frac = click_x % self.size.cell.width; - return; - } + // Whether or not this is a rectangular selection. + const rectangle_selection = + SurfaceMouse.isRectangleSelectState(self.mouse.mods); - // If this is a different cell and we haven't started selection, - // we determine the starting cell first. - if (self.io.terminal.screen.selection == null) { - // - If we're moving to a point before the start, then we select - // the starting cell if we started after the boundary, else - // we start selection of the prior cell. - // - Inverse logic for a point after the start. - const start: terminal.Pin = if (dragLeftClickBefore( - drag_pin, - click_pin, - self.mouse.mods, - )) start: { - if (cell_start_xpos >= cell_xboundary) break :start click_pin; - if (click_pin.x > 0) break :start click_pin.left(1); - var start = click_pin.up(1) orelse click_pin; - start.x = self.io.terminal.screen.pages.cols - 1; - break :start start; - } else start: { - if (cell_start_xpos < cell_xboundary) break :start click_pin; - if (click_pin.x < self.io.terminal.screen.pages.cols - 1) - break :start click_pin.right(1); - var start = click_pin.down(1) orelse click_pin; - start.x = 0; - break :start start; - }; + // Whether the click pin and drag pin are equal. + const same_pin = drag_pin.eql(click_pin); - try self.setSelection(terminal.Selection.init( - start, - drag_pin, - SurfaceMouse.isRectangleSelectState(self.mouse.mods), - )); - return; - } + // Whether or not the end point of our selection is before the start point. + const end_before_start = ebs: { + if (same_pin) { + break :ebs drag_x_frac < click_x_frac; + } - // TODO: detect if selection point is passed the point where we've - // actually written data before and disallow it. - - // We moved! Set the selection end point. The start point should be - // set earlier. - assert(self.io.terminal.screen.selection != null); - const sel = self.io.terminal.screen.selection.?; - try self.setSelection(terminal.Selection.init( - sel.start(), - drag_pin, - sel.rectangle, - )); -} - -// Resets the selection if we switched directions, depending on the select -// mode. See dragLeftClickSingle for more details. -fn checkResetSelSwitch( - self: *Surface, - drag_pin: terminal.Pin, -) void { - const screen = &self.io.terminal.screen; - const sel = screen.selection orelse return; - const sel_start = sel.start(); - const sel_end = sel.end(); - - var reset: bool = false; - if (sel.rectangle) { - // When we're in rectangle mode, we reset the selection relative to - // the click point depending on the selection mode we're in, with - // the exception of single-column selections, which we always reset - // on if we drift. - if (sel_start.x == sel_end.x) { - reset = drag_pin.x != sel_start.x; - } else { - reset = switch (sel.order(screen)) { - .forward => drag_pin.x < sel_start.x or drag_pin.before(sel_start), - .reverse => drag_pin.x > sel_start.x or sel_start.before(drag_pin), - .mirrored_forward => drag_pin.x > sel_start.x or drag_pin.before(sel_start), - .mirrored_reverse => drag_pin.x < sel_start.x or sel_start.before(drag_pin), + // Special handling for rectangular selections, we only use x position. + if (rectangle_selection) { + break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) { + .eq => drag_x_frac < click_x_frac, + .lt => true, + .gt => false, }; } - } else { - // Normal select uses simpler logic that is just based on the - // selection start/end. - reset = if (sel_end.before(sel_start)) - sel_start.before(drag_pin) + + break :ebs drag_pin.before(click_pin); + }; + + // Whether or not the the click pin cell + // should be included in the selection. + const include_click_cell = if (end_before_start) + click_x_frac >= threshold_point + else + click_x_frac < threshold_point; + + // Whether or not the the drag pin cell + // should be included in the selection. + const include_drag_cell = if (end_before_start) + drag_x_frac < threshold_point + else + drag_x_frac >= threshold_point; + + // If the click cell should be included in the selection then it's the + // start, otherwise we get the previous or next cell to it depending on + // the type and direction of the selection. + const start_pin = + if (include_click_cell) + click_pin + else if (end_before_start) + if (rectangle_selection) + click_pin.leftClamp(1) + else + click_pin.leftWrap(1) orelse click_pin + else if (rectangle_selection) + click_pin.rightClamp(1) else - drag_pin.before(sel_start); + click_pin.rightWrap(1) orelse click_pin; + + // Likewise for the end pin with the drag cell. + const end_pin = + if (include_drag_cell) + drag_pin + else if (end_before_start) + if (rectangle_selection) + drag_pin.rightClamp(1) + else + drag_pin.rightWrap(1) orelse drag_pin + else if (rectangle_selection) + drag_pin.leftClamp(1) + else + drag_pin.leftWrap(1) orelse drag_pin; + + // If the click cell is the same as the drag cell and the click cell + // shouldn't be included, or if the cells are adjacent such that the + // start or end pin becomes the other cell, and that cell should not + // be included, then we have no selection, so we set it to null. + // + // If in rectangular selection mode, we compare columns as well. + // + // TODO(qwerasd): this can/should probably be refactored, it's a bit + // repetitive and does excess work in rectangle mode. + if ((!include_click_cell and same_pin) or + (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or + (!include_click_cell and end_pin.eql(click_pin)) or + (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or + (!include_drag_cell and start_pin.eql(drag_pin)) or + (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) + { + try self.setSelection(null); + return; } - // Nullifying a selection can't fail. - if (reset) self.setSelection(null) catch unreachable; -} + // TODO: Clamp selection to the screen area, don't + // let it extend past the last written row. -// Handles how whether or not the drag screen point is before the click point. -// When we are in rectangle select, we only interpret the x axis to determine -// where to start the selection (before or after the click point). See -// dragLeftClickSingle for more details. -fn dragLeftClickBefore( - drag_pin: terminal.Pin, - click_pin: terminal.Pin, - mods: input.Mods, -) bool { - if (mods.ctrlOrSuper() and mods.alt) { - return drag_pin.x < click_pin.x; - } + try self.setSelection( + terminal.Selection.init( + start_pin, + end_pin, + rectangle_selection, + ), + ); - return drag_pin.before(click_pin); + return; } /// Call to notify Ghostty that the color scheme for the terminal has diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 300af8e13..a0eb3edd1 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3572,6 +3572,74 @@ pub const Pin = struct { return result; } + /// Move the pin left n columns, stopping at the start of the row. + pub fn leftClamp(self: Pin, n: size.CellCountInt) Pin { + var result = self; + result.x -|= n; + return result; + } + + /// Move the pin right n columns, stopping at the end of the row. + pub fn rightClamp(self: Pin, n: size.CellCountInt) Pin { + var result = self; + result.x = @min(self.x +| n, self.node.data.size.cols - 1); + return result; + } + + /// Move the pin left n cells, wrapping to the previous row as needed. + /// + /// If the offset goes beyond the top of the screen, returns null. + /// + /// TODO: Unit tests. + pub fn leftWrap(self: Pin, n: usize) ?Pin { + // NOTE: This assumes that all pages have the same width, which may + // be violated under certain circumstances by incomplete reflow. + const cols = self.node.data.size.cols; + const remaining_in_row = self.x; + + if (n <= remaining_in_row) return self.left(n); + + const extra_after_remaining = n - remaining_in_row; + + const rows_off = 1 + extra_after_remaining / cols; + + switch (self.upOverflow(rows_off)) { + .offset => |v| { + var result = v; + result.x = @intCast(cols - extra_after_remaining % cols); + return result; + }, + .overflow => return null, + } + } + + /// Move the pin right n cells, wrapping to the next row as needed. + /// + /// If the offset goes beyond the bottom of the screen, returns null. + /// + /// TODO: Unit tests. + pub fn rightWrap(self: Pin, n: usize) ?Pin { + // NOTE: This assumes that all pages have the same width, which may + // be violated under certain circumstances by incomplete reflow. + const cols = self.node.data.size.cols; + const remaining_in_row = cols - self.x - 1; + + if (n <= remaining_in_row) return self.right(n); + + const extra_after_remaining = n - remaining_in_row; + + const rows_off = 1 + extra_after_remaining / cols; + + switch (self.downOverflow(rows_off)) { + .offset => |v| { + var result = v; + result.x = @intCast(extra_after_remaining % cols - 1); + return result; + }, + .overflow => return null, + } + } + /// Move the pin down a certain number of rows, or return null if /// the pin goes beyond the end of the screen. pub fn down(self: Pin, n: usize) ?Pin { From 4d11673318e91d020655b1c4313de96c1084be47 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 26 May 2025 12:33:36 -0600 Subject: [PATCH 084/245] unit test mouse selection logic Adds many test cases for expected behavior of the selection logic, this will allow changes to be made more confidently in the future without fear of regressions. --- src/Surface.zig | 1164 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1128 insertions(+), 36 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3726c37c7..ffe39d46d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3676,11 +3676,29 @@ fn dragLeftClickTriple( fn dragLeftClickSingle( self: *Surface, drag_pin: terminal.Pin, - xpos: f64, + drag_x: f64, ) !void { - // TODO: Unit tests for this logic, maybe extract it out to a pure - // function so that it can be tested without mocking state. + // This logic is in a separate function so that it can be unit tested. + try self.setSelection(mouseSelection( + self.mouse.left_click_pin.?.*, + drag_pin, + @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), + @intFromFloat(@max(0.0, drag_x)), + self.mouse.mods, + self.size, + )); +} +/// Calculates the appropriate selection given pins and pixel x positions for +/// the click point and the drag point, as well as mouse mods and screen size. +fn mouseSelection( + click_pin: terminal.Pin, + drag_pin: terminal.Pin, + click_x: u32, + drag_x: u32, + mods: input.Mods, + size: rendererpkg.Size, +) ?terminal.Selection { // Explanation: // // # Normal selections @@ -3702,41 +3720,25 @@ fn dragLeftClickSingle( // Rectangular selections are handled similarly, except that // entire columns are considered rather than individual cells. - const click_pin = self.mouse.left_click_pin.?.*; - // We only include cells in the selection if the threshold point lies // between the start and end points of the selection. A threshold of // 60% of the cell width was chosen empirically because it felt good. - const threshold_point: u32 = @intFromFloat( - @as(f64, @floatFromInt(self.size.cell.width)) * 0.6, - ); + const threshold_point: u32 = @intFromFloat(@round( + @as(f64, @floatFromInt(size.cell.width)) * 0.6, + )); // We use this to clamp the pixel positions below. - const max_x = self.size.grid().columns * self.size.cell.width - 1; + const max_x = size.grid().columns * size.cell.width - 1; // We need to know how far across in the cell the drag pos is, so // we subtract the padding and then take it modulo the cell width. - const drag_x = @min( - max_x, - @as(u32, @intFromFloat(@max(0.0, xpos))) -| self.size.padding.left, - ); - const drag_x_frac = drag_x % self.size.cell.width; + const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width; // We figure out the fractional part of the click x position similarly. - // - // NOTE: This click_x position may be incorrect for the current location - // of the click pin, since it's a tracked pin that can move, so we - // should only use this for the fractional position not absolute. - const click_x = @min( - max_x, - @as(u32, @intFromFloat(@max(0.0, self.mouse.left_click_xpos))) -| - self.size.padding.left, - ); - const click_x_frac = click_x % self.size.cell.width; + const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width; // Whether or not this is a rectangular selection. - const rectangle_selection = - SurfaceMouse.isRectangleSelectState(self.mouse.mods); + const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods); // Whether the click pin and drag pin are equal. const same_pin = drag_pin.eql(click_pin); @@ -3819,22 +3821,17 @@ fn dragLeftClickSingle( (!include_drag_cell and start_pin.eql(drag_pin)) or (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x)) { - try self.setSelection(null); - return; + return null; } // TODO: Clamp selection to the screen area, don't // let it extend past the last written row. - try self.setSelection( - terminal.Selection.init( - start_pin, - end_pin, - rectangle_selection, - ), + return terminal.Selection.init( + start_pin, + end_pin, + rectangle_selection, ); - - return; } /// Call to notify Ghostty that the color scheme for the terminal has @@ -4819,3 +4816,1098 @@ fn presentSurface(self: *Surface) !void { {}, ); } + +test "Surface: selection logic" { + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We are testing normal selection logic here so no mods. + const mods: input.Mods = .{}; + + const expectEqual = std.testing.expectEqualDeep; + + // LTR, including click and drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + + const start_pin = click_pin; + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, including click pin cell but not drag pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + const start_pin = click_pin; + const end_pin = drag_pin.left(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, including drag pin cell but not click pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At rightmost px in cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At rightmost px in cell. + + const start_pin = click_pin.right(1); + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, including neither click nor drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + const start_pin = click_pin.right(1); + const end_pin = drag_pin.left(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, empty selection (single cell on only left half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = click_pin; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 1; // At px 1 within the cell. + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // LTR, empty selection (single cell on only right half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = click_pin; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 2; // 1px left of right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // LTR, empty selection (between two cells, not crossing threshold) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 4, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // --- RTL + + // RTL, including click and drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + const start_pin = click_pin; + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, including click pin cell but not drag pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + + const start_pin = click_pin; + const end_pin = drag_pin.right(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, including drag pin cell but not click pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within cell. + + const start_pin = click_pin.left(1); + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, including neither click nor drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + + const start_pin = click_pin.left(1); + const end_pin = drag_pin.right(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, empty selection (single cell on only left half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = click_pin; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 1; // At px 1 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // RTL, empty selection (single cell on only right half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + const drag_pin = click_pin; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 2; // 1px left of right edge of cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // RTL, empty selection (between two cells, not crossing threshold) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 4, .y = 3 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 3 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // -- Wrapping + + // LTR, wrap excluded cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 9, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 0, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = 0, .y = 3 }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = 9, .y = 3 }, + }) orelse unreachable; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, wrap excluded cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 0, .y = 4 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 9, .y = 2 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = 9, .y = 3 }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = 0, .y = 3 }, + }) orelse unreachable; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = false, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } +} + +test "Surface: rectangle selection logic" { + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We hold ctrl and alt so that this test is platform-agnostic. + const mods: input.Mods = .{ + .ctrl = true, + .alt = true, + }; + + try std.testing.expect(SurfaceMouse.isRectangleSelectState(mods)); + + const expectEqual = std.testing.expectEqualDeep; + + // LTR, including click and drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + + const start_pin = click_pin; + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, including click pin cell but not drag pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + const start_pin = click_pin; + const end_pin = drag_pin.left(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, including drag pin cell but not click pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At rightmost px in cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At rightmost px in cell. + + const start_pin = click_pin.right(1); + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, including neither click nor drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + const start_pin = click_pin.right(1); + const end_pin = drag_pin.left(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // LTR, empty selection (single column on only left half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 1; // At px 1 within the cell. + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // LTR, empty selection (single column on only right half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 2; // 1px left of right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // LTR, empty selection (between two columns, not crossing threshold) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 4, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // --- RTL + + // RTL, including click and drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + + const start_pin = click_pin; + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, including click pin cell but not drag pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + + const start_pin = click_pin; + const end_pin = drag_pin.right(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, including drag pin cell but not click pin cell + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within cell. + + const start_pin = click_pin.left(1); + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, including neither click nor drag pin cells + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 5, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At the rightmost px of the cell. + + const start_pin = click_pin.left(1); + const end_pin = drag_pin.right(1); + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, empty selection (single column on only left half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 1; // At px 1 within the cell. + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 1; // At px 0 within the cell. + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // RTL, empty selection (single column on only right half) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 2; // 1px left of right edge of cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // RTL, empty selection (between two cells, not crossing threshold) + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 4, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 3, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + + try expectEqual( + null, + mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + ), + ); + } + + // -- Wrapping + + // LTR, do not wrap + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 9, .y = 2 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 0, .y = 4 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + + const start_pin = click_pin; + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } + + // RTL, do not wrap + { + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = 0, .y = 4 }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = 9, .y = 2 }, + }) orelse unreachable; + + const click_x = + @as(u32, click_pin.x) * size.cell.width + size.padding.left + + 0; // At px 0 within the cell + const drag_x = + @as(u32, drag_pin.x) * size.cell.width + size.padding.left + + size.cell.width - 1; // At right edge of cell + + const start_pin = click_pin; + const end_pin = drag_pin; + + try expectEqual(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = true, + }, mouseSelection( + click_pin, + drag_pin, + click_x, + drag_x, + mods, + size, + )); + } +} From 6aa84d0e92ad4b88692a6da8c2834821574b5773 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 26 May 2025 14:31:59 -0600 Subject: [PATCH 085/245] test: introduce helper function for mouse selection tests Removes a lot of repeated code and makes the test cases easier to understand at a glance. --- src/Surface.zig | 1448 +++++++++++++---------------------------------- 1 file changed, 390 insertions(+), 1058 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ffe39d46d..8b4f58496 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4817,7 +4817,29 @@ fn presentSurface(self: *Surface) !void { ); } -test "Surface: selection logic" { +/// Utility function for the unit tests for mouse selection logic. +/// +/// Tests a click and drag on a 10x5 cell grid, x positions are given in +/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3. +/// +/// NOTE: The size tested with has 10px wide cells, meaning only one digit +/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1. +/// +/// The provided start_x/y and end_x/y are the expected start and end points +/// of the resulting selection. +fn testMouseSelection( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + start_x: terminal.size.CellCountInt, + start_y: u32, + end_x: terminal.size.CellCountInt, + end_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + // Our screen size is 10x5 cells that are // 10x20 px, with 5px padding on all sides. const size: rendererpkg.Size = .{ @@ -4828,1086 +4850,396 @@ test "Surface: selection logic" { var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); defer screen.deinit(); - // We are testing normal selection logic here so no mods. - const mods: input.Mods = .{}; + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; - const expectEqual = std.testing.expectEqualDeep; + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); - // LTR, including click and drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; - const start_pin = click_pin; - const end_pin = drag_pin; + const start_pin = screen.pages.pin(.{ + .viewport = .{ .x = start_x, .y = start_y }, + }) orelse unreachable; + const end_pin = screen.pages.pin(.{ + .viewport = .{ .x = end_x, .y = end_y }, + }) orelse unreachable; - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( + try std.testing.expectEqualDeep(terminal.Selection{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, + }, mouseSelection( + click_pin, + drag_pin, + click_x_pos, + drag_x_pos, + mods, + size, + )); +} + +/// Like `testMouseSelection` but checks that the resulting selection is null. +/// +/// See `testMouseSelection` for more details. +fn testMouseSelectionIsNull( + click_x: f64, + click_y: u32, + drag_x: f64, + drag_y: u32, + rect: bool, +) !void { + assert(builtin.is_test); + + // Our screen size is 10x5 cells that are + // 10x20 px, with 5px padding on all sides. + const size: rendererpkg.Size = .{ + .cell = .{ .width = 10, .height = 20 }, + .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, + .screen = .{ .width = 110, .height = 110 }, + }; + var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + defer screen.deinit(); + + // We hold both ctrl and alt for rectangular + // select so that this test is platform agnostic. + const mods: input.Mods = .{ + .ctrl = rect, + .alt = rect, + }; + + try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods)); + + const click_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y }, + }) orelse unreachable; + const drag_pin = screen.pages.pin(.{ + .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y }, + }) orelse unreachable; + + const cell_width_f64: f64 = @floatFromInt(size.cell.width); + const click_x_pos: u32 = + @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) + + size.padding.left; + const drag_x_pos: u32 = + @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) + + size.padding.left; + + try std.testing.expectEqual( + null, + mouseSelection( click_pin, drag_pin, - click_x, - drag_x, + click_x_pos, + drag_x_pos, mods, size, - )); - } + ), + ); +} - // LTR, including click pin cell but not drag pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; +test "Surface: selection logic" { + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. + // -- LTR + // single cell selection + try testMouseSelection( + 3.0, 3, // click + 3.9, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 3.0, 3, // click + 5.9, 3, // drag + 3, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 3.0, 3, // click + 5.0, 3, // drag + 3, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 3.9, 3, // click + 5.9, 3, // drag + 4, 3, // expected start + 5, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 3.9, 3, // click + 5.0, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.0, 3, // click + 3.1, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.8, 3, // click + 3.9, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 3, // click + 4.0, 3, // drag + false, // regular selection + ); - const start_pin = click_pin; - const end_pin = drag_pin.left(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, including drag pin cell but not click pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At rightmost px in cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At rightmost px in cell. - - const start_pin = click_pin.right(1); - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, including neither click nor drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - - const start_pin = click_pin.right(1); - const end_pin = drag_pin.left(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, empty selection (single cell on only left half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = click_pin; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 1; // At px 1 within the cell. - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // LTR, empty selection (single cell on only right half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = click_pin; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 2; // 1px left of right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // LTR, empty selection (between two cells, not crossing threshold) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 4, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // --- RTL - - // RTL, including click and drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - - const start_pin = click_pin; - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, including click pin cell but not drag pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - - const start_pin = click_pin; - const end_pin = drag_pin.right(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, including drag pin cell but not click pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within cell. - - const start_pin = click_pin.left(1); - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, including neither click nor drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - - const start_pin = click_pin.left(1); - const end_pin = drag_pin.right(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, empty selection (single cell on only left half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = click_pin; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 1; // At px 1 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // RTL, empty selection (single cell on only right half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - const drag_pin = click_pin; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 2; // 1px left of right edge of cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // RTL, empty selection (between two cells, not crossing threshold) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 4, .y = 3 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 3 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } + // -- RTL + // single cell selection + try testMouseSelection( + 3.9, 3, // click + 3.0, 3, // drag + 3, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click and drag pin cells + try testMouseSelection( + 5.9, 3, // click + 3.0, 3, // drag + 5, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including click pin cell but not drag pin cell + try testMouseSelection( + 5.9, 3, // click + 3.9, 3, // drag + 5, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // including drag pin cell but not click pin cell + try testMouseSelection( + 5.0, 3, // click + 3.0, 3, // drag + 4, 3, // expected start + 3, 3, // expected end + false, // regular selection + ); + // including neither click nor drag pin cells + try testMouseSelection( + 5.0, 3, // click + 3.9, 3, // drag + 4, 3, // expected start + 4, 3, // expected end + false, // regular selection + ); + // empty selection (single cell on only left half) + try testMouseSelectionIsNull( + 3.1, 3, // click + 3.0, 3, // drag + false, // regular selection + ); + // empty selection (single cell on only right half) + try testMouseSelectionIsNull( + 3.9, 3, // click + 3.8, 3, // drag + false, // regular selection + ); + // empty selection (between two cells, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 3, // click + 3.9, 3, // drag + false, // regular selection + ); // -- Wrapping - // LTR, wrap excluded cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 9, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 0, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - - const start_pin = screen.pages.pin(.{ - .viewport = .{ .x = 0, .y = 3 }, - }) orelse unreachable; - const end_pin = screen.pages.pin(.{ - .viewport = .{ .x = 9, .y = 3 }, - }) orelse unreachable; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 0, 3, // expected start + 9, 3, // expected end + false, // regular selection + ); // RTL, wrap excluded cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 0, .y = 4 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 9, .y = 2 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - - const start_pin = screen.pages.pin(.{ - .viewport = .{ .x = 9, .y = 3 }, - }) orelse unreachable; - const end_pin = screen.pages.pin(.{ - .viewport = .{ .x = 0, .y = 3 }, - }) orelse unreachable; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = false, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 9, 3, // expected start + 0, 3, // expected end + false, // regular selection + ); } test "Surface: rectangle selection logic" { - // Our screen size is 10x5 cells that are - // 10x20 px, with 5px padding on all sides. - const size: rendererpkg.Size = .{ - .cell = .{ .width = 10, .height = 20 }, - .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, - .screen = .{ .width = 110, .height = 110 }, - }; - var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); - defer screen.deinit(); + // We disable format to make these easier to + // read by pairing sets of coordinates per line. + // zig fmt: off - // We hold ctrl and alt so that this test is platform-agnostic. - const mods: input.Mods = .{ - .ctrl = true, - .alt = true, - }; + // -- LTR + // single column selection + try testMouseSelection( + 3.0, 2, // click + 3.9, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 3.0, 2, // click + 5.9, 4, // drag + 3, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 3.0, 2, // click + 5.0, 4, // drag + 3, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 3.9, 2, // click + 5.9, 4, // drag + 4, 2, // expected start + 5, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 3.9, 2, // click + 5.0, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.0, 2, // click + 3.1, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.8, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 3.9, 2, // click + 4.0, 4, // drag + true, //rectangle selection + ); - try std.testing.expect(SurfaceMouse.isRectangleSelectState(mods)); - - const expectEqual = std.testing.expectEqualDeep; - - // LTR, including click and drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - - const start_pin = click_pin; - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, including click pin cell but not drag pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - - const start_pin = click_pin; - const end_pin = drag_pin.left(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, including drag pin cell but not click pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At rightmost px in cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At rightmost px in cell. - - const start_pin = click_pin.right(1); - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, including neither click nor drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - - const start_pin = click_pin.right(1); - const end_pin = drag_pin.left(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // LTR, empty selection (single column on only left half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 1; // At px 1 within the cell. - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // LTR, empty selection (single column on only right half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 2; // 1px left of right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // LTR, empty selection (between two columns, not crossing threshold) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 4, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // --- RTL - - // RTL, including click and drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - - const start_pin = click_pin; - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, including click pin cell but not drag pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - - const start_pin = click_pin; - const end_pin = drag_pin.right(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, including drag pin cell but not click pin cell - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within cell. - - const start_pin = click_pin.left(1); - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, including neither click nor drag pin cells - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 5, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At the rightmost px of the cell. - - const start_pin = click_pin.left(1); - const end_pin = drag_pin.right(1); - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - - // RTL, empty selection (single column on only left half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 1; // At px 1 within the cell. - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 1; // At px 0 within the cell. - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // RTL, empty selection (single column on only right half) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 2; // 1px left of right edge of cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } - - // RTL, empty selection (between two cells, not crossing threshold) - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 4, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 3, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - - try expectEqual( - null, - mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - ), - ); - } + // -- RTL + // single column selection + try testMouseSelection( + 3.9, 2, // click + 3.0, 4, // drag + 3, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click and drag pin columns + try testMouseSelection( + 5.9, 2, // click + 3.0, 4, // drag + 5, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including click pin column but not drag pin column + try testMouseSelection( + 5.9, 2, // click + 3.9, 4, // drag + 5, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // including drag pin column but not click pin column + try testMouseSelection( + 5.0, 2, // click + 3.0, 4, // drag + 4, 2, // expected start + 3, 4, // expected end + true, //rectangle selection + ); + // including neither click nor drag pin columns + try testMouseSelection( + 5.0, 2, // click + 3.9, 4, // drag + 4, 2, // expected start + 4, 4, // expected end + true, //rectangle selection + ); + // empty selection (single column on only left half) + try testMouseSelectionIsNull( + 3.1, 2, // click + 3.0, 4, // drag + true, //rectangle selection + ); + // empty selection (single column on only right half) + try testMouseSelectionIsNull( + 3.9, 2, // click + 3.8, 4, // drag + true, //rectangle selection + ); + // empty selection (between two columns, not crossing threshold) + try testMouseSelectionIsNull( + 4.0, 2, // click + 3.9, 4, // drag + true, //rectangle selection + ); // -- Wrapping - // LTR, do not wrap - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 9, .y = 2 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 0, .y = 4 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - - const start_pin = click_pin; - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } - + try testMouseSelection( + 9.9, 2, // click + 0.0, 4, // drag + 9, 2, // expected start + 0, 4, // expected end + true, //rectangle selection + ); // RTL, do not wrap - { - const click_pin = screen.pages.pin(.{ - .viewport = .{ .x = 0, .y = 4 }, - }) orelse unreachable; - const drag_pin = screen.pages.pin(.{ - .viewport = .{ .x = 9, .y = 2 }, - }) orelse unreachable; - - const click_x = - @as(u32, click_pin.x) * size.cell.width + size.padding.left + - 0; // At px 0 within the cell - const drag_x = - @as(u32, drag_pin.x) * size.cell.width + size.padding.left + - size.cell.width - 1; // At right edge of cell - - const start_pin = click_pin; - const end_pin = drag_pin; - - try expectEqual(terminal.Selection{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = true, - }, mouseSelection( - click_pin, - drag_pin, - click_x, - drag_x, - mods, - size, - )); - } + try testMouseSelection( + 0.0, 4, // click + 9.9, 2, // drag + 0, 4, // expected start + 9, 2, // expected end + true, //rectangle selection + ); } From ba02f0ae22b06fa7e0f1a5b7b38f073c935b8c1e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 27 May 2025 09:45:31 -0700 Subject: [PATCH 086/245] decl literal --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8b4f58496..0a2885dff 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3827,7 +3827,7 @@ fn mouseSelection( // TODO: Clamp selection to the screen area, don't // let it extend past the last written row. - return terminal.Selection.init( + return .init( start_pin, end_pin, rectangle_selection, From 21c97aa9d64769061351df82eee7e0b7a27de71e Mon Sep 17 00:00:00 2001 From: Jonatan Borkowski Date: Sun, 25 May 2025 22:22:07 +0200 Subject: [PATCH 087/245] add support for buffer switching with CSI ? 47 h/l --- src/terminal/modes.zig | 1 + src/termio/stream_handler.zig | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 60ecc7698..b36266b32 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -206,6 +206,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "cursor_visible", .value = 25, .default = true }, .{ .name = "enable_mode_3", .value = 40 }, .{ .name = "reverse_wrap", .value = 45 }, + .{ .name = "alt_screen_legacy", .value = 47 }, .{ .name = "keypad_keys", .value = 66 }, .{ .name = "enable_left_and_right_margin", .value = 69 }, .{ .name = "mouse_event_normal", .value = 1000 }, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 299c7cd45..ffd00e14d 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -582,6 +582,16 @@ pub const StreamHandler = struct { self.terminal.scrolling_region.right = self.terminal.cols - 1; }, + .alt_screen_legacy => { + if (enabled) + self.terminal.alternateScreen(.{}) + else + self.terminal.primaryScreen(.{}); + + // Schedule a render since we changed screens + try self.queueRender(); + }, + .alt_screen => { const opts: terminal.Terminal.AlternateScreenOptions = .{ .cursor_save = false, From 6f7e9d5bea9840627888a6c3c9e89056680ec721 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 27 May 2025 21:55:28 -0600 Subject: [PATCH 088/245] code style: use `@splat` where possible As of Zig 0.14.0, `@splat` can be used for array types, which eliminates a lot of redundant syntax and makes things generally cleaner. I've explicitly avoided applying this change in the renderer files for now since it would just create rebasing conflicts in my renderer rework branch which I'll be PR-ing pretty soon. --- src/Surface.zig | 2 +- src/datastruct/cache_table.zig | 2 +- src/font/SharedGridSet.zig | 2 +- src/font/sprite/Box.zig | 2 +- src/terminal/Tabstops.zig | 2 +- src/terminal/Terminal.zig | 2 +- src/terminal/kitty/key.zig | 4 ++-- src/terminal/ref_counted_set.zig | 6 +++--- src/terminfo/Source.zig | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0a2885dff..01639964b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -160,7 +160,7 @@ pub const InputEffect = enum { /// Mouse state for the surface. const Mouse = struct { /// The last tracked mouse button state by button. - click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max, + click_state: [input.MouseButton.max]input.MouseButtonState = @splat(.release), /// The last mods state when the last mouse button (whatever it was) was /// pressed or release. diff --git a/src/datastruct/cache_table.zig b/src/datastruct/cache_table.zig index 40d36cc24..fbfb30d71 100644 --- a/src/datastruct/cache_table.zig +++ b/src/datastruct/cache_table.zig @@ -70,7 +70,7 @@ pub fn CacheTable( /// become a pointless check, but hopefully branch prediction picks /// up on it at that point. The memory cost isn't too bad since it's /// just bytes, so should be a fraction the size of the main table. - lengths: [bucket_count]u8 = [_]u8{0} ** bucket_count, + lengths: [bucket_count]u8 = @splat(0), /// An instance of the context structure. /// Must be initialized before calling any operations. diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 858d7930f..e3e61907b 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -498,7 +498,7 @@ pub const Key = struct { /// each style. For example, bold is from /// offsets[@intFromEnum(.bold) - 1] to /// offsets[@intFromEnum(.bold)]. - style_offsets: StyleOffsets = .{0} ** style_offsets_len, + style_offsets: StyleOffsets = @splat(0), /// The codepoint map configuration. codepoint_map: CodepointMap = .{}, diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index b1ebfe3a9..f3942b83d 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -2517,7 +2517,7 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const octants: [octants_len]Octant = comptime octants: { @setEvalBranchQuota(10_000); - var result: [octants_len]Octant = .{Octant{}} ** octants_len; + var result: [octants_len]Octant = @splat(.{}); var i: usize = 0; const data = @embedFile("octants.txt"); diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index 5a54fb28b..4ab5133d9 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -44,7 +44,7 @@ const masks = blk: { cols: usize = 0, /// Preallocated tab stops. -prealloc_stops: [prealloc_count]Unit = [1]Unit{0} ** prealloc_count, +prealloc_stops: [prealloc_count]Unit = @splat(0), /// Dynamically expanded stops above prealloc stops. dynamic_stops: []Unit = &[0]Unit{}, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 595fee1ba..bb6702201 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2329,7 +2329,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { try writer.writeByte('0'); const pen = self.screen.cursor.style; - var attrs = [_]u8{0} ** 8; + var attrs: [8]u8 = @splat(0); var i: usize = 0; if (pen.flags.bold) { diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig index 8bafcb7dc..0883c90f2 100644 --- a/src/terminal/kitty/key.zig +++ b/src/terminal/kitty/key.zig @@ -8,7 +8,7 @@ const std = @import("std"); pub const FlagStack = struct { const len = 8; - flags: [len]Flags = .{Flags{}} ** len, + flags: [len]Flags = @splat(.{}), idx: u3 = 0, /// Return the current stack value @@ -51,7 +51,7 @@ pub const FlagStack = struct { // could send a huge number of pop commands to waste cpu. if (n >= self.flags.len) { self.idx = 0; - self.flags = .{Flags{}} ** len; + self.flags = @splat(.{}); return; } diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 8023461f3..153e331a6 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -115,7 +115,7 @@ pub fn RefCountedSet( /// input. We handle this gracefully by returning an error /// anywhere where we're about to insert if there's any /// item with a PSL in the last slot of the stats array. - psl_stats: [32]Id = [_]Id{0} ** 32, + psl_stats: [32]Id = @splat(0), /// The backing store of items items: Offset(Item), @@ -663,7 +663,7 @@ pub fn RefCountedSet( const table = self.table.ptr(base); const items = self.items.ptr(base); - var psl_stats: [32]Id = [_]Id{0} ** 32; + var psl_stats: [32]Id = @splat(0); for (items[0..self.layout.cap], 0..) |item, id| { if (item.meta.bucket < std.math.maxInt(Id)) { @@ -676,7 +676,7 @@ pub fn RefCountedSet( assert(std.mem.eql(Id, &psl_stats, &self.psl_stats)); - psl_stats = [_]Id{0} ** 32; + psl_stats = @splat(0); for (table[0..self.layout.table_cap], 0..) |id, bucket| { const item = items[id]; diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 8ffd9cabb..7692e6f54 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -74,7 +74,7 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { // We have all of our capabilities plus To, TN, and RGB which aren't // in the capabilities list but are query-able. const len = self.capabilities.len + 3; - var kvs: [len]KV = .{.{ "", "" }} ** len; + var kvs: [len]KV = @splat(.{ "", "" }); // We first build all of our entries with raw K=V pairs. kvs[0] = .{ "TN", self.names[0] }; From d1501a492530b99c00a11fdd58d5ec411ac7c937 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 27 May 2025 22:15:43 -0700 Subject: [PATCH 089/245] fix: properly intialize key event in GlobalEventTap --- .../Sources/Features/Global Keybinds/GlobalEventTap.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 935c2fb03..644285c9a 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -141,12 +141,7 @@ fileprivate func cgEventFlagsChangedHandler( guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result } // Build our event input and call ghostty - var key_ev = ghostty_input_key_s() - key_ev.action = GHOSTTY_ACTION_PRESS - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false + var key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) if (ghostty_app_key(ghostty, key_ev)) { GlobalEventTap.logger.info("global key event handled event=\(event)") return nil From 9c1abf487e0f05cebc4f3e932e1a5a8cdb5aa6a6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 17:15:40 -0500 Subject: [PATCH 090/245] OSC: start adding structure to allow multiple color operations per OSC --- src/terminal/osc.zig | 418 ++++++++++++++++++++++++++++------ src/terminal/stream.zig | 7 + src/termio/stream_handler.zig | 175 ++++++++++++++ 3 files changed, 526 insertions(+), 74 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 932964137..7729eaa6b 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -109,6 +109,13 @@ pub const Command = union(enum) { value: []const u8, }, + /// OSC color operations + color_operation: struct { + source: ColorOperationSource, + operations: std.ArrayListUnmanaged(ColorOperation) = .empty, + terminator: Terminator = .st, + }, + /// OSC 4, OSC 10, and OSC 11 color report. report_color: struct { /// OSC 4 requests a palette color, OSC 10 requests the foreground @@ -182,6 +189,32 @@ pub const Command = union(enum) { /// Wait input (OSC 9;5) wait_input: void, + pub const ColorOperationSource = enum(u16) { + osc_4 = 4, + osc_10 = 10, + osc_11 = 11, + osc_12 = 12, + osc_104 = 104, + + pub fn format( + self: ColorOperationSource, + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.print("{d}", .{@intFromEnum(self)}); + } + }; + + pub const ColorOperation = union(enum) { + set: struct { + kind: ColorKind, + color: RGB, + }, + reset: ColorKind, + report: ColorKind, + }; + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -234,6 +267,15 @@ pub const Terminator = enum { .bel => "\x07", }; } + + pub fn format( + self: Terminator, + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try writer.writeAll(self.string()); + } }; pub const Parser = struct { @@ -288,6 +330,7 @@ pub const Parser = struct { @"0", @"1", @"10", + @"104", @"11", @"12", @"13", @@ -327,17 +370,16 @@ pub const Parser = struct { clipboard_kind_end, // Get/set color palette index - color_palette_index, - color_palette_index_end, + osc_4, + + // Reset color palette index + osc_104, // Hyperlinks hyperlink_param_key, hyperlink_param_value, hyperlink_uri, - // Reset color palette index - reset_color_palette_index, - // rxvt extension. Only used for OSC 777 and only the value "notify" is // supported rxvt_extension, @@ -423,6 +465,10 @@ pub const Parser = struct { v.list.deinit(); self.command = default; }, + .color_operation => |*v| { + v.operations.deinit(self.alloc.?); + self.command = default; + }, else => {}, } } @@ -503,18 +549,26 @@ pub const Parser = struct { .@"10" => switch (c) { ';' => self.state = .query_fg_color, - '4' => { - self.command = .{ .reset_color = .{ - .kind = .{ .palette = 0 }, - .value = "", - } }; + '4' => self.state = .@"104", + else => self.state = .invalid, + }, - self.state = .reset_color_palette_index; + .@"104" => switch (c) { + ';' => osc_104: { + if (self.alloc == null) { + log.info("OSC 104 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_104; + } + self.state = .osc_104; + self.buf_start = self.buf_idx; self.complete = true; }, else => self.state = .invalid, }, + .osc_104 => {}, + .@"11" => switch (c) { ';' => self.state = .query_bg_color, '0' => { @@ -621,65 +675,20 @@ pub const Parser = struct { }, .@"4" => switch (c) { - ';' => { - self.state = .color_palette_index; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .color_palette_index => switch (c) { - '0'...'9' => {}, - ';' => blk: { - const str = self.buf[self.buf_start .. self.buf_idx - 1]; - if (str.len == 0) { + ';' => osc_4: { + if (self.alloc == null) { + log.info("OSC 4 requires an allocator, but none was provided", .{}); self.state = .invalid; - break :blk; + break :osc_4; } - - if (std.fmt.parseUnsigned(u8, str, 10)) |num| { - self.state = .color_palette_index_end; - self.temp_state = .{ .num = num }; - } else |err| switch (err) { - error.Overflow => self.state = .invalid, - error.InvalidCharacter => unreachable, - } - }, - else => self.state = .invalid, - }, - - .color_palette_index_end => switch (c) { - '?' => { - self.command = .{ .report_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - } }; - + self.state = .osc_4; + self.buf_start = self.buf_idx; self.complete = true; }, - else => { - self.command = .{ .set_color = .{ - .kind = .{ .palette = @intCast(self.temp_state.num) }, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, + else => self.state = .invalid, }, - .reset_color_palette_index => switch (c) { - ';' => { - self.state = .string; - self.temp_state = .{ .str = &self.command.reset_color.value }; - self.buf_start = self.buf_idx; - self.complete = false; - }, - else => { - self.state = .invalid; - self.complete = false; - }, - }, + .osc_4 => {}, .@"5" => switch (c) { '2' => self.state = .@"52", @@ -1327,6 +1336,104 @@ pub const Parser = struct { self.temp_state.str.* = list.items; } + fn parseOSC4(self: *Parser) void { + assert(self.state == .osc_4); + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = .osc_4, + .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 8) catch |err| { + log.warn("unable to allocate memory for OSC 4 parsing: {}", .{err}); + self.state = .invalid; + return; + }, + }, + }; + + const str = self.buf[self.buf_start..self.buf_idx]; + var it = std.mem.splitScalar(u8, str, ';'); + while (it.next()) |index_str| { + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid palette index spec in OSC 4: {s}", .{index_str}); + // skip any spec + _ = it.next(); + continue; + }, + }; + const spec_str = it.next() orelse continue; + if (std.mem.eql(u8, spec_str, "?")) { + self.command.color_operation.operations.append( + alloc, + .{ + .report = .{ .palette = index }, + }, + ) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + } else { + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification {s} in OSC 4: {}", .{ spec_str, err }); + continue; + }; + self.command.color_operation.operations.append( + alloc, + .{ + .set = .{ + .kind = .{ + .palette = index, + }, + .color = color, + }, + }, + ) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + } + } + } + + fn parseOSC104(self: *Parser) void { + assert(self.state == .osc_104); + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = .osc_104, + .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 8) catch |err| { + log.warn("unable to allocate memory for OSC 104 parsing: {}", .{err}); + self.state = .invalid; + return; + }, + }, + }; + + const str = self.buf[self.buf_start..self.buf_idx]; + var it = std.mem.splitScalar(u8, str, ';'); + while (it.next()) |index_str| { + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid palette index spec in OSC 104: {s}", .{index_str}); + continue; + }, + }; + self.command.color_operation.operations.append( + alloc, + .{ + .reset = .{ .palette = index }, + }, + ) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + } + } + /// End the sequence and return the command, if any. If the return value /// is null, then no valid command was found. The optional terminator_ch /// is the final character in the OSC sequence. This is used to determine @@ -1350,12 +1457,15 @@ pub const Parser = struct { .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), + .osc_4 => self.parseOSC4(), + .osc_104 => self.parseOSC104(), else => {}, } switch (self.command) { .report_color => |*c| c.terminator = .init(terminator_ch), .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch), + .color_operation => |*c| c.terminator = .init(terminator_ch), else => {}, } @@ -1729,32 +1839,192 @@ test "OSC: set background color" { try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); } -test "OSC: get palette color" { +test "OSC: OSC4: get palette color 1" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; const input = "4;1;?"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind); - try testing.expectEqual(cmd.report_color.terminator, .st); + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 1); + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual(Command.ColorKind{ .palette = 1 }, op.report); + try testing.expectEqual(cmd.color_operation.terminator, .st); } -test "OSC: set palette color" { +test "OSC: OSC4: get palette color 2" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;1;?;2;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 2); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual(Command.ColorKind{ .palette = 1 }, op.report); + } + { + const op = cmd.color_operation.operations.items[1]; + try testing.expect(op == .report); + try testing.expectEqual(Command.ColorKind{ .palette = 2 }, op.report); + } + try testing.expectEqual(cmd.color_operation.terminator, .st); +} + +test "OSC: OSC4: set palette color 1" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; const input = "4;17;rgb:aa/bb/cc"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 1); + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.set.kind); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); +} + +test "OSC: OSC4: set palette color 2" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;17;rgb:aa/bb/cc;1;rgb:00/11/22"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 2); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.set.kind); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + { + const op = cmd.color_operation.operations.items[1]; + try testing.expect(op == .set); + try testing.expectEqual(Command.ColorKind{ .palette = 1 }, op.set.kind); + try testing.expectEqual( + RGB{ .r = 0x00, .g = 0x11, .b = 0x22 }, + op.set.color, + ); + } +} + +test "OSC: OSC4: mix get/set palette color" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;17;rgb:aa/bb/cc;254;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 2); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.set.kind); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + { + const op = cmd.color_operation.operations.items[1]; + try testing.expect(op == .report); + try testing.expectEqual(Command.ColorKind{ .palette = 254 }, op.report); + } +} + +test "OSC: OSC104: reset palette color 1" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "104;17"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.reset); + } +} + +test "OSC: OSC104: reset palette color 2" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "104;17;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.operations.items.len == 2); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.reset); + } + { + const op = cmd.color_operation.operations.items[1]; + try testing.expect(op == .reset); + try testing.expectEqual(Command.ColorKind{ .palette = 111 }, op.reset); + } } test "OSC: conemu sleep" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 76fa6c129..2a1ae80c9 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1555,6 +1555,13 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .color_operation => |v| { + if (@hasDecl(T, "handleColorOperation")) { + try self.handler.handleColorOperation(v.source, v.operations.items, v.terminator); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + .report_color => |v| { if (@hasDecl(T, "reportColor")) { try self.handler.reportColor(v.kind, v.terminator); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ffd00e14d..57a1eeacf 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1195,6 +1195,181 @@ pub const StreamHandler = struct { } } + pub fn handleColorOperation( + self: *StreamHandler, + source: terminal.osc.Command.ColorOperationSource, + operations: []terminal.osc.Command.ColorOperation, + terminator: terminal.osc.Terminator, + ) !void { + var buffer: [1024]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buffer); + const alloc = fba.allocator(); + + var response: std.ArrayListUnmanaged(u8) = .empty; + const writer = response.writer(alloc); + + var report: bool = false; + + try writer.print("\x1b]{}", .{source}); + + for (operations) |op| { + switch (op) { + .set => |set| { + switch (set.kind) { + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = set.color; + self.terminal.color_palette.mask.set(i); + }, + .foreground => { + self.foreground_color = set.color; + _ = self.renderer_mailbox.push(.{ + .foreground_color = set.color, + }, .{ .forever = {} }); + }, + .background => { + self.background_color = set.color; + _ = self.renderer_mailbox.push(.{ + .background_color = set.color, + }, .{ .forever = {} }); + }, + .cursor => { + self.cursor_color = set.color; + _ = self.renderer_mailbox.push(.{ + .cursor_color = set.color, + }, .{ .forever = {} }); + }, + } + + // Notify the surface of the color change + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = set.kind, + .color = set.color, + } }); + }, + + .reset => |kind| { + switch (kind) { + .palette => |i| { + const mask = &self.terminal.color_palette.mask; + self.terminal.flags.dirty.palette = true; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + + self.surfaceMessageWriter(.{ + .color_change = .{ + .kind = .{ .palette = @intCast(i) }, + .color = self.terminal.color_palette.colors[i], + }, + }); + }, + .foreground => { + self.foreground_color = null; + _ = self.renderer_mailbox.push(.{ + .foreground_color = self.foreground_color, + }, .{ .forever = {} }); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .foreground, + .color = self.default_foreground_color, + } }); + }, + .background => { + self.background_color = null; + _ = self.renderer_mailbox.push(.{ + .background_color = self.background_color, + }, .{ .forever = {} }); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .background, + .color = self.default_background_color, + } }); + }, + .cursor => { + self.cursor_color = null; + + _ = self.renderer_mailbox.push(.{ + .cursor_color = self.cursor_color, + }, .{ .forever = {} }); + + if (self.default_cursor_color) |color| { + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .cursor, + .color = color, + } }); + } + }, + } + }, + + .report => |kind| report: { + if (self.osc_color_report_format == .none) break :report; + + report = true; + + const color = switch (kind) { + .palette => |i| self.terminal.color_palette.colors[i], + .foreground => self.foreground_color orelse self.default_foreground_color, + .background => self.background_color orelse self.default_background_color, + .cursor => self.cursor_color orelse + self.default_cursor_color orelse + self.foreground_color orelse + self.default_foreground_color, + }; + + switch (self.osc_color_report_format) { + .@"16-bit" => switch (kind) { + .palette => |i| try writer.print( + ";{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + i, + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + }, + ), + else => try writer.print( + ";rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + }, + ), + }, + + .@"8-bit" => switch (kind) { + .palette => |i| try writer.print( + ";{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ + i, + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + }, + ), + else => try writer.print( + ";rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + }, + ), + }, + + .none => unreachable, + } + }, + } + } + if (report) { + try writer.writeAll(terminator.string()); + const msg: termio.Message = .{ .write_stable = response.items }; + self.messageWriter(msg); + } + } + /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, /// default foreground color, and background color respectively. pub fn reportColor( From 5ec1c15ecfe5e2a29f4e0c9cbe116aeed7ad62d5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 18:05:59 -0500 Subject: [PATCH 091/245] OSC: add more tests --- src/terminal/osc.zig | 170 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 17 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 7729eaa6b..778196160 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1357,8 +1357,8 @@ pub const Parser = struct { while (it.next()) |index_str| { const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { error.Overflow, error.InvalidCharacter => { - log.warn("invalid palette index spec in OSC 4: {s}", .{index_str}); - // skip any spec + log.warn("invalid color palette index in OSC 4: {s} {}", .{ index_str, err }); + // skip any color spec _ = it.next(); continue; }, @@ -1376,7 +1376,7 @@ pub const Parser = struct { }; } else { const color = RGB.parse(spec_str) catch |err| { - log.warn("invalid color specification {s} in OSC 4: {}", .{ spec_str, err }); + log.warn("invalid color specification in OSC 4: {s} {}", .{ spec_str, err }); continue; }; self.command.color_operation.operations.append( @@ -1418,7 +1418,7 @@ pub const Parser = struct { while (it.next()) |index_str| { const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { error.Overflow, error.InvalidCharacter => { - log.warn("invalid palette index spec in OSC 104: {s}", .{index_str}); + log.warn("invalid color palette index in OSC 104: {s} {}", .{ index_str, err }); continue; }, }; @@ -1854,10 +1854,15 @@ test "OSC: OSC4: get palette color 1" { try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); try testing.expect(cmd.color_operation.operations.items.len == 1); - const op = cmd.color_operation.operations.items[0]; - try testing.expect(op == .report); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, op.report); - try testing.expectEqual(cmd.color_operation.terminator, .st); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.report, + ); + try testing.expectEqual(cmd.color_operation.terminator, .st); + } } test "OSC: OSC4: get palette color 2" { @@ -1878,12 +1883,18 @@ test "OSC: OSC4: get palette color 2" { { const op = cmd.color_operation.operations.items[0]; try testing.expect(op == .report); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, op.report); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.report, + ); } { const op = cmd.color_operation.operations.items[1]; try testing.expect(op == .report); - try testing.expectEqual(Command.ColorKind{ .palette = 2 }, op.report); + try testing.expectEqual( + Command.ColorKind{ .palette = 2 }, + op.report, + ); } try testing.expectEqual(cmd.color_operation.terminator, .st); } @@ -1905,7 +1916,10 @@ test "OSC: OSC4: set palette color 1" { try testing.expect(cmd.color_operation.operations.items.len == 1); const op = cmd.color_operation.operations.items[0]; try testing.expect(op == .set); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.set.kind); + try testing.expectEqual( + Command.ColorKind{ .palette = 17 }, + op.set.kind, + ); try testing.expectEqual( RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, op.set.color, @@ -1930,7 +1944,10 @@ test "OSC: OSC4: set palette color 2" { { const op = cmd.color_operation.operations.items[0]; try testing.expect(op == .set); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.set.kind); + try testing.expectEqual( + Command.ColorKind{ .palette = 17 }, + op.set.kind, + ); try testing.expectEqual( RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, op.set.color, @@ -1939,7 +1956,10 @@ test "OSC: OSC4: set palette color 2" { { const op = cmd.color_operation.operations.items[1]; try testing.expect(op == .set); - try testing.expectEqual(Command.ColorKind{ .palette = 1 }, op.set.kind); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.set.kind, + ); try testing.expectEqual( RGB{ .r = 0x00, .g = 0x11, .b = 0x22 }, op.set.color, @@ -1947,6 +1967,60 @@ test "OSC: OSC4: set palette color 2" { } } +test "OSC: OSC4: get with invalid index" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;1111;?;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.report, + ); + } +} + +test "OSC: OSC4: set with invalid index" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;256;#ffffff;1;#aabbcc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } +} + test "OSC: OSC4: mix get/set palette color" { const testing = std.testing; @@ -1965,7 +2039,10 @@ test "OSC: OSC4: mix get/set palette color" { { const op = cmd.color_operation.operations.items[0]; try testing.expect(op == .set); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.set.kind); + try testing.expectEqual( + Command.ColorKind{ .palette = 17 }, + op.set.kind, + ); try testing.expectEqual( RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, op.set.color, @@ -1996,7 +2073,10 @@ test "OSC: OSC104: reset palette color 1" { { const op = cmd.color_operation.operations.items[0]; try testing.expect(op == .reset); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.reset); + try testing.expectEqual( + Command.ColorKind{ .palette = 17 }, + op.reset, + ); } } @@ -2018,12 +2098,68 @@ test "OSC: OSC104: reset palette color 2" { { const op = cmd.color_operation.operations.items[0]; try testing.expect(op == .reset); - try testing.expectEqual(Command.ColorKind{ .palette = 17 }, op.reset); + try testing.expectEqual( + Command.ColorKind{ .palette = 17 }, + op.reset, + ); } { const op = cmd.color_operation.operations.items[1]; try testing.expect(op == .reset); - try testing.expectEqual(Command.ColorKind{ .palette = 111 }, op.reset); + try testing.expectEqual( + Command.ColorKind{ .palette = 111 }, + op.reset, + ); + } +} + +test "OSC: OSC104: invalid palette index" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "104;ffff;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual( + Command.ColorKind{ .palette = 111 }, + op.reset, + ); + } +} + +test "OSC: OSC104: empty palette index" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "104;;111"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual( + Command.ColorKind{ .palette = 111 }, + op.reset, + ); } } From 5bb74929554e298651298d32f73ef29de14e4294 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 20:44:33 -0500 Subject: [PATCH 092/245] OSC: convert OSC 110, 111, and 112 and add more tests --- src/terminal/Parser.zig | 21 +- src/terminal/osc.zig | 554 +++++++++++++++++++++++++--------- src/terminal/stream.zig | 21 -- src/termio/stream_handler.zig | 197 ------------ 4 files changed, 437 insertions(+), 356 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 14ed6d6df..80772d71f 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -877,7 +877,12 @@ test "osc: change window title (end in esc)" { // https://github.com/darrenstarr/VtNetCore/pull/14 // Saw this on HN, decided to add a test case because why not. test "osc: 112 incomplete sequence" { - var p = init(); + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = init(); + p.osc_parser.alloc = arena.allocator(); + _ = p.next(0x1B); _ = p.next(']'); _ = p.next('1'); @@ -892,8 +897,18 @@ test "osc: 112 incomplete sequence" { try testing.expect(a[2] == null); const cmd = a[0].?.osc_dispatch; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .osc_112); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual( + osc.Command.ColorKind.cursor, + op.reset, + ); + } } } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 778196160..7b9239e43 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -116,37 +116,6 @@ pub const Command = union(enum) { terminator: Terminator = .st, }, - /// OSC 4, OSC 10, and OSC 11 color report. - report_color: struct { - /// OSC 4 requests a palette color, OSC 10 requests the foreground - /// color, OSC 11 the background color. - kind: ColorKind, - - /// We must reply with the same string terminator (ST) as used in the - /// request. - terminator: Terminator = .st, - }, - - /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4) - set_color: struct { - /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11 - /// the background color. - kind: ColorKind, - - /// The color spec as a string - value: []const u8, - }, - - /// Reset a palette color (OSC 104) or the foreground (OSC 110), background - /// (OSC 111), or cursor (OSC 112) color. - reset_color: struct { - kind: ColorKind, - - /// OSC 104 can have parameters indicating which palette colors to - /// reset. - value: []const u8, - }, - /// Kitty color protocol, OSC 21 /// https://sw.kovidgoyal.net/kitty/color-stack/#id1 kitty_color_protocol: kitty.color.OSC, @@ -195,6 +164,9 @@ pub const Command = union(enum) { osc_11 = 11, osc_12 = 12, osc_104 = 104, + osc_110 = 110, + osc_111 = 111, + osc_112 = 112, pub fn format( self: ColorOperationSource, @@ -347,15 +319,6 @@ pub const Parser = struct { @"8", @"9", - // OSC 10 is used to query or set the current foreground color. - query_fg_color, - - // OSC 11 is used to query or set the current background color. - query_bg_color, - - // OSC 12 is used to query or set the current cursor color. - query_cursor_color, - // We're in a semantic prompt OSC command but we aren't sure // what the command is yet, i.e. `133;` semantic_prompt, @@ -372,9 +335,27 @@ pub const Parser = struct { // Get/set color palette index osc_4, + // Get/set foreground color + osc_10, + + // Get/set background color + osc_11, + + // Get/set cursor color + osc_12, + // Reset color palette index osc_104, + // Reset foreground color + osc_110, + + // Reset background color + osc_111, + + // Reset cursor color + osc_112, + // Hyperlinks hyperlink_param_key, hyperlink_param_value, @@ -548,15 +529,26 @@ pub const Parser = struct { }, .@"10" => switch (c) { - ';' => self.state = .query_fg_color, + ';' => osc_10: { + if (self.alloc == null) { + log.warn("OSC 10 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_10; + } + self.state = .osc_10; + self.buf_start = self.buf_idx; + self.complete = true; + }, '4' => self.state = .@"104", else => self.state = .invalid, }, + .osc_10 => {}, + .@"104" => switch (c) { ';' => osc_104: { if (self.alloc == null) { - log.info("OSC 104 requires an allocator, but none was provided", .{}); + log.warn("OSC 104 requires an allocator, but none was provided", .{}); self.state = .invalid; break :osc_104; } @@ -570,30 +562,73 @@ pub const Parser = struct { .osc_104 => {}, .@"11" => switch (c) { - ';' => self.state = .query_bg_color, - '0' => { - self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } }; + ';' => osc_11: { + if (self.alloc == null) { + log.warn("OSC 11 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_11; + } + self.state = .osc_11; + self.buf_start = self.buf_idx; self.complete = true; - self.state = .invalid; }, - '1' => { - self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } }; + '0' => osc_110: { + if (self.alloc == null) { + log.warn("OSC 110 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_110; + } + self.state = .osc_110; + self.buf_start = self.buf_idx; self.complete = true; - self.state = .invalid; }, - '2' => { - self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } }; + '1' => osc_111: { + if (self.alloc == null) { + log.warn("OSC 111 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_111; + } + self.state = .osc_111; + self.buf_start = self.buf_idx; + self.complete = true; + }, + '2' => osc_112: { + if (self.alloc == null) { + log.warn("OSC 112 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_112; + } + self.state = .osc_112; + self.buf_start = self.buf_idx; self.complete = true; - self.state = .invalid; }, else => self.state = .invalid, }, + .osc_11 => {}, + + .osc_110 => {}, + + .osc_111 => {}, + + .osc_112 => {}, + .@"12" => switch (c) { - ';' => self.state = .query_cursor_color, + ';' => osc_12: { + if (self.alloc == null) { + log.warn("OSC 12 requires an allocator, but none was provided", .{}); + self.state = .invalid; + break :osc_12; + } + self.state = .osc_12; + self.buf_start = self.buf_idx; + self.complete = true; + }, else => self.state = .invalid, }, + .osc_12 => {}, + .@"13" => switch (c) { '3' => self.state = .@"133", else => self.state = .invalid, @@ -978,60 +1013,6 @@ pub const Parser = struct { }, }, - .query_fg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .foreground } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .foreground, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_bg_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .background } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .background, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - - .query_cursor_color => switch (c) { - '?' => { - self.command = .{ .report_color = .{ .kind = .cursor } }; - self.complete = true; - self.state = .invalid; - }, - else => { - self.command = .{ .set_color = .{ - .kind = .cursor, - .value = "", - } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.set_color.value }; - self.buf_start = self.buf_idx - 1; - }, - }, - .semantic_prompt => switch (c) { 'A' => { self.state = .semantic_option_start; @@ -1397,6 +1378,115 @@ pub const Parser = struct { } } + fn parseOSC101112(self: *Parser) void { + assert(switch (self.state) { + .osc_10, .osc_11, .osc_12 => true, + else => false, + }); + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = switch (self.state) { + .osc_10 => .osc_10, + .osc_11 => .osc_11, + .osc_12 => .osc_12, + else => unreachable, + }, + .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 1) catch |err| { + log.warn("unable to allocate memory for OSC 10/11/12 parsing: {}", .{err}); + self.state = .invalid; + return; + }, + }, + }; + const str = self.buf[self.buf_start..self.buf_idx]; + var it = std.mem.splitScalar(u8, str, ';'); + const color_str = it.next() orelse { + log.warn("OSC 10/11/12 requires an argument", .{}); + self.state = .invalid; + return; + }; + if (std.mem.eql(u8, color_str, "?")) { + self.command.color_operation.operations.append( + alloc, + .{ + .report = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + }, + ) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + } else { + const color = RGB.parse(color_str) catch |err| { + log.warn("invalid color specification in OSC 10/11/12: {s} {}", .{ color_str, err }); + return; + }; + self.command.color_operation.operations.append( + alloc, + .{ + .set = .{ + .kind = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + .color = color, + }, + }, + ) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + } + } + + fn parseOSC110111112(self: *Parser) void { + assert(switch (self.state) { + .osc_110, .osc_111, .osc_112 => true, + else => false, + }); + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = switch (self.state) { + .osc_110 => .osc_110, + .osc_111 => .osc_111, + .osc_112 => .osc_112, + else => unreachable, + }, + .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 1) catch |err| { + log.warn("unable to allocate memory for OSC 110/111/112 parsing: {}", .{err}); + self.state = .invalid; + return; + }, + }, + }; + self.command.color_operation.operations.append( + alloc, + .{ + .reset = switch (self.state) { + .osc_110 => .foreground, + .osc_111 => .background, + .osc_112 => .cursor, + else => unreachable, + }, + }, + ) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + } + fn parseOSC104(self: *Parser) void { assert(self.state == .osc_104); @@ -1457,13 +1547,22 @@ pub const Parser = struct { .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), - .osc_4 => self.parseOSC4(), - .osc_104 => self.parseOSC104(), + .osc_4, + => self.parseOSC4(), + .osc_10, + .osc_11, + .osc_12, + => self.parseOSC101112(), + .osc_104, + => self.parseOSC104(), + .osc_110, + .osc_111, + .osc_112, + => self.parseOSC110111112(), else => {}, } switch (self.command) { - .report_color => |*c| c.terminator = .init(terminator_ch), .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch), .color_operation => |*c| c.terminator = .init(terminator_ch), else => {}, @@ -1674,17 +1773,86 @@ test "OSC: end_of_input" { try testing.expect(cmd == .end_of_input); } -test "OSC: reset cursor color" { +test "OSC: OSC110: reset cursor color" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); - const input = "112"; + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "110"; for (input) |ch| p.next(ch); const cmd = p.end(null).?; - try testing.expect(cmd == .reset_color); - try testing.expectEqual(cmd.reset_color.kind, .cursor); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .osc_110); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual( + Command.ColorKind.foreground, + op.reset, + ); + } +} + +test "OSC: OSC111: reset cursor color" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "111"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .osc_111); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual( + Command.ColorKind.background, + op.reset, + ); + } +} + +test "OSC: OSC112: reset cursor color" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "112"; + for (input) |ch| { + log.warn("feeding {c} {s}", .{ ch, @tagName(p.state) }); + p.next(ch); + } + log.warn("finish: {s}", .{@tagName(p.state)}); + + const cmd = p.end(null).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .osc_112); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .reset); + try testing.expectEqual( + Command.ColorKind.cursor, + op.reset, + ); + } } test "OSC: get/set clipboard" { @@ -1781,62 +1949,178 @@ test "OSC: longer than buffer" { try testing.expect(p.complete == false); } -test "OSC: report default foreground color" { +test "OSC: OSC10: report default foreground color" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; const input = "10;?"; for (input) |ch| p.next(ch); // This corresponds to ST = ESC followed by \ const cmd = p.end('\x1b').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .foreground); - try testing.expectEqual(cmd.report_color.terminator, .st); + + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .osc_10); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind.foreground, + op.report, + ); + } } -test "OSC: set foreground color" { +test "OSC: OSC10: set foreground color" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; const input = "10;rgbi:0.0/0.5/1.0"; for (input) |ch| p.next(ch); const cmd = p.end('\x07').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .foreground); - try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0"); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .osc_10); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual( + Command.ColorKind.foreground, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0x00, .g = 0x7f, .b = 0xff }, + op.set.color, + ); + } } -test "OSC: report default background color" { +test "OSC: OSC11: report default background color" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; const input = "11;?"; for (input) |ch| p.next(ch); // This corresponds to ST = BEL character const cmd = p.end('\x07').?; - try testing.expect(cmd == .report_color); - try testing.expectEqual(cmd.report_color.kind, .background); - try testing.expectEqual(cmd.report_color.terminator, .bel); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .osc_11); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind.background, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .bel); } -test "OSC: set background color" { +test "OSC: OSC11: set background color" { const testing = std.testing; - var p: Parser = .{}; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; const input = "11;rgb:f/ff/ffff"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .set_color); - try testing.expectEqual(cmd.set_color.kind, .background); - try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff"); + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .osc_11); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual( + Command.ColorKind.background, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xff, .g = 0xff, .b = 0xff }, + op.set.color, + ); + } +} + +test "OSC: OSC12: report background color" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "12;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = BEL character + const cmd = p.end('\x07').?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .osc_12); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind.cursor, + op.report, + ); + } + try testing.expectEqual(cmd.color_operation.terminator, .bel); +} + +test "OSC: OSC12: set background color" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "12;rgb:f/ff/ffff"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(cmd.color_operation.source == .osc_12); + try testing.expect(cmd.color_operation.operations.items.len == 1); + { + const op = cmd.color_operation.operations.items[0]; + try testing.expect(op == .set); + try testing.expectEqual( + Command.ColorKind.cursor, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xff, .g = 0xff, .b = 0xff }, + op.set.color, + ); + } } test "OSC: OSC4: get palette color 1" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 2a1ae80c9..08ce23098 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1562,27 +1562,6 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .report_color => |v| { - if (@hasDecl(T, "reportColor")) { - try self.handler.reportColor(v.kind, v.terminator); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .set_color => |v| { - if (@hasDecl(T, "setColor")) { - try self.handler.setColor(v.kind, v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - - .reset_color => |v| { - if (@hasDecl(T, "resetColor")) { - try self.handler.resetColor(v.kind, v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); - }, - .kitty_color_protocol => |v| { if (@hasDecl(T, "sendKittyColorReport")) { try self.handler.sendKittyColorReport(v); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 57a1eeacf..396aae01f 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1370,203 +1370,6 @@ pub const StreamHandler = struct { } } - /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color, - /// default foreground color, and background color respectively. - pub fn reportColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - terminator: terminal.osc.Terminator, - ) !void { - if (self.osc_color_report_format == .none) return; - - const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse - self.default_cursor_color orelse - self.foreground_color orelse - self.default_foreground_color, - }; - - var msg: termio.Message = .{ .write_small = .{} }; - const resp = switch (self.osc_color_report_format) { - .@"16-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - i, - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", - .{ - kind.code(), - @as(u16, color.r) * 257, - @as(u16, color.g) * 257, - @as(u16, color.b) * 257, - terminator.string(), - }, - ), - }, - - .@"8-bit" => switch (kind) { - .palette => |i| try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - i, - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - else => try std.fmt.bufPrint( - &msg.write_small.data, - "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", - .{ - kind.code(), - @as(u16, color.r), - @as(u16, color.g), - @as(u16, color.b), - terminator.string(), - }, - ), - }, - .none => unreachable, // early return above - }; - msg.write_small.len = @intCast(resp.len); - self.messageWriter(msg); - } - - pub fn setColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - const color = try terminal.color.RGB.parse(value); - - switch (kind) { - .palette => |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = color; - self.terminal.color_palette.mask.set(i); - }, - .foreground => { - self.foreground_color = color; - _ = self.renderer_mailbox.push(.{ - .foreground_color = color, - }, .{ .forever = {} }); - }, - .background => { - self.background_color = color; - _ = self.renderer_mailbox.push(.{ - .background_color = color, - }, .{ .forever = {} }); - }, - .cursor => { - self.cursor_color = color; - _ = self.renderer_mailbox.push(.{ - .cursor_color = color, - }, .{ .forever = {} }); - }, - } - - // Notify the surface of the color change - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = kind, - .color = color, - } }); - } - - pub fn resetColor( - self: *StreamHandler, - kind: terminal.osc.Command.ColorKind, - value: []const u8, - ) !void { - switch (kind) { - .palette => { - const mask = &self.terminal.color_palette.mask; - if (value.len == 0) { - // Find all bit positions in the mask which are set and - // reset those indices to the default palette - var it = mask.iterator(.{}); - while (it.next()) |i| { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], - } }); - } - } else { - var it = std.mem.tokenizeScalar(u8, value, ';'); - while (it.next()) |param| { - // Skip invalid parameters - const i = std.fmt.parseUnsigned(u8, param, 10) catch continue; - if (mask.isSet(i)) { - self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], - } }); - } - } - } - }, - .foreground => { - self.foreground_color = null; - _ = self.renderer_mailbox.push(.{ - .foreground_color = self.foreground_color, - }, .{ .forever = {} }); - - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .foreground, - .color = self.default_foreground_color, - } }); - }, - .background => { - self.background_color = null; - _ = self.renderer_mailbox.push(.{ - .background_color = self.background_color, - }, .{ .forever = {} }); - - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .background, - .color = self.default_background_color, - } }); - }, - .cursor => { - self.cursor_color = null; - - _ = self.renderer_mailbox.push(.{ - .cursor_color = self.cursor_color, - }, .{ .forever = {} }); - - if (self.default_cursor_color) |color| { - self.surfaceMessageWriter(.{ .color_change = .{ - .kind = .cursor, - .color = color, - } }); - } - }, - } - } - pub fn showDesktopNotification( self: *StreamHandler, title: []const u8, From 1288296fdc718308049b124f031a3899973e59ab Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 22:04:26 -0500 Subject: [PATCH 093/245] OSC: add a datastructure to prevent some (most?) allocations --- src/datastruct/list.zig | 100 ++++++++++++++++ src/terminal/Parser.zig | 6 +- src/terminal/osc.zig | 216 +++++++++++++++++++++------------- src/terminal/stream.zig | 2 +- src/termio/stream_handler.zig | 6 +- 5 files changed, 246 insertions(+), 84 deletions(-) create mode 100644 src/datastruct/list.zig diff --git a/src/datastruct/list.zig b/src/datastruct/list.zig new file mode 100644 index 000000000..e5f8bb483 --- /dev/null +++ b/src/datastruct/list.zig @@ -0,0 +1,100 @@ +const std = @import("std"); + +const assert = std.debug.assert; + +/// Datastructure to manage a (usually) small list of items. To prevent allocations +/// on the heap, statically allocate a small array that gets used to store items. Once +/// that small array is full then memory will be dynamically allocated on the heap +/// to store items. +pub fn ArrayListStaticUnmanaged(comptime static_size: usize, comptime T: type) type { + return struct { + count: usize, + static: [static_size]T, + dynamic: std.ArrayListUnmanaged(T), + + const Self = @This(); + + pub const empty: Self = .{ + .count = 0, + .static = undefined, + .dynamic = .empty, + }; + + pub fn deinit(self: *Self, alloc: std.mem.Allocator) void { + self.dynamic.deinit(alloc); + } + + pub fn append(self: *Self, alloc: std.mem.Allocator, item: T) !void { + if (self.count < static_size) { + self.static[self.count] = item; + self.count += 1; + assert(self.count <= static_size); + return; + } + try self.dynamic.append(alloc, item); + self.count += 1; + assert(self.count == static_size + self.dynamic.items.len); + } + + pub const Iterator = struct { + context: *const Self, + index: usize, + + pub fn next(self: *Iterator) ?T { + if (self.index >= self.context.count) return null; + + if (self.index < static_size) { + defer self.index += 1; + return self.context.static[self.index]; + } + + assert(self.index - static_size < self.context.dynamic.items.len); + + defer self.index += 1; + return self.context.dynamic.items[self.index - static_size]; + } + }; + + pub fn iterator(self: *const Self) Iterator { + return .{ + .context = self, + .index = 0, + }; + } + }; +} + +test "ArrayListStaticUnmanged: 1" { + const alloc = std.testing.allocator; + + var l: ArrayListStaticUnmanaged(1, usize) = .empty; + defer l.deinit(alloc); + + try l.append(alloc, 1); + + try std.testing.expectEqual(1, l.count); + try std.testing.expectEqual(1, l.static[0]); + try std.testing.expectEqual(0, l.dynamic.items.len); + + var it = l.iterator(); + try std.testing.expectEqual(1, it.next().?); + try std.testing.expectEqual(null, it.next()); +} + +test "ArrayListStaticUnmanged: 2" { + const alloc = std.testing.allocator; + + var l: ArrayListStaticUnmanaged(1, usize) = .empty; + defer l.deinit(alloc); + + try l.append(alloc, 1); + try l.append(alloc, 2); + + try std.testing.expectEqual(2, l.count); + try std.testing.expectEqual(1, l.static[0]); + try std.testing.expectEqual(1, l.dynamic.items.len); + var it = l.iterator(); + try std.testing.expectEqual(1, it.next().?); + try std.testing.expectEqual(2, it.next().?); + try std.testing.expectEqual(null, it.next()); +} diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 80772d71f..0f035f7fb 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -900,15 +900,17 @@ test "osc: 112 incomplete sequence" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_112); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( osc.Command.ColorKind.cursor, op.reset, ); } + try std.testing.expect(it.next() == null); } } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 7b9239e43..0f5ecf724 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -13,6 +13,8 @@ const Allocator = mem.Allocator; const RGB = @import("color.zig").RGB; const kitty = @import("kitty.zig"); +const ArrayListStaticUnmanaged = @import("../datastruct/list.zig").ArrayListStaticUnmanaged; + const log = std.log.scoped(.osc); pub const Command = union(enum) { @@ -112,7 +114,7 @@ pub const Command = union(enum) { /// OSC color operations color_operation: struct { source: ColorOperationSource, - operations: std.ArrayListUnmanaged(ColorOperation) = .empty, + operations: ColorOperationList = .empty, terminator: Terminator = .st, }, @@ -187,6 +189,8 @@ pub const Command = union(enum) { report: ColorKind, }; + pub const ColorOperationList = ArrayListStaticUnmanaged(4, ColorOperation); + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -1325,11 +1329,7 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ .source = .osc_4, - .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 8) catch |err| { - log.warn("unable to allocate memory for OSC 4 parsing: {}", .{err}); - self.state = .invalid; - return; - }, + .operations = .empty, }, }; @@ -1394,11 +1394,7 @@ pub const Parser = struct { .osc_12 => .osc_12, else => unreachable, }, - .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 1) catch |err| { - log.warn("unable to allocate memory for OSC 10/11/12 parsing: {}", .{err}); - self.state = .invalid; - return; - }, + .operations = .empty, }, }; const str = self.buf[self.buf_start..self.buf_idx]; @@ -1464,11 +1460,7 @@ pub const Parser = struct { .osc_112 => .osc_112, else => unreachable, }, - .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 1) catch |err| { - log.warn("unable to allocate memory for OSC 110/111/112 parsing: {}", .{err}); - self.state = .invalid; - return; - }, + .operations = .empty, }, }; self.command.color_operation.operations.append( @@ -1495,11 +1487,7 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ .source = .osc_104, - .operations = std.ArrayListUnmanaged(Command.ColorOperation).initCapacity(alloc, 8) catch |err| { - log.warn("unable to allocate memory for OSC 104 parsing: {}", .{err}); - self.state = .invalid; - return; - }, + .operations = .empty, }, }; @@ -1788,15 +1776,17 @@ test "OSC: OSC110: reset cursor color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_110); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind.foreground, op.reset, ); } + try testing.expect(it.next() == null); } test "OSC: OSC111: reset cursor color" { @@ -1814,15 +1804,17 @@ test "OSC: OSC111: reset cursor color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_111); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind.background, op.reset, ); } + try testing.expect(it.next() == null); } test "OSC: OSC112: reset cursor color" { @@ -1834,25 +1826,55 @@ test "OSC: OSC112: reset cursor color" { var p: Parser = .{ .alloc = arena.allocator() }; const input = "112"; - for (input) |ch| { - log.warn("feeding {c} {s}", .{ ch, @tagName(p.state) }); - p.next(ch); - } - log.warn("finish: {s}", .{@tagName(p.state)}); + for (input) |ch| p.next(ch); const cmd = p.end(null).?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_112); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind.cursor, op.reset, ); } + try testing.expect(it.next() == null); +} + +test "OSC: OSC112: reset cursor color with semicolon" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "112;"; + for (input) |ch| { + log.warn("feeding {c} {s}", .{ ch, @tagName(p.state) }); + p.next(ch); + } + log.warn("finish: {s}", .{@tagName(p.state)}); + + const cmd = p.end(0x07).?; + try testing.expect(cmd == .color_operation); + try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(cmd.color_operation.source == .osc_112); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); + { + const op = it.next().?; + try testing.expect(op == .reset); + try testing.expectEqual( + Command.ColorKind.cursor, + op.reset, + ); + } + try testing.expect(it.next() == null); } test "OSC: get/set clipboard" { @@ -1966,15 +1988,17 @@ test "OSC: OSC10: report default foreground color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_10); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind.foreground, op.report, ); } + try testing.expect(it.next() == null); } test "OSC: OSC10: set foreground color" { @@ -1992,9 +2016,10 @@ test "OSC: OSC10: set foreground color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_10); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind.foreground, @@ -2005,6 +2030,7 @@ test "OSC: OSC10: set foreground color" { op.set.color, ); } + try testing.expect(it.next() == null); } test "OSC: OSC11: report default background color" { @@ -2023,9 +2049,10 @@ test "OSC: OSC11: report default background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_11); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind.background, @@ -2033,6 +2060,7 @@ test "OSC: OSC11: report default background color" { ); } try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(it.next() == null); } test "OSC: OSC11: set background color" { @@ -2050,9 +2078,10 @@ test "OSC: OSC11: set background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_11); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind.background, @@ -2063,6 +2092,7 @@ test "OSC: OSC11: set background color" { op.set.color, ); } + try testing.expect(it.next() == null); } test "OSC: OSC12: report background color" { @@ -2081,9 +2111,10 @@ test "OSC: OSC12: report background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_12); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind.cursor, @@ -2091,6 +2122,7 @@ test "OSC: OSC12: report background color" { ); } try testing.expectEqual(cmd.color_operation.terminator, .bel); + try testing.expect(it.next() == null); } test "OSC: OSC12: set background color" { @@ -2108,9 +2140,10 @@ test "OSC: OSC12: set background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_12); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind.cursor, @@ -2121,6 +2154,7 @@ test "OSC: OSC12: set background color" { op.set.color, ); } + try testing.expect(it.next() == null); } test "OSC: OSC4: get palette color 1" { @@ -2137,9 +2171,10 @@ test "OSC: OSC4: get palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, @@ -2147,6 +2182,7 @@ test "OSC: OSC4: get palette color 1" { ); try testing.expectEqual(cmd.color_operation.terminator, .st); } + try testing.expect(it.next() == null); } test "OSC: OSC4: get palette color 2" { @@ -2163,9 +2199,10 @@ test "OSC: OSC4: get palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 2); + try testing.expect(cmd.color_operation.operations.count == 2); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, @@ -2173,7 +2210,7 @@ test "OSC: OSC4: get palette color 2" { ); } { - const op = cmd.color_operation.operations.items[1]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind{ .palette = 2 }, @@ -2181,6 +2218,7 @@ test "OSC: OSC4: get palette color 2" { ); } try testing.expectEqual(cmd.color_operation.terminator, .st); + try testing.expect(it.next() == null); } test "OSC: OSC4: set palette color 1" { @@ -2197,17 +2235,21 @@ test "OSC: OSC4: set palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 1); - const op = cmd.color_operation.operations.items[0]; - try testing.expect(op == .set); - try testing.expectEqual( - Command.ColorKind{ .palette = 17 }, - op.set.kind, - ); - try testing.expectEqual( - RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, - op.set.color, - ); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); + { + const op = it.next().?; + try testing.expect(op == .set); + try testing.expectEqual( + Command.ColorKind{ .palette = 17 }, + op.set.kind, + ); + try testing.expectEqual( + RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc }, + op.set.color, + ); + } + try testing.expect(it.next() == null); } test "OSC: OSC4: set palette color 2" { @@ -2224,9 +2266,10 @@ test "OSC: OSC4: set palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 2); + try testing.expect(cmd.color_operation.operations.count == 2); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, @@ -2238,7 +2281,7 @@ test "OSC: OSC4: set palette color 2" { ); } { - const op = cmd.color_operation.operations.items[1]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, @@ -2249,6 +2292,7 @@ test "OSC: OSC4: set palette color 2" { op.set.color, ); } + try testing.expect(it.next() == null); } test "OSC: OSC4: get with invalid index" { @@ -2265,15 +2309,17 @@ test "OSC: OSC4: get with invalid index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.report, ); } + try testing.expect(it.next() == null); } test "OSC: OSC4: set with invalid index" { @@ -2290,9 +2336,10 @@ test "OSC: OSC4: set with invalid index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, @@ -2303,6 +2350,7 @@ test "OSC: OSC4: set with invalid index" { op.set.color, ); } + try testing.expect(it.next() == null); } test "OSC: OSC4: mix get/set palette color" { @@ -2319,9 +2367,10 @@ test "OSC: OSC4: mix get/set palette color" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.items.len == 2); + try testing.expect(cmd.color_operation.operations.count == 2); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .set); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, @@ -2333,10 +2382,11 @@ test "OSC: OSC4: mix get/set palette color" { ); } { - const op = cmd.color_operation.operations.items[1]; + const op = it.next().?; try testing.expect(op == .report); try testing.expectEqual(Command.ColorKind{ .palette = 254 }, op.report); } + try testing.expect(it.next() == null); } test "OSC: OSC104: reset palette color 1" { @@ -2353,15 +2403,17 @@ test "OSC: OSC104: reset palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, op.reset, ); } + try testing.expect(it.next() == null); } test "OSC: OSC104: reset palette color 2" { @@ -2378,9 +2430,10 @@ test "OSC: OSC104: reset palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.items.len == 2); + try testing.expect(cmd.color_operation.operations.count == 2); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, @@ -2388,13 +2441,14 @@ test "OSC: OSC104: reset palette color 2" { ); } { - const op = cmd.color_operation.operations.items[1]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 111 }, op.reset, ); } + try testing.expect(it.next() == null); } test "OSC: OSC104: invalid palette index" { @@ -2411,15 +2465,17 @@ test "OSC: OSC104: invalid palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 111 }, op.reset, ); } + try testing.expect(it.next() == null); } test "OSC: OSC104: empty palette index" { @@ -2436,15 +2492,17 @@ test "OSC: OSC104: empty palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.items.len == 1); + try testing.expect(cmd.color_operation.operations.count == 1); + var it = cmd.color_operation.operations.iterator(); { - const op = cmd.color_operation.operations.items[0]; + const op = it.next().?; try testing.expect(op == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 111 }, op.reset, ); } + try std.testing.expect(it.next() == null); } test "OSC: conemu sleep" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 08ce23098..fd30720b3 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1557,7 +1557,7 @@ pub fn Stream(comptime Handler: type) type { .color_operation => |v| { if (@hasDecl(T, "handleColorOperation")) { - try self.handler.handleColorOperation(v.source, v.operations.items, v.terminator); + try self.handler.handleColorOperation(v.source, &v.operations, v.terminator); return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 396aae01f..fd450f229 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1198,7 +1198,7 @@ pub const StreamHandler = struct { pub fn handleColorOperation( self: *StreamHandler, source: terminal.osc.Command.ColorOperationSource, - operations: []terminal.osc.Command.ColorOperation, + operations: *const terminal.osc.Command.ColorOperationList, terminator: terminal.osc.Terminator, ) !void { var buffer: [1024]u8 = undefined; @@ -1212,7 +1212,9 @@ pub const StreamHandler = struct { try writer.print("\x1b]{}", .{source}); - for (operations) |op| { + var it = operations.iterator(); + + while (it.next()) |op| { switch (op) { .set => |set| { switch (set.kind) { From 04e8e521719e040e36e0814fd6944220a7e1cbd5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 22:26:01 -0500 Subject: [PATCH 094/245] OSC: reflow comment --- src/datastruct/list.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/datastruct/list.zig b/src/datastruct/list.zig index e5f8bb483..7e9b78761 100644 --- a/src/datastruct/list.zig +++ b/src/datastruct/list.zig @@ -2,10 +2,10 @@ const std = @import("std"); const assert = std.debug.assert; -/// Datastructure to manage a (usually) small list of items. To prevent allocations -/// on the heap, statically allocate a small array that gets used to store items. Once -/// that small array is full then memory will be dynamically allocated on the heap -/// to store items. +/// Datastructure to manage a (usually) small list of items. To prevent +/// allocations on the heap, statically allocate a small array that gets used to +/// store items. Once that small array is full then memory will be dynamically +/// allocated on the heap to store items. pub fn ArrayListStaticUnmanaged(comptime static_size: usize, comptime T: type) type { return struct { count: usize, From 1d9d253e4d2651f5c075082e94bf0eecbcd706e8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 22:26:21 -0500 Subject: [PATCH 095/245] OSC: fix bug with buffer disappearing --- src/termio/stream_handler.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fd450f229..51dec5347 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1367,7 +1367,7 @@ pub const StreamHandler = struct { } if (report) { try writer.writeAll(terminator.string()); - const msg: termio.Message = .{ .write_stable = response.items }; + const msg = try termio.Message.writeReq(self.alloc, response.items); self.messageWriter(msg); } } From 397a8b13e06b74ba5a9a73f4bcbecf769ba3f485 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 May 2025 22:57:18 -0500 Subject: [PATCH 096/245] OSC: more tests --- src/terminal/osc.zig | 205 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 0f5ecf724..a8906b74f 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -2295,7 +2295,7 @@ test "OSC: OSC4: set palette color 2" { try testing.expect(it.next() == null); } -test "OSC: OSC4: get with invalid index" { +test "OSC: OSC4: get with invalid index 1" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -2322,6 +2322,209 @@ test "OSC: OSC4: get with invalid index" { try testing.expect(it.next() == null); } +test "OSC: OSC4: get with invalid index 2" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;5;?;1111;?;1;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.count == 2); + var it = cmd.color_operation.operations.iterator(); + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 5 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +// Inspired by Microsoft Edit +test "OSC: OSC4: multiple get 8a" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.count == 8); + var it = cmd.color_operation.operations.iterator(); + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 0 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 1 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 2 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 3 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 4 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 5 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 6 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 7 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +// Inspired by Microsoft Edit +test "OSC: OSC4: multiple get 8b" { + const testing = std.testing; + + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var p: Parser = .{ .alloc = arena.allocator() }; + + const input = "4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.operations.count == 8); + var it = cmd.color_operation.operations.iterator(); + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 8 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 9 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 10 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 11 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 12 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 13 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 14 }, + op.report, + ); + } + { + const op = it.next().?; + try testing.expect(op == .report); + try testing.expectEqual( + Command.ColorKind{ .palette = 15 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + test "OSC: OSC4: set with invalid index" { const testing = std.testing; From 479fa9f809b079e638941afe8394564e2bffbd8f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 08:04:33 -0500 Subject: [PATCH 097/245] OSC: use std.SegmentedList instead of custom data structure --- src/datastruct/list.zig | 100 ----------- src/terminal/Parser.zig | 6 +- src/terminal/osc.zig | 308 ++++++++++++++++------------------ src/termio/stream_handler.zig | 4 +- 4 files changed, 154 insertions(+), 264 deletions(-) delete mode 100644 src/datastruct/list.zig diff --git a/src/datastruct/list.zig b/src/datastruct/list.zig deleted file mode 100644 index 7e9b78761..000000000 --- a/src/datastruct/list.zig +++ /dev/null @@ -1,100 +0,0 @@ -const std = @import("std"); - -const assert = std.debug.assert; - -/// Datastructure to manage a (usually) small list of items. To prevent -/// allocations on the heap, statically allocate a small array that gets used to -/// store items. Once that small array is full then memory will be dynamically -/// allocated on the heap to store items. -pub fn ArrayListStaticUnmanaged(comptime static_size: usize, comptime T: type) type { - return struct { - count: usize, - static: [static_size]T, - dynamic: std.ArrayListUnmanaged(T), - - const Self = @This(); - - pub const empty: Self = .{ - .count = 0, - .static = undefined, - .dynamic = .empty, - }; - - pub fn deinit(self: *Self, alloc: std.mem.Allocator) void { - self.dynamic.deinit(alloc); - } - - pub fn append(self: *Self, alloc: std.mem.Allocator, item: T) !void { - if (self.count < static_size) { - self.static[self.count] = item; - self.count += 1; - assert(self.count <= static_size); - return; - } - try self.dynamic.append(alloc, item); - self.count += 1; - assert(self.count == static_size + self.dynamic.items.len); - } - - pub const Iterator = struct { - context: *const Self, - index: usize, - - pub fn next(self: *Iterator) ?T { - if (self.index >= self.context.count) return null; - - if (self.index < static_size) { - defer self.index += 1; - return self.context.static[self.index]; - } - - assert(self.index - static_size < self.context.dynamic.items.len); - - defer self.index += 1; - return self.context.dynamic.items[self.index - static_size]; - } - }; - - pub fn iterator(self: *const Self) Iterator { - return .{ - .context = self, - .index = 0, - }; - } - }; -} - -test "ArrayListStaticUnmanged: 1" { - const alloc = std.testing.allocator; - - var l: ArrayListStaticUnmanaged(1, usize) = .empty; - defer l.deinit(alloc); - - try l.append(alloc, 1); - - try std.testing.expectEqual(1, l.count); - try std.testing.expectEqual(1, l.static[0]); - try std.testing.expectEqual(0, l.dynamic.items.len); - - var it = l.iterator(); - try std.testing.expectEqual(1, it.next().?); - try std.testing.expectEqual(null, it.next()); -} - -test "ArrayListStaticUnmanged: 2" { - const alloc = std.testing.allocator; - - var l: ArrayListStaticUnmanaged(1, usize) = .empty; - defer l.deinit(alloc); - - try l.append(alloc, 1); - try l.append(alloc, 2); - - try std.testing.expectEqual(2, l.count); - try std.testing.expectEqual(1, l.static[0]); - try std.testing.expectEqual(1, l.dynamic.items.len); - var it = l.iterator(); - try std.testing.expectEqual(1, it.next().?); - try std.testing.expectEqual(2, it.next().?); - try std.testing.expectEqual(null, it.next()); -} diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 0f035f7fb..df18fbc7a 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -900,11 +900,11 @@ test "osc: 112 incomplete sequence" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_112); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( osc.Command.ColorKind.cursor, op.reset, diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index a8906b74f..449713ff2 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -13,8 +13,6 @@ const Allocator = mem.Allocator; const RGB = @import("color.zig").RGB; const kitty = @import("kitty.zig"); -const ArrayListStaticUnmanaged = @import("../datastruct/list.zig").ArrayListStaticUnmanaged; - const log = std.log.scoped(.osc); pub const Command = union(enum) { @@ -111,10 +109,18 @@ pub const Command = union(enum) { value: []const u8, }, - /// OSC color operations + /// OSC color operations to set, reset, or report color settings. Some OSCs + /// allow multiple operations to be specified in a single OSC so we need a + /// list-like datastructure to manage them. We use std.SegmentedList because + /// it minimizes the number of allocations and copies because a large + /// majority of the time there will be only one operation per OSC. + /// + /// Currently, these OSCs are handled by `color_operation`: + /// + /// 4, 10, 11, 12, 104, 110, 111, 112 color_operation: struct { source: ColorOperationSource, - operations: ColorOperationList = .empty, + operations: ColorOperationList = .{}, terminator: Terminator = .st, }, @@ -189,7 +195,7 @@ pub const Command = union(enum) { report: ColorKind, }; - pub const ColorOperationList = ArrayListStaticUnmanaged(4, ColorOperation); + pub const ColorOperationList = std.SegmentedList(ColorOperation, 4); pub const ColorKind = union(enum) { palette: u8, @@ -1329,7 +1335,6 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ .source = .osc_4, - .operations = .empty, }, }; @@ -1346,34 +1351,30 @@ pub const Parser = struct { }; const spec_str = it.next() orelse continue; if (std.mem.eql(u8, spec_str, "?")) { - self.command.color_operation.operations.append( - alloc, - .{ - .report = .{ .palette = index }, - }, - ) catch |err| { + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; + op.* = .{ + .report = .{ .palette = index }, + }; } else { const color = RGB.parse(spec_str) catch |err| { log.warn("invalid color specification in OSC 4: {s} {}", .{ spec_str, err }); continue; }; - self.command.color_operation.operations.append( - alloc, - .{ - .set = .{ - .kind = .{ - .palette = index, - }, - .color = color, - }, - }, - ) catch |err| { + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; + op.* = .{ + .set = .{ + .kind = .{ + .palette = index, + }, + .color = color, + }, + }; } } } @@ -1394,7 +1395,6 @@ pub const Parser = struct { .osc_12 => .osc_12, else => unreachable, }, - .operations = .empty, }, }; const str = self.buf[self.buf_start..self.buf_idx]; @@ -1405,42 +1405,38 @@ pub const Parser = struct { return; }; if (std.mem.eql(u8, color_str, "?")) { - self.command.color_operation.operations.append( - alloc, - .{ - .report = switch (self.state) { - .osc_10 => .foreground, - .osc_11 => .background, - .osc_12 => .cursor, - else => unreachable, - }, - }, - ) catch |err| { + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; + op.* = .{ + .report = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + }; } else { const color = RGB.parse(color_str) catch |err| { log.warn("invalid color specification in OSC 10/11/12: {s} {}", .{ color_str, err }); return; }; - self.command.color_operation.operations.append( - alloc, - .{ - .set = .{ - .kind = switch (self.state) { - .osc_10 => .foreground, - .osc_11 => .background, - .osc_12 => .cursor, - else => unreachable, - }, - .color = color, - }, - }, - ) catch |err| { + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; + op.* = .{ + .set = .{ + .kind = switch (self.state) { + .osc_10 => .foreground, + .osc_11 => .background, + .osc_12 => .cursor, + else => unreachable, + }, + .color = color, + }, + }; } } @@ -1460,23 +1456,20 @@ pub const Parser = struct { .osc_112 => .osc_112, else => unreachable, }, - .operations = .empty, }, }; - self.command.color_operation.operations.append( - alloc, - .{ - .reset = switch (self.state) { - .osc_110 => .foreground, - .osc_111 => .background, - .osc_112 => .cursor, - else => unreachable, - }, - }, - ) catch |err| { + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; + op.* = .{ + .reset = switch (self.state) { + .osc_110 => .foreground, + .osc_111 => .background, + .osc_112 => .cursor, + else => unreachable, + }, + }; } fn parseOSC104(self: *Parser) void { @@ -1487,7 +1480,6 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ .source = .osc_104, - .operations = .empty, }, }; @@ -1500,15 +1492,13 @@ pub const Parser = struct { continue; }, }; - self.command.color_operation.operations.append( - alloc, - .{ - .reset = .{ .palette = index }, - }, - ) catch |err| { + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; + op.* = .{ + .reset = .{ .palette = index }, + }; } } @@ -1776,11 +1766,11 @@ test "OSC: OSC110: reset cursor color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_110); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind.foreground, op.reset, @@ -1804,11 +1794,11 @@ test "OSC: OSC111: reset cursor color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_111); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind.background, op.reset, @@ -1832,11 +1822,11 @@ test "OSC: OSC112: reset cursor color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_112); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind.cursor, op.reset, @@ -1864,11 +1854,11 @@ test "OSC: OSC112: reset cursor color with semicolon" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_112); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind.cursor, op.reset, @@ -1988,11 +1978,11 @@ test "OSC: OSC10: report default foreground color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_10); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind.foreground, op.report, @@ -2016,11 +2006,11 @@ test "OSC: OSC10: set foreground color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_10); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind.foreground, op.set.kind, @@ -2049,11 +2039,11 @@ test "OSC: OSC11: report default background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_11); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind.background, op.report, @@ -2078,11 +2068,11 @@ test "OSC: OSC11: set background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_11); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind.background, op.set.kind, @@ -2111,11 +2101,11 @@ test "OSC: OSC12: report background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); try testing.expect(cmd.color_operation.source == .osc_12); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind.cursor, op.report, @@ -2140,11 +2130,11 @@ test "OSC: OSC12: set background color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); try testing.expect(cmd.color_operation.source == .osc_12); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind.cursor, op.set.kind, @@ -2171,11 +2161,11 @@ test "OSC: OSC4: get palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.report, @@ -2199,11 +2189,11 @@ test "OSC: OSC4: get palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 2); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.report, @@ -2211,7 +2201,7 @@ test "OSC: OSC4: get palette color 2" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 2 }, op.report, @@ -2235,11 +2225,11 @@ test "OSC: OSC4: set palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, op.set.kind, @@ -2266,11 +2256,11 @@ test "OSC: OSC4: set palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 2); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, op.set.kind, @@ -2282,7 +2272,7 @@ test "OSC: OSC4: set palette color 2" { } { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.set.kind, @@ -2309,11 +2299,11 @@ test "OSC: OSC4: get with invalid index 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.report, @@ -2336,11 +2326,11 @@ test "OSC: OSC4: get with invalid index 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 2); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 5 }, op.report, @@ -2348,7 +2338,7 @@ test "OSC: OSC4: get with invalid index 2" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.report, @@ -2372,11 +2362,11 @@ test "OSC: OSC4: multiple get 8a" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 8); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 8); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 0 }, op.report, @@ -2384,7 +2374,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.report, @@ -2392,7 +2382,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 2 }, op.report, @@ -2400,7 +2390,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 3 }, op.report, @@ -2408,7 +2398,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 4 }, op.report, @@ -2416,7 +2406,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 5 }, op.report, @@ -2424,7 +2414,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 6 }, op.report, @@ -2432,7 +2422,7 @@ test "OSC: OSC4: multiple get 8a" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 7 }, op.report, @@ -2456,11 +2446,11 @@ test "OSC: OSC4: multiple get 8b" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 8); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 8); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 8 }, op.report, @@ -2468,7 +2458,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 9 }, op.report, @@ -2476,7 +2466,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 10 }, op.report, @@ -2484,7 +2474,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 11 }, op.report, @@ -2492,7 +2482,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 12 }, op.report, @@ -2500,7 +2490,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 13 }, op.report, @@ -2508,7 +2498,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 14 }, op.report, @@ -2516,7 +2506,7 @@ test "OSC: OSC4: multiple get 8b" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual( Command.ColorKind{ .palette = 15 }, op.report, @@ -2539,11 +2529,11 @@ test "OSC: OSC4: set with invalid index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind{ .palette = 1 }, op.set.kind, @@ -2570,11 +2560,11 @@ test "OSC: OSC4: mix get/set palette color" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_4); - try testing.expect(cmd.color_operation.operations.count == 2); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .set); + try testing.expect(op.* == .set); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, op.set.kind, @@ -2586,7 +2576,7 @@ test "OSC: OSC4: mix get/set palette color" { } { const op = it.next().?; - try testing.expect(op == .report); + try testing.expect(op.* == .report); try testing.expectEqual(Command.ColorKind{ .palette = 254 }, op.report); } try testing.expect(it.next() == null); @@ -2606,11 +2596,11 @@ test "OSC: OSC104: reset palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, op.reset, @@ -2633,11 +2623,11 @@ test "OSC: OSC104: reset palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.count == 2); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 2); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 17 }, op.reset, @@ -2645,7 +2635,7 @@ test "OSC: OSC104: reset palette color 2" { } { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 111 }, op.reset, @@ -2668,11 +2658,11 @@ test "OSC: OSC104: invalid palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 111 }, op.reset, @@ -2695,11 +2685,11 @@ test "OSC: OSC104: empty palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expect(cmd.color_operation.source == .osc_104); - try testing.expect(cmd.color_operation.operations.count == 1); - var it = cmd.color_operation.operations.iterator(); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; - try testing.expect(op == .reset); + try testing.expect(op.* == .reset); try testing.expectEqual( Command.ColorKind{ .palette = 111 }, op.reset, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 51dec5347..5977f6564 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1212,10 +1212,10 @@ pub const StreamHandler = struct { try writer.print("\x1b]{}", .{source}); - var it = operations.iterator(); + var it = operations.constIterator(0); while (it.next()) |op| { - switch (op) { + switch (op.*) { .set => |set| { switch (set.kind) { .palette => |i| { From bd4d1950ce12855479424a7d7713bc65383e6d32 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 08:17:31 -0500 Subject: [PATCH 098/245] OSC: remove unused code --- src/terminal/osc.zig | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 449713ff2..0c429c70e 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -202,15 +202,6 @@ pub const Command = union(enum) { foreground, background, cursor, - - pub fn code(self: ColorKind) []const u8 { - return switch (self) { - .palette => "4", - .foreground => "10", - .background => "11", - .cursor => "12", - }; - } }; pub const ProgressState = enum { From f2dfd9f6779f0cabb61f2c4ef3a70216ab9d42de Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 08:18:09 -0500 Subject: [PATCH 099/245] OSC: improve formatting of ColorOperationSource --- src/terminal/osc.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 0c429c70e..78a3560af 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -179,10 +179,10 @@ pub const Command = union(enum) { pub fn format( self: ColorOperationSource, comptime _: []const u8, - _: std.fmt.FormatOptions, + options: std.fmt.FormatOptions, writer: anytype, ) !void { - try writer.print("{d}", .{@intFromEnum(self)}); + try std.fmt.formatInt(@intFromEnum(self), 10, .lower, options, writer); } }; From e0ddc7a2fa66b5e990fc1bfa6ddc0e3ef930bb40 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 08:32:10 -0500 Subject: [PATCH 100/245] OSC: clean up color_operation handling --- src/termio/stream_handler.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 5977f6564..4bb0f9c9d 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1201,6 +1201,9 @@ pub const StreamHandler = struct { operations: *const terminal.osc.Command.ColorOperationList, terminator: terminal.osc.Terminator, ) !void { + // return early if there is nothing to do + if (operations.count() == 0) return; + var buffer: [1024]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buffer); const alloc = fba.allocator(); @@ -1366,6 +1369,8 @@ pub const StreamHandler = struct { } } if (report) { + // If any of the operations were reports, finialize the report + // string and send it to the terminal. try writer.writeAll(terminator.string()); const msg = try termio.Message.writeReq(self.alloc, response.items); self.messageWriter(msg); From 35384670c4c80f532966955c2e4ad78dc41e2a48 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 08:59:37 -0500 Subject: [PATCH 101/245] OSC: fix typo --- src/termio/stream_handler.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 4bb0f9c9d..ca16b0bd2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1369,7 +1369,7 @@ pub const StreamHandler = struct { } } if (report) { - // If any of the operations were reports, finialize the report + // If any of the operations were reports, finalize the report // string and send it to the terminal. try writer.writeAll(terminator.string()); const msg = try termio.Message.writeReq(self.alloc, response.items); From fa03115f01abf63e45e37316479e291150fc6787 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 10:52:36 -0500 Subject: [PATCH 102/245] OSC: don't use arena during testing --- src/terminal/Parser.zig | 6 +- src/terminal/osc.zig | 147 ++++++++++++++-------------------------- 2 files changed, 51 insertions(+), 102 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index df18fbc7a..8cf2996d6 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -877,11 +877,9 @@ test "osc: change window title (end in esc)" { // https://github.com/darrenstarr/VtNetCore/pull/14 // Saw this on HN, decided to add a test case because why not. test "osc: 112 incomplete sequence" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - var p: Parser = init(); - p.osc_parser.alloc = arena.allocator(); + defer p.deinit(); + p.osc_parser.alloc = std.testing.allocator; _ = p.next(0x1B); _ = p.next(']'); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 78a3560af..7e5a71536 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1745,10 +1745,8 @@ test "OSC: end_of_input" { test "OSC: OSC110: reset cursor color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "110"; for (input) |ch| p.next(ch); @@ -1773,10 +1771,8 @@ test "OSC: OSC110: reset cursor color" { test "OSC: OSC111: reset cursor color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "111"; for (input) |ch| p.next(ch); @@ -1801,10 +1797,8 @@ test "OSC: OSC111: reset cursor color" { test "OSC: OSC112: reset cursor color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "112"; for (input) |ch| p.next(ch); @@ -1829,10 +1823,8 @@ test "OSC: OSC112: reset cursor color" { test "OSC: OSC112: reset cursor color with semicolon" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "112;"; for (input) |ch| { @@ -1888,9 +1880,8 @@ test "OSC: get/set clipboard (optional parameter)" { test "OSC: get/set clipboard with allocator" { const testing = std.testing; - const alloc = testing.allocator; - var p: Parser = .{ .alloc = alloc }; + var p: Parser = .{ .alloc = testing.allocator }; defer p.deinit(); const input = "52;s;?"; @@ -1955,10 +1946,8 @@ test "OSC: longer than buffer" { test "OSC: OSC10: report default foreground color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "10;?"; for (input) |ch| p.next(ch); @@ -1985,10 +1974,8 @@ test "OSC: OSC10: report default foreground color" { test "OSC: OSC10: set foreground color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "10;rgbi:0.0/0.5/1.0"; for (input) |ch| p.next(ch); @@ -2017,10 +2004,8 @@ test "OSC: OSC10: set foreground color" { test "OSC: OSC11: report default background color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "11;?"; for (input) |ch| p.next(ch); @@ -2047,10 +2032,8 @@ test "OSC: OSC11: report default background color" { test "OSC: OSC11: set background color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "11;rgb:f/ff/ffff"; for (input) |ch| p.next(ch); @@ -2079,10 +2062,8 @@ test "OSC: OSC11: set background color" { test "OSC: OSC12: report background color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "12;?"; for (input) |ch| p.next(ch); @@ -2109,10 +2090,8 @@ test "OSC: OSC12: report background color" { test "OSC: OSC12: set background color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "12;rgb:f/ff/ffff"; for (input) |ch| p.next(ch); @@ -2141,10 +2120,8 @@ test "OSC: OSC12: set background color" { test "OSC: OSC4: get palette color 1" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;1;?"; for (input) |ch| p.next(ch); @@ -2169,10 +2146,8 @@ test "OSC: OSC4: get palette color 1" { test "OSC: OSC4: get palette color 2" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;1;?;2;?"; for (input) |ch| p.next(ch); @@ -2205,10 +2180,8 @@ test "OSC: OSC4: get palette color 2" { test "OSC: OSC4: set palette color 1" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;17;rgb:aa/bb/cc"; for (input) |ch| p.next(ch); @@ -2236,10 +2209,8 @@ test "OSC: OSC4: set palette color 1" { test "OSC: OSC4: set palette color 2" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;17;rgb:aa/bb/cc;1;rgb:00/11/22"; for (input) |ch| p.next(ch); @@ -2279,10 +2250,8 @@ test "OSC: OSC4: set palette color 2" { test "OSC: OSC4: get with invalid index 1" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;1111;?;1;?"; for (input) |ch| p.next(ch); @@ -2306,10 +2275,8 @@ test "OSC: OSC4: get with invalid index 1" { test "OSC: OSC4: get with invalid index 2" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;5;?;1111;?;1;?"; for (input) |ch| p.next(ch); @@ -2342,10 +2309,8 @@ test "OSC: OSC4: get with invalid index 2" { test "OSC: OSC4: multiple get 8a" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?"; for (input) |ch| p.next(ch); @@ -2426,10 +2391,8 @@ test "OSC: OSC4: multiple get 8a" { test "OSC: OSC4: multiple get 8b" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?"; for (input) |ch| p.next(ch); @@ -2509,10 +2472,8 @@ test "OSC: OSC4: multiple get 8b" { test "OSC: OSC4: set with invalid index" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;256;#ffffff;1;#aabbcc"; for (input) |ch| p.next(ch); @@ -2540,10 +2501,8 @@ test "OSC: OSC4: set with invalid index" { test "OSC: OSC4: mix get/set palette color" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "4;17;rgb:aa/bb/cc;254;?"; for (input) |ch| p.next(ch); @@ -2576,10 +2535,8 @@ test "OSC: OSC4: mix get/set palette color" { test "OSC: OSC104: reset palette color 1" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "104;17"; for (input) |ch| p.next(ch); @@ -2603,10 +2560,8 @@ test "OSC: OSC104: reset palette color 1" { test "OSC: OSC104: reset palette color 2" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "104;17;111"; for (input) |ch| p.next(ch); @@ -2638,10 +2593,8 @@ test "OSC: OSC104: reset palette color 2" { test "OSC: OSC104: invalid palette index" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "104;ffff;111"; for (input) |ch| p.next(ch); @@ -2665,10 +2618,8 @@ test "OSC: OSC104: invalid palette index" { test "OSC: OSC104: empty palette index" { const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - - var p: Parser = .{ .alloc = arena.allocator() }; + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); const input = "104;;111"; for (input) |ch| p.next(ch); From bcf4d55dad47472e317130f4372fb3ddfa35b512 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 11:30:17 -0500 Subject: [PATCH 103/245] OSC: nest ColorOperation-related structs --- src/apprt/surface.zig | 2 +- src/terminal/Parser.zig | 4 +- src/terminal/osc.zig | 232 +++++++++++++++++----------------- src/termio/stream_handler.zig | 4 +- 4 files changed, 122 insertions(+), 120 deletions(-) diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 6de41c544..dce6a3a56 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -74,7 +74,7 @@ pub const Message = union(enum) { /// A terminal color was changed using OSC sequences. color_change: struct { - kind: terminal.osc.Command.ColorKind, + kind: terminal.osc.Command.ColorOperation.Kind, color: terminal.color.RGB, }, diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 8cf2996d6..ec3f322f6 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -897,14 +897,14 @@ test "osc: 112 incomplete sequence" { const cmd = a[0].?.osc_dispatch; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); - try testing.expect(cmd.color_operation.source == .osc_112); + try testing.expect(cmd.color_operation.source == .reset_cursor); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - osc.Command.ColorKind.cursor, + osc.Command.ColorOperation.Kind.cursor, op.reset, ); } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 7e5a71536..67f665f1a 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -119,8 +119,8 @@ pub const Command = union(enum) { /// /// 4, 10, 11, 12, 104, 110, 111, 112 color_operation: struct { - source: ColorOperationSource, - operations: ColorOperationList = .{}, + source: ColorOperation.Source, + operations: ColorOperation.List = .{}, terminator: Terminator = .st, }, @@ -166,42 +166,44 @@ pub const Command = union(enum) { /// Wait input (OSC 9;5) wait_input: void, - pub const ColorOperationSource = enum(u16) { - osc_4 = 4, - osc_10 = 10, - osc_11 = 11, - osc_12 = 12, - osc_104 = 104, - osc_110 = 110, - osc_111 = 111, - osc_112 = 112, - - pub fn format( - self: ColorOperationSource, - comptime _: []const u8, - options: std.fmt.FormatOptions, - writer: anytype, - ) !void { - try std.fmt.formatInt(@intFromEnum(self), 10, .lower, options, writer); - } - }; - pub const ColorOperation = union(enum) { + pub const Source = enum(u16) { + // these numbers are based on the OSC operation code + // see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands + get_set_palette = 4, + get_set_foreground = 10, + get_set_background = 11, + get_set_cursor = 12, + reset_palette = 104, + reset_foreground = 110, + reset_background = 111, + reset_cursor = 112, + + pub fn format( + self: Source, + comptime _: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try std.fmt.formatInt(@intFromEnum(self), 10, .lower, options, writer); + } + }; + + pub const List = std.SegmentedList(ColorOperation, 4); + + pub const Kind = union(enum) { + palette: u8, + foreground, + background, + cursor, + }; + set: struct { - kind: ColorKind, + kind: Kind, color: RGB, }, - reset: ColorKind, - report: ColorKind, - }; - - pub const ColorOperationList = std.SegmentedList(ColorOperation, 4); - - pub const ColorKind = union(enum) { - palette: u8, - foreground, - background, - cursor, + reset: Kind, + report: Kind, }; pub const ProgressState = enum { @@ -1325,7 +1327,7 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ - .source = .osc_4, + .source = .get_set_palette, }, }; @@ -1381,9 +1383,9 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ .source = switch (self.state) { - .osc_10 => .osc_10, - .osc_11 => .osc_11, - .osc_12 => .osc_12, + .osc_10 => .get_set_foreground, + .osc_11 => .get_set_background, + .osc_12 => .get_set_cursor, else => unreachable, }, }, @@ -1442,9 +1444,9 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ .source = switch (self.state) { - .osc_110 => .osc_110, - .osc_111 => .osc_111, - .osc_112 => .osc_112, + .osc_110 => .reset_foreground, + .osc_111 => .reset_background, + .osc_112 => .reset_cursor, else => unreachable, }, }, @@ -1470,7 +1472,7 @@ pub const Parser = struct { self.command = .{ .color_operation = .{ - .source = .osc_104, + .source = .get_set_palette, }, }; @@ -1742,7 +1744,7 @@ test "OSC: end_of_input" { try testing.expect(cmd == .end_of_input); } -test "OSC: OSC110: reset cursor color" { +test "OSC: OSC110: reset foreground color" { const testing = std.testing; var p: Parser = .{ .alloc = testing.allocator }; @@ -1754,21 +1756,21 @@ test "OSC: OSC110: reset cursor color" { const cmd = p.end(null).?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); - try testing.expect(cmd.color_operation.source == .osc_110); + try testing.expect(cmd.color_operation.source == .reset_foreground); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind.foreground, + Command.ColorOperation.Kind.foreground, op.reset, ); } try testing.expect(it.next() == null); } -test "OSC: OSC111: reset cursor color" { +test "OSC: OSC111: reset background color" { const testing = std.testing; var p: Parser = .{ .alloc = testing.allocator }; @@ -1780,14 +1782,14 @@ test "OSC: OSC111: reset cursor color" { const cmd = p.end(null).?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); - try testing.expect(cmd.color_operation.source == .osc_111); + try testing.expect(cmd.color_operation.source == .reset_background); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind.background, + Command.ColorOperation.Kind.background, op.reset, ); } @@ -1806,14 +1808,14 @@ test "OSC: OSC112: reset cursor color" { const cmd = p.end(null).?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); - try testing.expect(cmd.color_operation.source == .osc_112); + try testing.expect(cmd.color_operation.source == .reset_cursor); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind.cursor, + Command.ColorOperation.Kind.cursor, op.reset, ); } @@ -1836,14 +1838,14 @@ test "OSC: OSC112: reset cursor color with semicolon" { const cmd = p.end(0x07).?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); - try testing.expect(cmd.color_operation.source == .osc_112); + try testing.expect(cmd.color_operation.source == .reset_cursor); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind.cursor, + Command.ColorOperation.Kind.cursor, op.reset, ); } @@ -1943,7 +1945,7 @@ test "OSC: longer than buffer" { try testing.expect(p.complete == false); } -test "OSC: OSC10: report default foreground color" { +test "OSC: OSC10: report foreground color" { const testing = std.testing; var p: Parser = .{ .alloc = testing.allocator }; @@ -1957,14 +1959,14 @@ test "OSC: OSC10: report default foreground color" { try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); - try testing.expect(cmd.color_operation.source == .osc_10); + try testing.expect(cmd.color_operation.source == .get_set_foreground); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind.foreground, + Command.ColorOperation.Kind.foreground, op.report, ); } @@ -1983,14 +1985,14 @@ test "OSC: OSC10: set foreground color" { const cmd = p.end('\x07').?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); - try testing.expect(cmd.color_operation.source == .osc_10); + try testing.expect(cmd.color_operation.source == .get_set_foreground); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind.foreground, + Command.ColorOperation.Kind.foreground, op.set.kind, ); try testing.expectEqual( @@ -2001,7 +2003,7 @@ test "OSC: OSC10: set foreground color" { try testing.expect(it.next() == null); } -test "OSC: OSC11: report default background color" { +test "OSC: OSC11: report background color" { const testing = std.testing; var p: Parser = .{ .alloc = testing.allocator }; @@ -2014,14 +2016,14 @@ test "OSC: OSC11: report default background color" { const cmd = p.end('\x07').?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); - try testing.expect(cmd.color_operation.source == .osc_11); + try testing.expect(cmd.color_operation.source == .get_set_background); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind.background, + Command.ColorOperation.Kind.background, op.report, ); } @@ -2041,14 +2043,14 @@ test "OSC: OSC11: set background color" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); - try testing.expect(cmd.color_operation.source == .osc_11); + try testing.expect(cmd.color_operation.source == .get_set_background); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind.background, + Command.ColorOperation.Kind.background, op.set.kind, ); try testing.expectEqual( @@ -2059,7 +2061,7 @@ test "OSC: OSC11: set background color" { try testing.expect(it.next() == null); } -test "OSC: OSC12: report background color" { +test "OSC: OSC12: report cursor color" { const testing = std.testing; var p: Parser = .{ .alloc = testing.allocator }; @@ -2072,14 +2074,14 @@ test "OSC: OSC12: report background color" { const cmd = p.end('\x07').?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .bel); - try testing.expect(cmd.color_operation.source == .osc_12); + try testing.expect(cmd.color_operation.source == .get_set_cursor); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind.cursor, + Command.ColorOperation.Kind.cursor, op.report, ); } @@ -2087,7 +2089,7 @@ test "OSC: OSC12: report background color" { try testing.expect(it.next() == null); } -test "OSC: OSC12: set background color" { +test "OSC: OSC12: set cursor color" { const testing = std.testing; var p: Parser = .{ .alloc = testing.allocator }; @@ -2099,14 +2101,14 @@ test "OSC: OSC12: set background color" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); try testing.expectEqual(cmd.color_operation.terminator, .st); - try testing.expect(cmd.color_operation.source == .osc_12); + try testing.expect(cmd.color_operation.source == .get_set_cursor); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind.cursor, + Command.ColorOperation.Kind.cursor, op.set.kind, ); try testing.expectEqual( @@ -2128,14 +2130,14 @@ test "OSC: OSC4: get palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.report, ); try testing.expectEqual(cmd.color_operation.terminator, .st); @@ -2154,14 +2156,14 @@ test "OSC: OSC4: get palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 2); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.report, ); } @@ -2169,7 +2171,7 @@ test "OSC: OSC4: get palette color 2" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 2 }, + Command.ColorOperation.Kind{ .palette = 2 }, op.report, ); } @@ -2188,14 +2190,14 @@ test "OSC: OSC4: set palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind{ .palette = 17 }, + Command.ColorOperation.Kind{ .palette = 17 }, op.set.kind, ); try testing.expectEqual( @@ -2217,14 +2219,14 @@ test "OSC: OSC4: set palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 2); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind{ .palette = 17 }, + Command.ColorOperation.Kind{ .palette = 17 }, op.set.kind, ); try testing.expectEqual( @@ -2236,7 +2238,7 @@ test "OSC: OSC4: set palette color 2" { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.set.kind, ); try testing.expectEqual( @@ -2258,14 +2260,14 @@ test "OSC: OSC4: get with invalid index 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.report, ); } @@ -2283,14 +2285,14 @@ test "OSC: OSC4: get with invalid index 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 2); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 5 }, + Command.ColorOperation.Kind{ .palette = 5 }, op.report, ); } @@ -2298,7 +2300,7 @@ test "OSC: OSC4: get with invalid index 2" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.report, ); } @@ -2317,14 +2319,14 @@ test "OSC: OSC4: multiple get 8a" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 8); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 0 }, + Command.ColorOperation.Kind{ .palette = 0 }, op.report, ); } @@ -2332,7 +2334,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.report, ); } @@ -2340,7 +2342,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 2 }, + Command.ColorOperation.Kind{ .palette = 2 }, op.report, ); } @@ -2348,7 +2350,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 3 }, + Command.ColorOperation.Kind{ .palette = 3 }, op.report, ); } @@ -2356,7 +2358,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 4 }, + Command.ColorOperation.Kind{ .palette = 4 }, op.report, ); } @@ -2364,7 +2366,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 5 }, + Command.ColorOperation.Kind{ .palette = 5 }, op.report, ); } @@ -2372,7 +2374,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 6 }, + Command.ColorOperation.Kind{ .palette = 6 }, op.report, ); } @@ -2380,7 +2382,7 @@ test "OSC: OSC4: multiple get 8a" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 7 }, + Command.ColorOperation.Kind{ .palette = 7 }, op.report, ); } @@ -2399,14 +2401,14 @@ test "OSC: OSC4: multiple get 8b" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 8); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 8 }, + Command.ColorOperation.Kind{ .palette = 8 }, op.report, ); } @@ -2414,7 +2416,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 9 }, + Command.ColorOperation.Kind{ .palette = 9 }, op.report, ); } @@ -2422,7 +2424,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 10 }, + Command.ColorOperation.Kind{ .palette = 10 }, op.report, ); } @@ -2430,7 +2432,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 11 }, + Command.ColorOperation.Kind{ .palette = 11 }, op.report, ); } @@ -2438,7 +2440,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 12 }, + Command.ColorOperation.Kind{ .palette = 12 }, op.report, ); } @@ -2446,7 +2448,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 13 }, + Command.ColorOperation.Kind{ .palette = 13 }, op.report, ); } @@ -2454,7 +2456,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 14 }, + Command.ColorOperation.Kind{ .palette = 14 }, op.report, ); } @@ -2462,7 +2464,7 @@ test "OSC: OSC4: multiple get 8b" { const op = it.next().?; try testing.expect(op.* == .report); try testing.expectEqual( - Command.ColorKind{ .palette = 15 }, + Command.ColorOperation.Kind{ .palette = 15 }, op.report, ); } @@ -2480,14 +2482,14 @@ test "OSC: OSC4: set with invalid index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind{ .palette = 1 }, + Command.ColorOperation.Kind{ .palette = 1 }, op.set.kind, ); try testing.expectEqual( @@ -2509,14 +2511,14 @@ test "OSC: OSC4: mix get/set palette color" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_4); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 2); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .set); try testing.expectEqual( - Command.ColorKind{ .palette = 17 }, + Command.ColorOperation.Kind{ .palette = 17 }, op.set.kind, ); try testing.expectEqual( @@ -2527,7 +2529,7 @@ test "OSC: OSC4: mix get/set palette color" { { const op = it.next().?; try testing.expect(op.* == .report); - try testing.expectEqual(Command.ColorKind{ .palette = 254 }, op.report); + try testing.expectEqual(Command.ColorOperation.Kind{ .palette = 254 }, op.report); } try testing.expect(it.next() == null); } @@ -2543,14 +2545,14 @@ test "OSC: OSC104: reset palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind{ .palette = 17 }, + Command.ColorOperation.Kind{ .palette = 17 }, op.reset, ); } @@ -2568,14 +2570,14 @@ test "OSC: OSC104: reset palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 2); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind{ .palette = 17 }, + Command.ColorOperation.Kind{ .palette = 17 }, op.reset, ); } @@ -2583,7 +2585,7 @@ test "OSC: OSC104: reset palette color 2" { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind{ .palette = 111 }, + Command.ColorOperation.Kind{ .palette = 111 }, op.reset, ); } @@ -2601,14 +2603,14 @@ test "OSC: OSC104: invalid palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind{ .palette = 111 }, + Command.ColorOperation.Kind{ .palette = 111 }, op.reset, ); } @@ -2626,14 +2628,14 @@ test "OSC: OSC104: empty palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .osc_104); + try testing.expect(cmd.color_operation.source == .get_set_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; try testing.expect(op.* == .reset); try testing.expectEqual( - Command.ColorKind{ .palette = 111 }, + Command.ColorOperation.Kind{ .palette = 111 }, op.reset, ); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ca16b0bd2..554a87805 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1197,8 +1197,8 @@ pub const StreamHandler = struct { pub fn handleColorOperation( self: *StreamHandler, - source: terminal.osc.Command.ColorOperationSource, - operations: *const terminal.osc.Command.ColorOperationList, + source: terminal.osc.Command.ColorOperation.Source, + operations: *const terminal.osc.Command.ColorOperation.List, terminator: terminal.osc.Terminator, ) !void { // return early if there is nothing to do From 5fb32fd8a0d43412cf9375ad5f1fe850f23810ca Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 11:37:34 -0500 Subject: [PATCH 104/245] OSC: add comptime check for size of OSC Command --- src/terminal/osc.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 67f665f1a..63d3e4c6b 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -189,7 +189,7 @@ pub const Command = union(enum) { } }; - pub const List = std.SegmentedList(ColorOperation, 4); + pub const List = std.SegmentedList(ColorOperation, 2); pub const Kind = union(enum) { palette: u8, @@ -213,6 +213,11 @@ pub const Command = union(enum) { indeterminate, pause, }; + + comptime { + assert(@sizeOf(Command) == 64); + // @compileLog(@sizeOf(Command)); + } }; /// The terminator used to end an OSC command. For OSC commands that demand From f0fc82c80f070937234198f6404e3626c514ad9f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 12:12:28 -0500 Subject: [PATCH 105/245] OSC: account for 32-bit systems in comptime Command size check --- src/terminal/osc.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 63d3e4c6b..8ca4326c5 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -215,7 +215,11 @@ pub const Command = union(enum) { }; comptime { - assert(@sizeOf(Command) == 64); + assert(@sizeOf(Command) == switch (@sizeOf(usize)) { + 4 => 44, + 8 => 64, + else => unreachable, + }); // @compileLog(@sizeOf(Command)); } }; From 1104993c940a26dcf3baad6917e988ea0c913cfb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 24 May 2025 16:42:55 -0500 Subject: [PATCH 106/245] OSC: move some processing back inside the OSC state machine --- src/terminal/osc.zig | 423 ++++++++++++++++++++++++------------------- 1 file changed, 238 insertions(+), 185 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 8ca4326c5..d0b59e834 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -345,7 +345,8 @@ pub const Parser = struct { clipboard_kind_end, // Get/set color palette index - osc_4, + osc_4_index, + osc_4_color, // Get/set foreground color osc_10, @@ -359,15 +360,6 @@ pub const Parser = struct { // Reset color palette index osc_104, - // Reset foreground color - osc_110, - - // Reset background color - osc_111, - - // Reset cursor color - osc_112, - // Hyperlinks hyperlink_param_key, hyperlink_param_value, @@ -547,6 +539,11 @@ pub const Parser = struct { self.state = .invalid; break :osc_10; } + self.command = .{ + .color_operation = .{ + .source = .get_set_foreground, + }, + }; self.state = .osc_10; self.buf_start = self.buf_idx; self.complete = true; @@ -555,7 +552,10 @@ pub const Parser = struct { else => self.state = .invalid, }, - .osc_10 => {}, + .osc_10, .osc_11, .osc_12 => switch (c) { + ';' => self.parseOSC101112(false), + else => {}, + }, .@"104" => switch (c) { ';' => osc_104: { @@ -564,6 +564,11 @@ pub const Parser = struct { self.state = .invalid; break :osc_104; } + self.command = .{ + .color_operation = .{ + .source = .reset_palette, + }, + }; self.state = .osc_104; self.buf_start = self.buf_idx; self.complete = true; @@ -571,7 +576,10 @@ pub const Parser = struct { else => self.state = .invalid, }, - .osc_104 => {}, + .osc_104 => switch (c) { + ';' => self.parseOSC104(false), + else => {}, + }, .@"11" => switch (c) { ';' => osc_11: { @@ -580,51 +588,52 @@ pub const Parser = struct { self.state = .invalid; break :osc_11; } + self.command = .{ + .color_operation = .{ + .source = .get_set_background, + }, + }; self.state = .osc_11; self.buf_start = self.buf_idx; self.complete = true; }, - '0' => osc_110: { + '0'...'2' => blk: { if (self.alloc == null) { - log.warn("OSC 110 requires an allocator, but none was provided", .{}); + log.warn("OSC 11{c} requires an allocator, but none was provided", .{c}); self.state = .invalid; - break :osc_110; + break :blk; } - self.state = .osc_110; - self.buf_start = self.buf_idx; - self.complete = true; - }, - '1' => osc_111: { - if (self.alloc == null) { - log.warn("OSC 111 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_111; - } - self.state = .osc_111; - self.buf_start = self.buf_idx; - self.complete = true; - }, - '2' => osc_112: { - if (self.alloc == null) { - log.warn("OSC 112 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_112; - } - self.state = .osc_112; - self.buf_start = self.buf_idx; + + const alloc = self.alloc orelse return; + + self.command = .{ + .color_operation = .{ + .source = switch (c) { + '0' => .reset_foreground, + '1' => .reset_background, + '2' => .reset_cursor, + else => unreachable, + }, + }, + }; + const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .reset = switch (c) { + '0' => .foreground, + '1' => .background, + '2' => .cursor, + else => unreachable, + }, + }; + self.state = .swallow; self.complete = true; }, else => self.state = .invalid, }, - .osc_11 => {}, - - .osc_110 => {}, - - .osc_111 => {}, - - .osc_112 => {}, - .@"12" => switch (c) { ';' => osc_12: { if (self.alloc == null) { @@ -632,6 +641,11 @@ pub const Parser = struct { self.state = .invalid; break :osc_12; } + self.command = .{ + .color_operation = .{ + .source = .get_set_cursor, + }, + }; self.state = .osc_12; self.buf_start = self.buf_idx; self.complete = true; @@ -639,8 +653,6 @@ pub const Parser = struct { else => self.state = .invalid, }, - .osc_12 => {}, - .@"13" => switch (c) { '3' => self.state = .@"133", else => self.state = .invalid, @@ -728,14 +740,30 @@ pub const Parser = struct { self.state = .invalid; break :osc_4; } - self.state = .osc_4; + self.command = .{ + .color_operation = .{ + .source = .get_set_palette, + }, + }; + self.state = .osc_4_index; self.buf_start = self.buf_idx; self.complete = true; }, else => self.state = .invalid, }, - .osc_4 => {}, + .osc_4_index => switch (c) { + ';' => self.state = .osc_4_color, + else => {}, + }, + + .osc_4_color => switch (c) { + ';' => { + self.parseOSC4(false); + self.state = .osc_4_index; + }, + else => {}, + }, .@"5" => switch (c) { '2' => self.state = .@"52", @@ -1329,85 +1357,104 @@ pub const Parser = struct { self.temp_state.str.* = list.items; } - fn parseOSC4(self: *Parser) void { - assert(self.state == .osc_4); + fn parseOSC4(self: *Parser, final: bool) void { + assert(self.state == .osc_4_color); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == .get_set_palette); const alloc = self.alloc orelse return; + const operations = &self.command.color_operation.operations; - self.command = .{ - .color_operation = .{ - .source = .get_set_palette, + const str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + self.buf_start = 0; + self.buf_idx = 0; + + var it = std.mem.splitScalar(u8, str, ';'); + const index_str = it.next() orelse { + log.warn("OSC 4 is missing palette index", .{}); + return; + }; + const spec_str = it.next() orelse { + log.warn("OSC 4 is missing color spec", .{}); + return; + }; + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid color palette index in OSC 4: {s} {}", .{ index_str, err }); + return; }, }; - - const str = self.buf[self.buf_start..self.buf_idx]; - var it = std.mem.splitScalar(u8, str, ';'); - while (it.next()) |index_str| { - const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { - error.Overflow, error.InvalidCharacter => { - log.warn("invalid color palette index in OSC 4: {s} {}", .{ index_str, err }); - // skip any color spec - _ = it.next(); - continue; + if (std.mem.eql(u8, spec_str, "?")) { + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .report = .{ .palette = index }, + }; + } else { + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification in OSC 4: '{s}' {}", .{ spec_str, err }); + return; + }; + const op = operations.addOne(alloc) catch |err| { + log.warn("unable to append color operation: {}", .{err}); + return; + }; + op.* = .{ + .set = .{ + .kind = .{ + .palette = index, + }, + .color = color, }, }; - const spec_str = it.next() orelse continue; - if (std.mem.eql(u8, spec_str, "?")) { - const op = self.command.color_operation.operations.addOne(alloc) catch |err| { - log.warn("unable to append color operation: {}", .{err}); - return; - }; - op.* = .{ - .report = .{ .palette = index }, - }; - } else { - const color = RGB.parse(spec_str) catch |err| { - log.warn("invalid color specification in OSC 4: {s} {}", .{ spec_str, err }); - continue; - }; - const op = self.command.color_operation.operations.addOne(alloc) catch |err| { - log.warn("unable to append color operation: {}", .{err}); - return; - }; - op.* = .{ - .set = .{ - .kind = .{ - .palette = index, - }, - .color = color, - }, - }; - } } } - fn parseOSC101112(self: *Parser) void { + fn parseOSC101112(self: *Parser, final: bool) void { assert(switch (self.state) { .osc_10, .osc_11, .osc_12 => true, else => false, }); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == switch (self.state) { + .osc_10 => Command.ColorOperation.Source.get_set_foreground, + .osc_11 => Command.ColorOperation.Source.get_set_background, + .osc_12 => Command.ColorOperation.Source.get_set_cursor, + else => unreachable, + }); + + const spec_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + + if (self.command.color_operation.operations.count() > 0) { + // don't emit the warning if the string is empty + if (spec_str.len == 0) return; + + log.warn("OSC 1{s} can only accept 1 color", .{switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }}); + return; + } + + if (spec_str.len == 0) { + log.warn("OSC 1{s} requires an argument", .{switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }}); + return; + } const alloc = self.alloc orelse return; + const operations = &self.command.color_operation.operations; - self.command = .{ - .color_operation = .{ - .source = switch (self.state) { - .osc_10 => .get_set_foreground, - .osc_11 => .get_set_background, - .osc_12 => .get_set_cursor, - else => unreachable, - }, - }, - }; - const str = self.buf[self.buf_start..self.buf_idx]; - var it = std.mem.splitScalar(u8, str, ';'); - const color_str = it.next() orelse { - log.warn("OSC 10/11/12 requires an argument", .{}); - self.state = .invalid; - return; - }; - if (std.mem.eql(u8, color_str, "?")) { - const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + if (std.mem.eql(u8, spec_str, "?")) { + const op = operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; @@ -1420,11 +1467,20 @@ pub const Parser = struct { }, }; } else { - const color = RGB.parse(color_str) catch |err| { - log.warn("invalid color specification in OSC 10/11/12: {s} {}", .{ color_str, err }); + const color = RGB.parse(spec_str) catch |err| { + log.warn("invalid color specification in OSC 1{s}: {s} {}", .{ + switch (self.state) { + .osc_10 => "0", + .osc_11 => "1", + .osc_12 => "2", + else => unreachable, + }, + spec_str, + err, + }); return; }; - const op = self.command.color_operation.operations.addOne(alloc) catch |err| { + const op = operations.addOne(alloc) catch |err| { log.warn("unable to append color operation: {}", .{err}); return; }; @@ -1442,22 +1498,21 @@ pub const Parser = struct { } } - fn parseOSC110111112(self: *Parser) void { - assert(switch (self.state) { - .osc_110, .osc_111, .osc_112 => true, - else => false, - }); + fn parseOSC104(self: *Parser, final: bool) void { + assert(self.state == .osc_104); + assert(self.command == .color_operation); + assert(self.command.color_operation.source == .reset_palette); const alloc = self.alloc orelse return; - self.command = .{ - .color_operation = .{ - .source = switch (self.state) { - .osc_110 => .reset_foreground, - .osc_111 => .reset_background, - .osc_112 => .reset_cursor, - else => unreachable, - }, + const index_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))]; + self.buf_start = 0; + self.buf_idx = 0; + + const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { + error.Overflow, error.InvalidCharacter => { + log.warn("invalid color palette index in OSC 104: {s} {}", .{ index_str, err }); + return; }, }; const op = self.command.color_operation.operations.addOne(alloc) catch |err| { @@ -1465,45 +1520,10 @@ pub const Parser = struct { return; }; op.* = .{ - .reset = switch (self.state) { - .osc_110 => .foreground, - .osc_111 => .background, - .osc_112 => .cursor, - else => unreachable, - }, + .reset = .{ .palette = index }, }; } - fn parseOSC104(self: *Parser) void { - assert(self.state == .osc_104); - - const alloc = self.alloc orelse return; - - self.command = .{ - .color_operation = .{ - .source = .get_set_palette, - }, - }; - - const str = self.buf[self.buf_start..self.buf_idx]; - var it = std.mem.splitScalar(u8, str, ';'); - while (it.next()) |index_str| { - const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) { - error.Overflow, error.InvalidCharacter => { - log.warn("invalid color palette index in OSC 104: {s} {}", .{ index_str, err }); - continue; - }, - }; - const op = self.command.color_operation.operations.addOne(alloc) catch |err| { - log.warn("unable to append color operation: {}", .{err}); - return; - }; - op.* = .{ - .reset = .{ .palette = index }, - }; - } - } - /// End the sequence and return the command, if any. If the return value /// is null, then no valid command was found. The optional terminator_ch /// is the final character in the OSC sequence. This is used to determine @@ -1527,18 +1547,9 @@ pub const Parser = struct { .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), - .osc_4, - => self.parseOSC4(), - .osc_10, - .osc_11, - .osc_12, - => self.parseOSC101112(), - .osc_104, - => self.parseOSC104(), - .osc_110, - .osc_111, - .osc_112, - => self.parseOSC110111112(), + .osc_4_color => self.parseOSC4(true), + .osc_10, .osc_11, .osc_12 => self.parseOSC101112(true), + .osc_104 => self.parseOSC104(true), else => {}, } @@ -1838,10 +1849,7 @@ test "OSC: OSC112: reset cursor color with semicolon" { defer p.deinit(); const input = "112;"; - for (input) |ch| { - log.warn("feeding {c} {s}", .{ ch, @tagName(p.state) }); - p.next(ch); - } + for (input) |ch| p.next(ch); log.warn("finish: {s}", .{@tagName(p.state)}); const cmd = p.end(0x07).?; @@ -2538,7 +2546,52 @@ test "OSC: OSC4: mix get/set palette color" { { const op = it.next().?; try testing.expect(op.* == .report); - try testing.expectEqual(Command.ColorOperation.Kind{ .palette = 254 }, op.report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 254 }, + op.report, + ); + } + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: incomplete color/spec 1" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 0); + var it = cmd.color_operation.operations.constIterator(0); + try testing.expect(it.next() == null); +} + +test "OSC: OSC4: incomplete color/spec 2" { + const testing = std.testing; + + var p: Parser = .{ .alloc = testing.allocator }; + defer p.deinit(); + + const input = "4;17;?;42"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .color_operation); + try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.operations.count() == 1); + var it = cmd.color_operation.operations.constIterator(0); + { + const op = it.next().?; + try testing.expect(op.* == .report); + try testing.expectEqual( + Command.ColorOperation.Kind{ .palette = 17 }, + op.report, + ); } try testing.expect(it.next() == null); } @@ -2554,7 +2607,7 @@ test "OSC: OSC104: reset palette color 1" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.source == .reset_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { @@ -2579,8 +2632,8 @@ test "OSC: OSC104: reset palette color 2" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .get_set_palette); - try testing.expect(cmd.color_operation.operations.count() == 2); + try testing.expect(cmd.color_operation.source == .reset_palette); + try testing.expectEqual(2, cmd.color_operation.operations.count()); var it = cmd.color_operation.operations.constIterator(0); { const op = it.next().?; @@ -2612,7 +2665,7 @@ test "OSC: OSC104: invalid palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.source == .reset_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { @@ -2637,7 +2690,7 @@ test "OSC: OSC104: empty palette index" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .color_operation); - try testing.expect(cmd.color_operation.source == .get_set_palette); + try testing.expect(cmd.color_operation.source == .reset_palette); try testing.expect(cmd.color_operation.operations.count() == 1); var it = cmd.color_operation.operations.constIterator(0); { From d3cb6d0d41835f9e57d4dca6b927440e0f505bb4 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 29 May 2025 15:45:51 -0500 Subject: [PATCH 107/245] GTK: add action to show the GTK inspector The default keybinds for showing the GTK inspector (`ctrl+shift+i` and `ctrl+shift+d`) don't work reliably in Ghostty due to the way Ghostty handles input. You can show the GTK inspector by setting the environment variable `GTK_DEBUG` to `interactive` before starting Ghostty but that's not always convenient. This adds a keybind action that will show the GTK inspector. Due to API limitations toggling the GTK inspector using the keybind action is impractical because GTK does not provide a convenient API to determine if the GTK inspector is already showing. Thus we limit ourselves to strictly showing the GTK inspector. To close the GTK inspector the user must click the close button on the GTK inspector window. If the GTK inspector window is already visible but is hidden, calling the keybind action will not bring the GTK inspector window to the front. --- include/ghostty.h | 1 + .../TerminalCommandPalette.swift | 3 ++- src/App.zig | 1 + src/apprt/action.zig | 4 ++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 19 +++++++++++++++++++ src/input/Binding.zig | 4 ++++ src/input/command.zig | 6 ++++++ 8 files changed, 38 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 950f5ef80..6b1625a30 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -653,6 +653,7 @@ typedef enum { GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 57a76dd43..47f2baf23 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -29,7 +29,8 @@ struct TerminalCommandPaletteView: View { let key = String(cString: c.action_key) switch (key) { case "toggle_tab_overview", - "toggle_window_decorations": + "toggle_window_decorations", + "show_gtk_inspector": return false default: return true diff --git a/src/App.zig b/src/App.zig index 005b745a6..39db2e2f9 100644 --- a/src/App.zig +++ b/src/App.zig @@ -445,6 +445,7 @@ pub fn performAction( .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}), .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}), .check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}), + .show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}), } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 8a23bc1a4..7866db182 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -165,6 +165,9 @@ pub const Action = union(Key) { /// Control whether the inspector is shown or hidden. inspector: Inspector, + /// Show the GTK inspector. + show_gtk_inspector, + /// The inspector for the given target has changes and should be /// rendered at the next opportunity. render_inspector, @@ -284,6 +287,7 @@ pub const Action = union(Key) { initial_size, cell_size, inspector, + show_gtk_inspector, render_inspector, desktop_notification, set_title, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 221d5344a..d67567aee 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -250,6 +250,7 @@ pub const App = struct { .reset_window_size, .ring_bell, .check_for_updates, + .show_gtk_inspector, => { log.info("unimplemented action={}", .{action}); return false; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 55c0be5e0..d1c8f2c59 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -481,6 +481,7 @@ pub fn performAction( .config_change => self.configChange(target, value.config), .reload_config => try self.reloadConfig(target, value), .inspector => self.controlInspector(target, value), + .show_gtk_inspector => self.showGTKInspector(), .desktop_notification => self.showDesktopNotification(target, value), .set_title => try self.setTitle(target, value), .pwd => try self.setPwd(target, value), @@ -687,6 +688,12 @@ fn controlInspector( surface.controlInspector(mode); } +fn showGTKInspector( + _: *const App, +) void { + gtk.Window.setInteractiveDebugging(@intFromBool(true)); +} + fn toggleMaximize(_: *App, target: apprt.Target) void { switch (target) { .app => {}, @@ -1060,6 +1067,7 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); try self.syncActionAccelerator("win.close", .{ .close_window = {} }); try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); @@ -1655,6 +1663,16 @@ fn gtkActionPresentSurface( ); } +fn gtkActionShowGTKInspector( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + self.core_app.performAction(self, .show_gtk_inspector) catch |err| { + log.err("error showing GTK inspector err={}", .{err}); + }; +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1673,6 +1691,7 @@ fn initActions(self: *App) void { .{ "open-config", gtkActionOpenConfig, null }, .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, + .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, }; inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 3818d99a6..4a5fb4522 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -397,6 +397,9 @@ pub const Action = union(enum) { /// keybind = cmd+i=inspector:toggle inspector: InspectorMode, + /// Show the GTK inspector. + show_gtk_inspector, + /// Open the configuration file in the default OS editor. If your default OS /// editor isn't configured then this will fail. Currently, any failures to /// open the configuration will show up only in the logs. @@ -795,6 +798,7 @@ pub const Action = union(enum) { .toggle_quick_terminal, .toggle_visibility, .check_for_updates, + .show_gtk_inspector, => .app, // These are app but can be special-cased in a surface context. diff --git a/src/input/command.zig b/src/input/command.zig index 8ef4a5f0e..53d1b6b3d 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -298,6 +298,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the inspector.", }}, + .show_gtk_inspector => comptime &.{.{ + .action = .show_gtk_inspector, + .title = "Show the GTK Inspector", + .description = "Show the GTK inspector.", + }}, + .open_config => comptime &.{.{ .action = .open_config, .title = "Open Config", From 0f1860f066cff0f4f93fa8d7bbe161cda3f3cb98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 May 2025 14:47:29 -0700 Subject: [PATCH 108/245] build: use a libc txt file to point to correct Apple SDK This fixes an issue where Ghostty would not build against the macOS 15.5 SDK. What was happening was that Zig was adding its embedded libc paths to the clang command line, which included old headers that were incompatible with the latest (macOS 15.5) SDK. Ghostty was adding the newer paths but they were being overridden by the embedded libc paths. The reason this was happening is because Zig was using its own logic to find the libc paths and this was colliding with the paths we were setting manually. To fix this, we now use a `libc.txt` file that explicitly tells Zig where to find libc, and we base this on our own SDK search logic. --- pkg/apple-sdk/build.zig | 91 +++++++++++++++++++++++++++++++-------- pkg/breakpad/build.zig | 2 +- pkg/cimgui/build.zig | 3 +- pkg/freetype/build.zig | 2 +- pkg/glfw/build.zig | 5 +-- pkg/glslang/build.zig | 6 +-- pkg/harfbuzz/build.zig | 3 +- pkg/highway/build.zig | 3 +- pkg/libintl/build.zig | 2 +- pkg/libpng/build.zig | 2 +- pkg/macos/build.zig | 5 +-- pkg/oniguruma/build.zig | 2 +- pkg/sentry/build.zig | 3 +- pkg/simdutf/build.zig | 2 +- pkg/spirv-cross/build.zig | 2 +- pkg/utfcpp/build.zig | 2 +- pkg/wuffs/build.zig | 5 --- pkg/zlib/build.zig | 2 +- src/build/SharedDeps.zig | 2 +- 19 files changed, 92 insertions(+), 52 deletions(-) diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 1be733dd6..18a6c0968 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -7,12 +7,17 @@ pub fn build(b: *std.Build) !void { _ = optimize; } -/// Add the SDK framework, include, and library paths to the given module. -/// The module target is used to determine the SDK to use so it must have -/// a resolved target. -pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { +/// Setup the step to point to the proper Apple SDK for libc and +/// frameworks. This expects and relies on the native SDK being +/// installed on the system. Ghostty doesn't support cross-compilation +/// for Apple platforms. +pub fn addPaths( + b: *std.Build, + step: *std.Build.Step.Compile, +) !void { // The cache. This always uses b.allocator and never frees memory - // (which is idiomatic for a Zig build exe). + // (which is idiomatic for a Zig build exe). We cache the libc txt + // file we create because it is expensive to generate (subprocesses). const Cache = struct { const Key = struct { arch: std.Target.Cpu.Arch, @@ -20,27 +25,72 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { abi: std.Target.Abi, }; - var map: std.AutoHashMapUnmanaged(Key, ?[]const u8) = .{}; + var map: std.AutoHashMapUnmanaged(Key, ?struct { + libc: std.Build.LazyPath, + framework: []const u8, + system_include: []const u8, + library: []const u8, + }) = .{}; }; - const target = m.resolved_target.?.result; + const target = step.rootModuleTarget(); const gop = try Cache.map.getOrPut(b.allocator, .{ .arch = target.cpu.arch, .os = target.os.tag, .abi = target.abi, }); - // This executes `xcrun` to get the SDK path. We don't want to execute - // this multiple times so we cache the value. if (!gop.found_existing) { - gop.value_ptr.* = std.zig.system.darwin.getSdk( - b.allocator, - m.resolved_target.?.result, - ); + // Detect our SDK using the "findNative" Zig stdlib function. + // This is really important because it forces using `xcrun` to + // find the SDK path. + const libc = try std.zig.LibCInstallation.findNative(.{ + .allocator = b.allocator, + .target = step.rootModuleTarget(), + .verbose = false, + }); + + // Render the file compatible with the `--libc` Zig flag. + var list: std.ArrayList(u8) = .init(b.allocator); + defer list.deinit(); + try libc.render(list.writer()); + + // Create a temporary file to store the libc path because + // `--libc` expects a file path. + const wf = b.addWriteFiles(); + const path = wf.add("libc.txt", list.items); + + // Determine our framework path. Zig has a bug where it doesn't + // parse this from the libc txt file for `-framework` flags: + // https://github.com/ziglang/zig/issues/24024 + const framework_path = framework: { + const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?; + const down2 = std.fs.path.dirname(down1).?; + break :framework try std.fs.path.join(b.allocator, &.{ + down2, + "System", + "Library", + "Frameworks", + }); + }; + + const library_path = library: { + const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?; + break :library try std.fs.path.join(b.allocator, &.{ + down1, + "lib", + }); + }; + + gop.value_ptr.* = .{ + .libc = path, + .framework = framework_path, + .system_include = libc.sys_include_dir.?, + .library = library_path, + }; } - // The active SDK we want to use - const path = gop.value_ptr.* orelse return switch (target.os.tag) { + const value = gop.value_ptr.* orelse return switch (target.os.tag) { // Return a more descriptive error. Before we just returned the // generic error but this was confusing a lot of community members. // It costs us nothing in the build script to return something better. @@ -50,7 +100,12 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void { .watchos => error.XcodeWatchOSSDKNotFound, else => error.XcodeAppleSDKNotFound, }; - m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) }); - m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) }); - m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) }); + + step.setLibCFile(value.libc); + + // This is only necessary until this bug is fixed: + // https://github.com/ziglang/zig/issues/24024 + step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/pkg/breakpad/build.zig b/pkg/breakpad/build.zig index e2fdec7ad..42247b12c 100644 --- a/pkg/breakpad/build.zig +++ b/pkg/breakpad/build.zig @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void { lib.addIncludePath(b.path("vendor")); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig index c76b53966..3ca735383 100644 --- a/pkg/cimgui/build.zig +++ b/pkg/cimgui/build.zig @@ -84,8 +84,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { if (!target.query.isNative()) { - try @import("apple_sdk").addPaths(b, lib.root_module); - try @import("apple_sdk").addPaths(b, module); + try @import("apple_sdk").addPaths(b, lib); } lib.addCSourceFile(.{ .file = imgui.path("backends/imgui_impl_metal.mm"), diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index bfe27e5aa..e9f72210a 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -69,7 +69,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/glfw/build.zig b/pkg/glfw/build.zig index cc61f18b2..142a558da 100644 --- a/pkg/glfw/build.zig +++ b/pkg/glfw/build.zig @@ -24,7 +24,7 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, exe.root_module); + try apple_sdk.addPaths(b, exe); } const tests_run = b.addRunArtifact(exe); @@ -122,8 +122,7 @@ fn buildLib( }, .macos => { - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); // Transitive dependencies, explicit linkage of these works around // ziglang/zig#17130 diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index 629490aa4..747216a39 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -16,10 +16,6 @@ pub fn build(b: *std.Build) !void { module.addIncludePath(upstream.path("")); module.addIncludePath(b.path("override")); - if (target.result.os.tag.isDarwin()) { - const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, module); - } if (target.query.isNative()) { const test_exe = b.addTest(.{ @@ -55,7 +51,7 @@ fn buildGlslang( lib.addIncludePath(b.path("override")); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index d0dd6d01c..3bdc30a32 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -93,8 +93,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu lib.linkLibCpp(); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } const dynamic_link_opts = options.dynamic_link_opts; diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index c72ca355f..5036316da 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -23,8 +23,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/libintl/build.zig b/pkg/libintl/build.zig index 53eb67f16..1baed195a 100644 --- a/pkg/libintl/build.zig +++ b/pkg/libintl/build.zig @@ -40,7 +40,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("gettext", .{})) |upstream| { diff --git a/pkg/libpng/build.zig b/pkg/libpng/build.zig index d012f2712..8729398f8 100644 --- a/pkg/libpng/build.zig +++ b/pkg/libpng/build.zig @@ -15,7 +15,7 @@ pub fn build(b: *std.Build) !void { } if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } // For dynamic linking, we prefer dynamic linking and to search by diff --git a/pkg/macos/build.zig b/pkg/macos/build.zig index 911664a2f..df76da9b4 100644 --- a/pkg/macos/build.zig +++ b/pkg/macos/build.zig @@ -45,8 +45,7 @@ pub fn build(b: *std.Build) !void { module.linkFramework("CoreVideo", .{}); module.linkFramework("QuartzCore", .{}); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } b.installArtifact(lib); @@ -58,7 +57,7 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - try apple_sdk.addPaths(b, test_exe.root_module); + try apple_sdk.addPaths(b, test_exe); } test_exe.linkLibrary(lib); diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index 1c93bbf9a..c23d744df 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -67,7 +67,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("oniguruma", .{})) |upstream| { diff --git a/pkg/sentry/build.zig b/pkg/sentry/build.zig index 3c0019710..0e6993ad4 100644 --- a/pkg/sentry/build.zig +++ b/pkg/sentry/build.zig @@ -20,8 +20,7 @@ pub fn build(b: *std.Build) !void { lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); - try apple_sdk.addPaths(b, module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 859653443..30de40fea 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -14,7 +14,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index c7d0d2039..ff67e3e72 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -44,7 +44,7 @@ fn buildSpirvCross( lib.linkLibCpp(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index 6b80fec7b..8e1a3cb20 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void { if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } var flags = std.ArrayList([]const u8).init(b.allocator); diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index d47771c22..4d144e76a 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -11,11 +11,6 @@ pub fn build(b: *std.Build) !void { .link_libc = true, }); - if (target.result.os.tag.isDarwin()) { - const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, module); - } - const unit_tests = b.addTest(.{ .root_source_file = b.path("src/main.zig"), .target = target, diff --git a/pkg/zlib/build.zig b/pkg/zlib/build.zig index 28ae62424..28344c989 100644 --- a/pkg/zlib/build.zig +++ b/pkg/zlib/build.zig @@ -12,7 +12,7 @@ pub fn build(b: *std.Build) !void { lib.linkLibC(); if (target.result.os.tag.isDarwin()) { const apple_sdk = @import("apple_sdk"); - try apple_sdk.addPaths(b, lib.root_module); + try apple_sdk.addPaths(b, lib); } if (b.lazyDependency("zlib", .{})) |upstream| { diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 512975ac0..d3741a358 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -377,7 +377,7 @@ pub fn add( // We always require the system SDK so that our system headers are available. // This makes things like `os/log.h` available for cross-compiling. if (step.rootModuleTarget().os.tag.isDarwin()) { - try @import("apple_sdk").addPaths(b, step.root_module); + try @import("apple_sdk").addPaths(b, step); const metallib = self.metallib.?; metallib.output.addStepDependencies(&step.step); From c5e5d61438343e888a83fe5fa190442ec5eb4534 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 May 2025 09:52:49 -0700 Subject: [PATCH 109/245] terminal: bring alt screen behaviors much closer in line with xterm This brings the behavior of mode 47, 1047, and 1049 much closer to xterm's behavior. I found that our prior implementation had many deficiencies. For example, we weren't properly copying the cursor state back to the primary screen from the alternate screen for modes 47 and 1047. And we weren't saving/restoring cursor state unconditionally for mode 1049 even if we were already in the alternate screen. These are weird, edgy behaviors that I don't think anyone expected (evidence by there being no bug reports about them), but they are bugs nontheless. Many tests added. --- src/terminal/Terminal.zig | 470 ++++++++++++++++++++++++++-------- src/termio/stream_handler.zig | 31 +-- 2 files changed, 368 insertions(+), 133 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index bb6702201..be7a58f9b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2515,39 +2515,37 @@ pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { &self.secondary_screen; } -/// Options for switching to the alternate screen. -pub const AlternateScreenOptions = struct { - cursor_save: bool = false, - clear_on_enter: bool = false, - clear_on_exit: bool = false, -}; - -/// Switch to the alternate screen buffer. +/// Switch to the given screen type (alternate or primary). /// -/// The alternate screen buffer: -/// * has its own grid -/// * has its own cursor state (included saved cursor) -/// * does not support scrollback +/// This does NOT handle behaviors such as clearing the screen, +/// copying the cursor, etc. This should be handled by downstream +/// callers. /// -pub fn alternateScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); +/// After calling this function, the `self.screen` field will point +/// to the current screen, and the returned value will be the previous +/// screen. If the return value is null, then the screen was not +/// switched because it was already the active screen. +/// +/// Note: This is written in a generic way so that we can support +/// more than two screens in the future if needed. There isn't +/// currently a spec for this, but it is something I think might +/// be useful in the future. +pub fn switchScreen(self: *Terminal, t: ScreenType) ?*Screen { + // If we're already on the requested screen we do nothing. + if (self.active_screen == t) return null; - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; - - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); + // We always end hyperlink state when switching screens. + // We need to do this on the original screen. + self.screen.endHyperlink(); // Switch the screens const old = self.screen; self.screen = self.secondary_screen; self.secondary_screen = old; - self.active_screen = .alternate; + self.active_screen = t; + + // The new screen should not have any hyperlinks set + assert(self.screen.cursor.hyperlink_id == 0); // Bring our charset state with us self.screen.charset = old.charset; @@ -2555,62 +2553,122 @@ pub fn alternateScreen( // Clear our selection self.screen.clearSelection(); - // Mark kitty images as dirty so they redraw + // Mark kitty images as dirty so they redraw. Without this set + // the images will remain where they were (the dirty bit on + // the screen only tracks the terminal grid, not the images). self.screen.kitty_images.dirty = true; - // Mark our terminal as dirty + // Mark our terminal as dirty to redraw the grid. self.flags.dirty.clear = true; - // Bring our pen with us - self.screen.cursorCopy(old.cursor, .{ - .hyperlink = false, - }) catch |err| { - log.warn("cursor copy failed entering alt screen err={}", .{err}); - }; + return &self.secondary_screen; +} - if (options.clear_on_enter) { - self.eraseDisplay(.complete, false); +/// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). +/// This is a much more opinionated operation than `switchScreen` +/// since it also handles the behaviors of the specific mode, +/// such as clearing the screen, saving/restoring the cursor, +/// etc. +/// +/// This should be used for legacy compatibility with VT protocols, +/// but more modern usage should use `switchScreen` instead and handle +/// details like clearing the screen, cursor saving, etc. manually. +pub fn switchScreenMode( + self: *Terminal, + mode: SwitchScreenMode, + enabled: bool, +) void { + // The behavior in this function is completely based on reading + // the xterm source, specifically "charproc.c" for + // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. + // We shouldn't touch anything in here without adding a unit + // test AND verifying the behavior with xterm. + + switch (mode) { + .@"47" => {}, + + // If we're disabling 1047 and we're on alt screen then + // we clear the screen. + .@"1047" => if (!enabled and self.active_screen == .alternate) { + self.eraseDisplay(.complete, false); + }, + + // 1049 unconditionally saves the cursor on enabling, even + // if we're already on the alternate screen. + .@"1049" => if (enabled) self.saveCursor(), + } + + // Switch screens first to whatever we're going to. + const to: ScreenType = if (enabled) .alternate else .primary; + const old_ = self.switchScreen(to); + + switch (mode) { + // For these modes, we need to copy the cursor. We only copy + // the cursor if the screen actually changed, otherwise the + // cursor is already copied. The cursor is copied regardless + // of destination screen. + .@"47", .@"1047" => if (old_) |old| { + self.screen.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + }, + + // Mode 1049 restores cursor on the primary screen when + // we disable it. + .@"1049" => if (enabled) { + assert(self.active_screen == .alternate); + self.eraseDisplay(.complete, false); + + // When we enter alt screen with 1049, we always copy the + // cursor from the primary screen (if we weren't already + // on it). + if (old_) |old| { + self.screen.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + } + } else { + assert(self.active_screen == .primary); + self.restoreCursor() catch |err| { + log.warn( + "restore cursor on switch screen failed to={} err={}", + .{ to, err }, + ); + }; + }, } } -/// Switch back to the primary screen (reset alternate screen mode). -pub fn primaryScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("primary screen active={} options={}", .{ self.active_screen, options }); +/// Modal screen changes. These map to the literal terminal +/// modes to enable or disable alternate screen modes. They each +/// have subtle behaviors so we define them as an enum here. +pub const SwitchScreenMode = enum { + /// Legacy alternate screen mode. This goes to the alternate + /// screen or primary screen and only copies the cursor. The + /// screen is not erased. + @"47", - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; + /// Alternate screen mode where the alternate screen is cleared + /// on exit. The primary screen is never cleared. The cursor is + /// copied. + @"1047", - if (options.clear_on_exit) self.eraseDisplay(.complete, false); - - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; - - // Clear our selection - self.screen.clearSelection(); - - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; - - // Mark our terminal as dirty - self.flags.dirty.clear = true; - - // We always end hyperlink state - self.screen.endHyperlink(); - - // Restore the cursor from the primary screen. This should not - // fail because we should not have to allocate memory since swapping - // screens does not create new cursors. - if (options.cursor_save) self.restoreCursor() catch |err| { - log.warn("restore cursor on primary screen failed err={}", .{err}); - }; -} + /// Save primary screen cursor, switch to alternate screen, + /// and clear the alternate screen on entry. On exit, + /// do not clear the screen, and restore the cursor on the + /// primary screen. + @"1049", +}; /// Return the current string value of the terminal. Newlines are /// encoded as "\n". This omits any formatting such as fg/bg. @@ -9203,37 +9261,6 @@ test "Terminal: saveCursor" { try testing.expect(t.modes.get(.origin)); } -test "Terminal: saveCursor with screen change" { - const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); - defer t.deinit(alloc); - - try t.setAttribute(.{ .bold = {} }); - t.setCursorPos(t.screen.cursor.y + 1, 3); - try testing.expect(t.screen.cursor.x == 2); - t.screen.charset.gr = .G3; - t.modes.set(.origin, true); - t.alternateScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - // make sure our cursor and charset have come with us - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.cursor.x == 2); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); - t.screen.charset.gr = .G0; - try t.setAttribute(.{ .reset_bold = {} }); - t.modes.set(.origin, false); - t.primaryScreen(.{ - .cursor_save = true, - .clear_on_enter = true, - }); - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.charset.gr == .G3); - try testing.expect(t.modes.get(.origin)); -} - test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 5 }); @@ -10472,7 +10499,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt - t.alternateScreen(.{}); + t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); t.markSemanticPrompt(.prompt); try testing.expect(!t.cursorIsAtPrompt()); @@ -10556,7 +10583,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - t.alternateScreen(.{}); + t.switchScreenMode(.@"1049", true); t.screen.kitty_keyboard.push(.{ .disambiguate = true, .report_events = false, @@ -10564,7 +10591,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { .report_all = true, .report_associated = true, }); - t.primaryScreen(.{}); + t.switchScreenMode(.@"1049", false); t.fullReset(); try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); @@ -10869,3 +10896,236 @@ test "Terminal: DECCOLM resets scroll region" { try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); } + +test "Terminal: mode 47 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"47", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should retain content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } +} + +test "Terminal: mode 47 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"47", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Verify that our style is set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + t.switchScreenMode(.@"47", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Verify that our style is still set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1047 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"1047", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 1047 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: mode 1047 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1047", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Verify that our style is set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + t.switchScreenMode(.@"1047", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Verify that our style is still set + { + try testing.expect(t.screen.cursor.style_id != style.default_id); + const page = &t.screen.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1049 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + t.switchScreenMode(.@"1049", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + t.switchScreenMode(.@"1049", false); + try testing.expectEqual(ScreenType.primary, t.active_screen); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Write, our cursor should be restored back. + try t.printString("C"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1AC", str); + } + + // Go back to alt screen with mode 1049 + t.switchScreenMode(.@"1049", true); + try testing.expectEqual(ScreenType.alternate, t.active_screen); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ffd00e14d..96565b30d 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -583,42 +583,17 @@ pub const StreamHandler = struct { }, .alt_screen_legacy => { - if (enabled) - self.terminal.alternateScreen(.{}) - else - self.terminal.primaryScreen(.{}); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"47", enabled); try self.queueRender(); }, .alt_screen => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = false, - .clear_on_enter = false, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"1047", enabled); try self.queueRender(); }, .alt_screen_save_cursor_clear_enter => { - const opts: terminal.Terminal.AlternateScreenOptions = .{ - .cursor_save = true, - .clear_on_enter = true, - }; - - if (enabled) - self.terminal.alternateScreen(opts) - else - self.terminal.primaryScreen(opts); - - // Schedule a render since we changed screens + self.terminal.switchScreenMode(.@"1049", enabled); try self.queueRender(); }, From 891b23917b3bbd0c07bffa96ae53da5dad062fd7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 29 May 2025 16:03:01 -0700 Subject: [PATCH 110/245] input: "ignore" binding action are still be processed by the OS/GUI Related to #7468 This changes the behavior of "ignore". Previously, Ghostty would consider "ignore" actions consumed but do nothing. They were like a black hole. Now, Ghostty returns `ignored` which lets the apprt forward the event to the OS/GUI. This enables keys that would otherwise be pty-encoded to be processed later, such as for GTK to show the GTK inspector. --- src/Surface.zig | 18 ++++++++++++------ src/input/Binding.zig | 11 +++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 01639964b..62a0ce549 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2069,12 +2069,18 @@ fn maybeHandleBinding( break :performed try self.performBindingAction(action); }; - // If we performed an action and it was a closing action, - // our "self" pointer is not safe to use anymore so we need to - // just exit immediately. - if (performed and closingAction(action)) { - log.debug("key binding is a closing binding, halting key event processing", .{}); - return .closed; + if (performed) { + // If we performed an action and it was a closing action, + // our "self" pointer is not safe to use anymore so we need to + // just exit immediately. + if (closingAction(action)) { + log.debug("key binding is a closing binding, halting key event processing", .{}); + return .closed; + } + + // If our action was "ignore" then we return the special input + // effect of "ignored". + if (action == .ignore) return .ignored; } // If we have the performable flag and the action was not performed, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 3818d99a6..bda0cfd47 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -222,13 +222,20 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { /// The set of actions that a keybinding can take. pub const Action = union(enum) { - /// Ignore this key combination, don't send it to the child process, just - /// black hole it. + /// Ignore this key combination, don't send it to the child process, + /// pretend that it never happened at the Ghostty level. The key + /// combination may still be processed by the OS or other + /// applications. ignore, /// This action is used to flag that the binding should be removed from /// the set. This should never exist in an active set and `set.put` has an /// assertion to verify this. + /// + /// This is only able to unbind bindings that were previously + /// bound to Ghostty. This cannot unbind bindings that were not + /// bound by Ghostty (e.g. bindings set by the OS or some other + /// application). unbind, /// Send a CSI sequence. The value should be the CSI sequence without the From 4d18c06804f1567c5b3efad60331c636f3eb4f28 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 14:04:14 +0200 Subject: [PATCH 111/245] gtk(wayland): customize keyboard interactivity for quick terminal Fixes #7476 --- src/apprt/gtk/Window.zig | 2 ++ src/apprt/gtk/winproto/wayland.zig | 28 ++++++++++++++++-------- src/config/Config.zig | 35 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index aa1f0a4b1..d9d2da057 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -90,6 +90,7 @@ pub const DerivedConfig = struct { quick_terminal_position: configpkg.Config.QuickTerminalPosition, quick_terminal_size: configpkg.Config.QuickTerminalSize, quick_terminal_autohide: bool, + quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity, maximize: bool, fullscreen: bool, @@ -109,6 +110,7 @@ pub const DerivedConfig = struct { .quick_terminal_position = config.@"quick-terminal-position", .quick_terminal_size = config.@"quick-terminal-size", .quick_terminal_autohide = config.@"quick-terminal-autohide", + .quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity", .maximize = config.maximize, .fullscreen = config.fullscreen, diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5f5feca6e..5a4f24ff7 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -110,7 +110,6 @@ pub const App = struct { gtk4_layer_shell.initForWindow(window); gtk4_layer_shell.setLayer(window, .top); - gtk4_layer_shell.setKeyboardMode(window, .on_demand); } fn registryListener( @@ -356,9 +355,9 @@ pub const Window = struct { fn syncQuickTerminal(self: *Window) !void { const window = self.apprt_window.window.as(gtk.Window); - const position = self.apprt_window.config.quick_terminal_position; + const config = &self.apprt_window.config; - const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (position) { + const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (config.quick_terminal_position) { .left => .left, .right => .right, .top => .top, @@ -366,6 +365,15 @@ pub const Window = struct { .center => null, }; + gtk4_layer_shell.setKeyboardMode( + window, + switch (config.quick_terminal_keyboard_interactivity) { + .none => .none, + .@"on-demand" => .on_demand, + .exclusive => .exclusive, + }, + ); + for (std.meta.tags(gtk4_layer_shell.ShellEdge)) |edge| { if (anchored_edge) |anchored| { if (edge == anchored) { @@ -412,16 +420,18 @@ pub const Window = struct { apprt_window: *ApprtWindow, ) callconv(.c) void { const window = apprt_window.window.as(gtk.Window); - const size = apprt_window.config.quick_terminal_size; - const position = apprt_window.config.quick_terminal_position; + const config = &apprt_window.config; var monitor_size: gdk.Rectangle = undefined; monitor.getGeometry(&monitor_size); - const dims = size.calculate(position, .{ - .width = @intCast(monitor_size.f_width), - .height = @intCast(monitor_size.f_height), - }); + const dims = config.quick_terminal_size.calculate( + config.quick_terminal_position, + .{ + .width = @intCast(monitor_size.f_width), + .height = @intCast(monitor_size.f_height), + }, + ); window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); } diff --git a/src/config/Config.zig b/src/config/Config.zig index a20719a8f..b59334160 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1808,6 +1808,34 @@ keybind: Keybinds = .{}, /// On Linux the behavior is always equivalent to `move`. @"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, +/// Determines under which circumstances that the quick terminal should receive +/// keyboard input. See the corresponding [Wayland documentation](https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:enum:keyboard_interactivity) +/// for a more detailed explanation of the behavior of each option. +/// +/// > [!NOTE] +/// > The exact behavior of each option may differ significantly across +/// > compositors -- experiment with them on your system to find one that +/// > suits your liking! +/// +/// Valid values are: +/// +/// * `none` +/// +/// The quick terminal will not receive any keyboard input. +/// +/// * `on-demand` (default) +/// +/// The quick terminal would only receive keyboard input when it is focused. +/// +/// * `exclusive` +/// +/// The quick terminal will always receive keyboard input, even when another +/// window is currently focused. +/// +/// Only has an effect on Linux Wayland. +/// On macOS the behavior is always equivalent to `on-demand`. +@"quick-terminal-keyboard-interactivity": QuickTerminalKeyboardInteractivity = .@"on-demand", + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -6138,6 +6166,13 @@ pub const QuickTerminalSpaceBehavior = enum { move, }; +/// See quick-terminal-keyboard-interactivity +pub const QuickTerminalKeyboardInteractivity = enum { + none, + @"on-demand", + exclusive, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, From 6fac355363abb63c148adbb99f7b4a430cb494a5 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 14:44:29 +0200 Subject: [PATCH 112/245] gtk(wayland): fallback when on-demand mode isn't supported This shouldn't be a real problem anymore since as of now (May 2025) all major compositors support at least version 4, but let's do this just in case. --- pkg/gtk4-layer-shell/src/main.zig | 4 ++++ src/apprt/gtk/winproto/wayland.zig | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index 88d99772b..d7eafa135 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -27,6 +27,10 @@ pub fn isSupported() bool { return c.gtk_layer_is_supported() != 0; } +pub fn getProtocolVersion() c_uint { + return c.gtk_layer_get_protocol_version(); +} + pub fn initForWindow(window: *gtk.Window) void { c.gtk_layer_init_for_window(@ptrCast(window)); } diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5a4f24ff7..e6861b1ed 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -369,7 +369,13 @@ pub const Window = struct { window, switch (config.quick_terminal_keyboard_interactivity) { .none => .none, - .@"on-demand" => .on_demand, + .@"on-demand" => on_demand: { + if (gtk4_layer_shell.getProtocolVersion() < 4) { + log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); + break :on_demand .exclusive; + } + break :on_demand .on_demand; + }, .exclusive => .exclusive, }, ); From 71a1ece7e91112a9f3dc7c57ef31a5bea7b0897c Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 14:50:26 +0200 Subject: [PATCH 113/245] gtk(wayland): gtk4-layer-shell -> layer-shell It was getting a bit too unwieldy. --- src/apprt/gtk/winproto/wayland.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index e6861b1ed..a8eaa5be7 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -6,8 +6,8 @@ const build_options = @import("build_options"); const gdk = @import("gdk"); const gdk_wayland = @import("gdk_wayland"); const gobject = @import("gobject"); -const gtk4_layer_shell = @import("gtk4-layer-shell"); const gtk = @import("gtk"); +const layer_shell = @import("gtk4-layer-shell"); const wayland = @import("wayland"); const Config = @import("../../../config.zig").Config; @@ -98,7 +98,7 @@ pub const App = struct { } pub fn supportsQuickTerminal(_: App) bool { - if (!gtk4_layer_shell.isSupported()) { + if (!layer_shell.isSupported()) { log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); return false; } @@ -108,8 +108,8 @@ pub const App = struct { pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { const window = apprt_window.window.as(gtk.Window); - gtk4_layer_shell.initForWindow(window); - gtk4_layer_shell.setLayer(window, .top); + layer_shell.initForWindow(window); + layer_shell.setLayer(window, .top); } fn registryListener( @@ -357,7 +357,7 @@ pub const Window = struct { const window = self.apprt_window.window.as(gtk.Window); const config = &self.apprt_window.config; - const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (config.quick_terminal_position) { + const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { .left => .left, .right => .right, .top => .top, @@ -365,12 +365,12 @@ pub const Window = struct { .center => null, }; - gtk4_layer_shell.setKeyboardMode( + layer_shell.setKeyboardMode( window, switch (config.quick_terminal_keyboard_interactivity) { .none => .none, .@"on-demand" => on_demand: { - if (gtk4_layer_shell.getProtocolVersion() < 4) { + if (layer_shell.getProtocolVersion() < 4) { log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); break :on_demand .exclusive; } @@ -380,18 +380,18 @@ pub const Window = struct { }, ); - for (std.meta.tags(gtk4_layer_shell.ShellEdge)) |edge| { + for (std.meta.tags(layer_shell.ShellEdge)) |edge| { if (anchored_edge) |anchored| { if (edge == anchored) { - gtk4_layer_shell.setMargin(window, edge, 0); - gtk4_layer_shell.setAnchor(window, edge, true); + layer_shell.setMargin(window, edge, 0); + layer_shell.setAnchor(window, edge, true); continue; } } // Arbitrary margin - could be made customizable? - gtk4_layer_shell.setMargin(window, edge, 20); - gtk4_layer_shell.setAnchor(window, edge, false); + layer_shell.setMargin(window, edge, 20); + layer_shell.setAnchor(window, edge, false); } if (self.apprt_window.isQuickTerminal()) { From dee7c835deaaeb9cbc76077a06efe393e5f6e8db Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 14:53:35 +0200 Subject: [PATCH 114/245] gtk(wayland): remove redundant check --- src/apprt/gtk/winproto/wayland.zig | 52 ++++++++++++++---------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index a8eaa5be7..483a09d3c 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -357,14 +357,6 @@ pub const Window = struct { const window = self.apprt_window.window.as(gtk.Window); const config = &self.apprt_window.config; - const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - .center => null, - }; - layer_shell.setKeyboardMode( window, switch (config.quick_terminal_keyboard_interactivity) { @@ -380,6 +372,14 @@ pub const Window = struct { }, ); + const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + .center => null, + }; + for (std.meta.tags(layer_shell.ShellEdge)) |edge| { if (anchored_edge) |anchored| { if (edge == anchored) { @@ -394,29 +394,27 @@ pub const Window = struct { layer_shell.setAnchor(window, edge, false); } - if (self.apprt_window.isQuickTerminal()) { - if (self.slide) |slide| slide.release(); + if (self.slide) |slide| slide.release(); - self.slide = if (anchored_edge) |anchored| slide: { - const mgr = self.app_context.kde_slide_manager orelse break :slide null; + self.slide = if (anchored_edge) |anchored| slide: { + const mgr = self.app_context.kde_slide_manager orelse break :slide null; - const slide = mgr.create(self.surface) catch |err| { - log.warn("could not create slide object={}", .{err}); - break :slide null; - }; + const slide = mgr.create(self.surface) catch |err| { + log.warn("could not create slide object={}", .{err}); + break :slide null; + }; - const slide_location: org.KdeKwinSlide.Location = switch (anchored) { - .top => .top, - .bottom => .bottom, - .left => .left, - .right => .right, - }; + const slide_location: org.KdeKwinSlide.Location = switch (anchored) { + .top => .top, + .bottom => .bottom, + .left => .left, + .right => .right, + }; - slide.setLocation(@intCast(@intFromEnum(slide_location))); - slide.commit(); - break :slide slide; - } else null; - } + slide.setLocation(@intCast(@intFromEnum(slide_location))); + slide.commit(); + break :slide slide; + } else null; } /// Update the size of the quick terminal based on monitor dimensions. From 6959fa84387397b6cb490acbc0fb1212f51c376a Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 15:02:01 +0200 Subject: [PATCH 115/245] gtk(wayland): explicitly set layer name Even though gtk4-layer-shell's documentation claims that "nobody quite knows what it's for", some compositors (like Niri) can define custom rules based on the layer name and it's beneficial in those cases to define a distinct name just for our quick terminals. --- pkg/gtk4-layer-shell/src/main.zig | 4 ++++ src/apprt/gtk/winproto/wayland.zig | 1 + 2 files changed, 5 insertions(+) diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index d7eafa135..06936bba2 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -50,3 +50,7 @@ pub fn setMargin(window: *gtk.Window, edge: ShellEdge, margin_size: c_int) void pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void { c.gtk_layer_set_keyboard_mode(@ptrCast(window), @intFromEnum(mode)); } + +pub fn setNamespace(window: *gtk.Window, name: [:0]const u8) void { + c.gtk_layer_set_namespace(@ptrCast(window), name.ptr); +} diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 483a09d3c..b718609e3 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -110,6 +110,7 @@ pub const App = struct { layer_shell.initForWindow(window); layer_shell.setLayer(window, .top); + layer_shell.setNamespace(window, "ghostty-quick-terminal"); } fn registryListener( From 90f431005b877704231ffc1ca437658881e333b6 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 21:23:10 +0200 Subject: [PATCH 116/245] gtk: request user attention on bell I'm not sure if this should be enabled by default like the tab animation, but on KDE at least this is unintrusive enough for me to always enable by default. Alacritty appears to agree with me as well. --- src/apprt/gtk/Surface.zig | 5 +++ src/apprt/gtk/Window.zig | 12 ++++-- src/apprt/gtk/winproto.zig | 6 +++ src/apprt/gtk/winproto/noop.zig | 2 + src/apprt/gtk/winproto/wayland.zig | 61 +++++++++++++++++++++++++++--- src/apprt/gtk/winproto/x11.zig | 29 +++++++------- src/build/SharedDeps.zig | 4 +- 7 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 1ee00ff1b..3d16e9fbb 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2447,6 +2447,11 @@ pub fn ringBell(self: *Surface) !void { // Need attention if we're not the currently selected tab if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); } + + // Request user attention + window.winproto.setUrgent(true) catch |err| { + log.err("failed to request user attention={}", .{err}); + }; } /// Handle a stream that is in an error state. diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index aa1f0a4b1..41eae3d85 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -814,11 +814,15 @@ fn gtkWindowNotifyIsActive( _: *gobject.ParamSpec, self: *Window, ) callconv(.c) void { - if (!self.isQuickTerminal()) return; + self.winproto.setUrgent(false) catch |err| { + log.err("failed to unrequest user attention={}", .{err}); + }; - // Hide when we're unfocused - if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { - self.toggleVisibility(); + if (self.isQuickTerminal()) { + // Hide when we're unfocused + if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { + self.toggleVisibility(); + } } } diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index ff83e6851..2dbe5a7a0 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -146,4 +146,10 @@ pub const Window = union(Protocol) { inline else => |*v| try v.addSubprocessEnv(env), } } + + pub fn setUrgent(self: *Window, urgent: bool) !void { + switch (self.*) { + inline else => |*v| try v.setUrgent(urgent), + } + } }; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 5cb5887c9..fb732b756 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -70,4 +70,6 @@ pub const Window = struct { } pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} + + pub fn setUrgent(_: *Window, _: bool) !void {} }; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 5f5feca6e..98b0ee238 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -16,6 +16,7 @@ const ApprtWindow = @import("../Window.zig"); const wl = wayland.client.wl; const org = wayland.client.org; +const xdg = wayland.client.xdg; const log = std.log.scoped(.winproto_wayland); @@ -34,6 +35,8 @@ pub const App = struct { kde_slide_manager: ?*org.KdeKwinSlideManager = null, default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, + + xdg_activation: ?*xdg.ActivationV1 = null, }; pub fn init( @@ -150,6 +153,15 @@ pub const App = struct { context.kde_slide_manager = slide_manager; return; } + + if (registryBind( + xdg.ActivationV1, + registry, + global, + )) |activation| { + context.xdg_activation = activation; + return; + } }, // We don't handle removal events @@ -207,15 +219,19 @@ pub const Window = struct { app_context: *App.Context, /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur, + blur_token: ?*org.KdeKwinBlur = null, /// Object that controls the decoration mode (client/server/auto) /// of the window. - decoration: ?*org.KdeKwinServerDecoration, + decoration: ?*org.KdeKwinServerDecoration = null, /// Object that controls the slide-in/slide-out animations of the /// quick terminal. Always null for windows other than the quick terminal. - slide: ?*org.KdeKwinSlide, + slide: ?*org.KdeKwinSlide = null, + + /// Object that, when present, denotes that the window is currently + /// requesting attention from the user. + activation_token: ?*xdg.ActivationTokenV1 = null, pub fn init( alloc: Allocator, @@ -268,9 +284,7 @@ pub const Window = struct { .apprt_window = apprt_window, .surface = wl_surface, .app_context = app.context, - .blur_token = null, .decoration = deco, - .slide = null, }; } @@ -315,6 +329,21 @@ pub const Window = struct { _ = env; } + pub fn setUrgent(self: *Window, urgent: bool) !void { + const activation = self.app_context.xdg_activation orelse return; + + // If there already is a token, destroy and unset it + if (self.activation_token) |token| token.destroy(); + + self.activation_token = if (urgent) token: { + const token = try activation.getActivationToken(); + token.setSurface(self.surface); + token.setListener(*Window, onActivationTokenEvent, self); + token.commit(); + break :token token; + } else null; + } + /// Update the blur state of the window. fn syncBlur(self: *Window) !void { const manager = self.app_context.kde_blur_manager orelse return; @@ -425,4 +454,26 @@ pub const Window = struct { window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); } + + fn onActivationTokenEvent( + token: *xdg.ActivationTokenV1, + event: xdg.ActivationTokenV1.Event, + self: *Window, + ) void { + const activation = self.app_context.xdg_activation orelse return; + const current_token = self.activation_token orelse return; + + if (token.getId() != current_token.getId()) { + log.warn("received event for unknown activation token; ignoring", .{}); + return; + } + + switch (event) { + .done => |done| { + activation.activate(done.token, self.surface); + token.destroy(); + self.activation_token = null; + }, + } + } }; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 387905b18..2c4925167 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -176,8 +176,8 @@ pub const App = struct { pub const Window = struct { app: *App, config: *const ApprtWindow.DerivedConfig, - window: xlib.Window, gtk_window: *adw.ApplicationWindow, + x11_surface: *gdk_x11.X11Surface, blur_region: Region = .{}, @@ -192,13 +192,6 @@ pub const Window = struct { gtk.Native, ).getSurface() orelse return error.NotX11Surface; - // Check if we're actually on X11 - if (gobject.typeCheckInstanceIsA( - surface.as(gobject.TypeInstance), - gdk_x11.X11Surface.getGObjectType(), - ) == 0) - return error.NotX11Surface; - const x11_surface = gobject.ext.cast( gdk_x11.X11Surface, surface, @@ -207,8 +200,8 @@ pub const Window = struct { return .{ .app = app, .config = &apprt_window.config, - .window = x11_surface.getXid(), .gtk_window = apprt_window.window, + .x11_surface = x11_surface, }; } @@ -279,7 +272,7 @@ pub const Window = struct { const blur = self.config.background_blur; log.debug("set blur={}, window xid={}, region={}", .{ blur, - self.window, + self.x11_surface.getXid(), self.blur_region, }); @@ -335,11 +328,19 @@ pub const Window = struct { pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { var buf: [64]u8 = undefined; - const window_id = try std.fmt.bufPrint(&buf, "{}", .{self.window}); + const window_id = try std.fmt.bufPrint( + &buf, + "{}", + .{self.x11_surface.getXid()}, + ); try env.put("WINDOWID", window_id); } + pub fn setUrgent(self: *Window, urgent: bool) !void { + self.x11_surface.setUrgencyHint(@intFromBool(urgent)); + } + fn getWindowProperty( self: *Window, comptime T: type, @@ -363,7 +364,7 @@ pub const Window = struct { const code = c.XGetWindowProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, options.offset, options.length, @@ -401,7 +402,7 @@ pub const Window = struct { const status = c.XChangeProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, typ, @intFromEnum(format), @@ -419,7 +420,7 @@ pub const Window = struct { fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { const status = c.XDeleteProperty( @ptrCast(@alignCast(self.app.display)), - self.window, + self.x11_surface.getXid(), name, ); if (status == 0) return error.RequestFailed; diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index d3741a358..5d737cb6f 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -609,21 +609,23 @@ fn addGTK( .wayland_protocols = wayland_protocols_dep.path(""), }); - // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/blur.xml"), ); + // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"), ); scanner.addCustomProtocol( plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), ); + scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); scanner.generate("wl_compositor", 1); scanner.generate("org_kde_kwin_blur_manager", 1); scanner.generate("org_kde_kwin_server_decoration_manager", 1); scanner.generate("org_kde_kwin_slide_manager", 1); + scanner.generate("xdg_activation_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ .root_source_file = scanner.result, From 8be5a78585a1185e355ed52ee64fa552655f07ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 May 2025 14:15:20 -0700 Subject: [PATCH 117/245] config: more robust handling of font-family overwrite for CLI args Fixes #7481 We unfortunately don't have a great way to unit test this since our logic relies on argv and I'm too lazy to extract it out right now. The core issue is that we previously detected if font-families changed by comparing lengths. This doesn't work because the CLI args can reset and add families back to a lesser length. This caused an integer overflow. We can fix this by not being clever and introducing the overwrite logic directly into the config type. I unit tested that. --- src/config/Config.zig | 64 +++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index a20719a8f..ce4e46df1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2722,19 +2722,18 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // can replay if we are discarding the default files. const replay_len_start = self._replay_steps.items.len; - // Keep track of font families because if they are set from the CLI - // then we clear the previously set values. This avoids a UX oddity - // where on the CLI you have to specify `font-family=""` to clear the - // font families before setting a new one. + // font-family settings set via the CLI overwrite any prior values + // rather than append. This avoids a UX oddity where you have to + // specify `font-family=""` to clear the font families. const fields = &[_][]const u8{ "font-family", "font-family-bold", "font-family-italic", "font-family-bold-italic", }; - var counter: [fields.len]usize = undefined; - inline for (fields, 0..) |field, i| { - counter[i] = @field(self, field).list.items.len; + inline for (fields) |field| @field(self, field).overwrite_next = true; + defer { + inline for (fields) |field| @field(self, field).overwrite_next = false; } // Initialize our CLI iterator. @@ -2759,28 +2758,6 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { try new_config.loadIter(alloc_gpa, &it); self.deinit(); self.* = new_config; - } else { - // If any of our font family settings were changed, then we - // replace the entire list with the new list. - inline for (fields, 0..) |field, i| { - const v = &@field(self, field); - - // 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; - } - } - } } // Any paths referenced from the CLI are relative to the current working @@ -4172,6 +4149,11 @@ pub const RepeatableString = struct { // Allocator for the list is the arena for the parent config. list: std.ArrayListUnmanaged([:0]const u8) = .{}, + // If true, then the next value will clear the list and start over + // rather than append. This is a bit of a hack but is here to make + // the font-family set of configurations work with CLI parsing. + overwrite_next: bool = false, + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; @@ -4181,6 +4163,12 @@ pub const RepeatableString = struct { return; } + // If we're overwriting then we clear before appending + if (self.overwrite_next) { + self.list.clearRetainingCapacity(); + self.overwrite_next = false; + } + const copy = try alloc.dupeZ(u8, value); try self.list.append(alloc, copy); } @@ -4247,6 +4235,24 @@ pub const RepeatableString = struct { try testing.expectEqual(@as(usize, 0), list.list.items.len); } + test "parseCLI overwrite" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "A"); + + // Set our overwrite flag + list.overwrite_next = true; + + try list.parseCLI(alloc, "B"); + try testing.expectEqual(@as(usize, 1), list.list.items.len); + try list.parseCLI(alloc, "C"); + try testing.expectEqual(@as(usize, 2), list.list.items.len); + } + test "formatConfig empty" { const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); From 34f08a450e8abd8f5c0331e03befa87eb1276f77 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 30 May 2025 14:59:53 -0600 Subject: [PATCH 118/245] font: rework coretext discovery sorting This should make the sorting more robust to fonts with questionable metadata or atypical style names. I was originally just going to change the scoring slightly to account for fonts whose regular italic style is named "Regular Italic" - which previously resulted in the Bold Italic or Thin Italic style being chosen instead because they're shorter names, but I decided to do some better inspection of the metadata and looser style name matching while I was changing code here anyway. Also adds a unit test to verify the sorting works correctly, though a more comprehensive set of tests may be desirable in the future. --- src/font/discovery.zig | 432 ++++++++++++++++++++++++++++++----------- 1 file changed, 316 insertions(+), 116 deletions(-) diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 384799da5..9284f9486 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const fontconfig = @import("fontconfig"); const macos = @import("macos"); +const opentype = @import("opentype.zig"); const options = @import("main.zig").options; const Collection = @import("main.zig").Collection; const DeferredFace = @import("main.zig").DeferredFace; @@ -562,149 +563,266 @@ pub const CoreText = struct { desc: *const Descriptor, list: []*macos.text.FontDescriptor, ) void { - var desc_mut = desc.*; - if (desc_mut.style == null) { - // If there is no explicit style set, we set a preferred - // based on the style bool attributes. - // - // TODO: doesn't handle i18n font names well, we should have - // another mechanism that uses the weight attribute if it exists. - // Wait for this to be a real problem. - desc_mut.style = if (desc_mut.bold and desc_mut.italic) - "Bold Italic" - else if (desc_mut.bold) - "Bold" - else if (desc_mut.italic) - "Italic" - else - null; - } - - std.mem.sortUnstable(*macos.text.FontDescriptor, list, &desc_mut, struct { + std.mem.sortUnstable(*macos.text.FontDescriptor, list, desc, struct { fn lessThan( desc_inner: *const Descriptor, lhs: *macos.text.FontDescriptor, rhs: *macos.text.FontDescriptor, ) bool { - const lhs_score = score(desc_inner, lhs); - const rhs_score = score(desc_inner, rhs); + const lhs_score: Score = .score(desc_inner, lhs); + const rhs_score: Score = .score(desc_inner, rhs); // Higher score is "less" (earlier) return lhs_score.int() > rhs_score.int(); } }.lessThan); } - /// We represent our sorting score as a packed struct so that we can - /// compare scores numerically but build scores symbolically. + /// We represent our sorting score as a packed struct so that we + /// can compare scores numerically but build scores symbolically. + /// + /// Note that packed structs store their fields from least to most + /// significant, so the fields here are defined in increasing order + /// of precedence. const Score = packed struct { const Backing = @typeInfo(@This()).@"struct".backing_integer.?; - glyph_count: u16 = 0, // clamped if > intmax - traits: Traits = .unmatched, - style: Style = .unmatched, + /// Number of glyphs in the font, if two fonts have identical + /// scores otherwise then we prefer the one with more glyphs. + /// + /// (Number of glyphs clamped at u16 intmax) + glyph_count: u16 = 0, + /// A fuzzy match on the style string, less important than + /// an exact match, and less important than trait matches. + fuzzy_style: u8 = 0, + /// Whether the bold-ness of the font matches the descriptor. + /// This is less important than italic because a font that's italic + /// when it shouldn't be or not italic when it should be is a bigger + /// problem (subjectively) than being the wrong weight. + bold: bool = false, + /// Whether the italic-ness of the font matches the descriptor. + /// This is less important than an exact match on the style string + /// because we want users to be allowed to override trait matching + /// for the bold/italic/bold italic styles if they want. + italic: bool = false, + /// An exact (case-insensitive) match on the style string. + exact_style: bool = false, + /// Whether the font is monospace, this is more important than any of + /// the other fields unless we're looking for a specific codepoint, + /// in which case that is the most important thing. monospace: bool = false, + /// If we're looking for a codepoint, whether this font has it. codepoint: bool = false, - const Traits = enum(u8) { unmatched = 0, _ }; - const Style = enum(u8) { unmatched = 0, match = 0xFF, _ }; - pub fn int(self: Score) Backing { return @bitCast(self); } - }; - fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { - var score_acc: Score = .{}; + fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score { + var self: Score = .{}; - // We always load the font if we can since some things can only be - // inspected on the font itself. - const font_: ?*macos.text.Font = macos.text.Font.createWithFontDescriptor( - ct_desc, - 12, - ) catch null; - defer if (font_) |font| font.release(); + // We always load the font if we can since some things can only be + // inspected on the font itself. Fonts that can't be loaded score + // 0 automatically because we don't want a font we can't load. + const font: *macos.text.Font = macos.text.Font.createWithFontDescriptor( + ct_desc, + 12, + ) catch return self; + defer font.release(); - // If we have a font, prefer the font with more glyphs. - if (font_) |font| { - const Type = @TypeOf(score_acc.glyph_count); - score_acc.glyph_count = std.math.cast( - Type, - font.getGlyphCount(), - ) orelse std.math.maxInt(Type); - } - - // If we're searching for a codepoint, prioritize fonts that - // have that codepoint. - if (desc.codepoint > 0) codepoint: { - const font = font_ orelse break :codepoint; - - // Turn UTF-32 into UTF-16 for CT API - var unichars: [2]u16 = undefined; - const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( - desc.codepoint, - &unichars, - ); - const len: usize = if (pair) 2 else 1; - - // Get our glyphs - var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; - score_acc.codepoint = font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len]); - } - - // Get our symbolic traits for the descriptor so we can compare - // boolean attributes like bold, monospace, etc. - const symbolic_traits: macos.text.FontSymbolicTraits = traits: { - const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; - defer traits.release(); - - const key = macos.text.FontTraitKey.symbolic.key(); - const symbolic = traits.getValue(macos.foundation.Number, key) orelse - break :traits .{}; - - break :traits macos.text.FontSymbolicTraits.init(symbolic); - }; - - score_acc.monospace = symbolic_traits.monospace; - - score_acc.style = style: { - const style = ct_desc.copyAttribute(.style_name) orelse - break :style .unmatched; - defer style.release(); - - // Get our style string - var buf: [128]u8 = undefined; - const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched; - - // If we have a specific desired style, attempt to search for that. - if (desc.style) |desired_style| { - // Matching style string gets highest score - if (std.mem.eql(u8, desired_style, style_str)) break :style .match; - } else if (!desc.bold and !desc.italic) { - // If we do not, and we have no symbolic traits, then we try - // to find "regular" (or no style). If we have symbolic traits - // we do nothing but we can improve scoring by taking that into - // account, too. - if (std.mem.eql(u8, "Regular", style_str)) { - break :style .match; - } + // We prefer fonts with more glyphs, all else being equal. + { + const Type = @TypeOf(self.glyph_count); + self.glyph_count = std.math.cast( + Type, + font.getGlyphCount(), + ) orelse std.math.maxInt(Type); } - // Otherwise the score is based on the length of the style string. - // Shorter styles are scored higher. This is a heuristic that - // if we don't have a desired style then shorter tends to be - // more often the "regular" style. - break :style @enumFromInt(100 -| style_str.len); - }; + // If we're searching for a codepoint, then we + // prioritize fonts that have that codepoint. + if (desc.codepoint > 0) { + // Turn UTF-32 into UTF-16 for CT API + var unichars: [2]u16 = undefined; + const pair = macos.foundation.stringGetSurrogatePairForLongCharacter( + desc.codepoint, + &unichars, + ); + const len: usize = if (pair) 2 else 1; - score_acc.traits = traits: { - var count: u8 = 0; - if (desc.bold == symbolic_traits.bold) count += 1; - if (desc.italic == symbolic_traits.italic) count += 1; - break :traits @enumFromInt(count); - }; + // Get our glyphs + var glyphs = [2]macos.graphics.Glyph{ 0, 0 }; + self.codepoint = font.getGlyphsForCharacters( + unichars[0..len], + glyphs[0..len], + ); + } - return score_acc; - } + // Get our symbolic traits for the descriptor so we can + // compare boolean attributes like bold, monospace, etc. + const symbolic_traits: macos.text.FontSymbolicTraits = traits: { + const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{}; + defer traits.release(); + + const key = macos.text.FontTraitKey.symbolic.key(); + const symbolic = traits.getValue(macos.foundation.Number, key) orelse + break :traits .{}; + + break :traits macos.text.FontSymbolicTraits.init(symbolic); + }; + + self.monospace = symbolic_traits.monospace; + + // We try to derived data from the font itself, which is generally + // more reliable than only using the symbolic traits for this. + const is_bold: bool, const is_italic: bool = derived: { + // We start with initial guesses based on the symbolic traits, + // but refine these with more information if we can get it. + var is_italic = symbolic_traits.italic; + var is_bold = symbolic_traits.bold; + + // Read the 'head' table out of the font data if it's available. + if (head: { + const tag = macos.text.FontTableTag.init("head"); + const data = font.copyTable(tag) orelse break :head null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :head opentype.Head.init(ptr[0..len]) catch |err| { + log.warn("error parsing head table: {}", .{err}); + break :head null; + }; + }) |head_| { + const head: opentype.Head = head_; + is_bold = is_bold or (head.macStyle & 1 == 1); + is_italic = is_italic or (head.macStyle & 2 == 2); + } + + // Read the 'OS/2' table out of the font data if it's available. + if (os2: { + const tag = macos.text.FontTableTag.init("OS/2"); + const data = font.copyTable(tag) orelse break :os2 null; + defer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :os2 opentype.OS2.init(ptr[0..len]) catch |err| { + log.warn("error parsing OS/2 table: {}", .{err}); + break :os2 null; + }; + }) |os2| { + is_bold = is_bold or os2.fsSelection.bold; + is_italic = is_italic or os2.fsSelection.italic; + } + + // Check if we have variation axes in our descriptor, if we + // do then we can derive weight italic-ness or both from them. + if (font.copyAttribute(.variation_axes)) |axes| variations: { + defer axes.release(); + + // Copy the variation values for this instance of the font. + // if there are none then we just break out immediately. + const values: *macos.foundation.Dictionary = + font.copyAttribute(.variation) orelse break :variations; + defer values.release(); + + var buf: [1024]u8 = undefined; + + // If we see the 'ital' value then we ignore 'slnt'. + var ital_seen = false; + + const len = axes.getCount(); + for (0..len) |i| { + const dict = axes.getValueAtIndex(macos.foundation.Dictionary, i); + const Key = macos.text.FontVariationAxisKey; + const cf_id = dict.getValue(Key.identifier.Value(), Key.identifier.key()).?; + const cf_name = dict.getValue(Key.name.Value(), Key.name.key()).?; + const cf_def = dict.getValue(Key.default_value.Value(), Key.default_value.key()).?; + + const name_str = cf_name.cstring(&buf, .utf8) orelse ""; + + // Default value + var def: f64 = 0; + _ = cf_def.getValue(.double, &def); + // Value in this font + var val: f64 = def; + if (values.getValue( + macos.foundation.Number, + cf_id, + )) |cf_val| _ = cf_val.getValue(.double, &val); + + if (std.mem.eql(u8, "wght", name_str)) { + // Somewhat subjective threshold, we consider fonts + // bold if they have a 'wght' set greater than 600. + is_bold = val > 600; + continue; + } + if (std.mem.eql(u8, "ital", name_str)) { + is_italic = val > 0.5; + ital_seen = true; + continue; + } + if (!ital_seen and std.mem.eql(u8, "slnt", name_str)) { + // Arbitrary threshold of anything more than a 5 + // degree clockwise slant is considered italic. + is_italic = val <= -5.0; + continue; + } + } + } + + break :derived .{ is_bold, is_italic }; + }; + + self.bold = desc.bold == is_bold; + self.italic = desc.italic == is_italic; + + // Get the style string from the font. + var style_str_buf: [128]u8 = undefined; + const style_str: []const u8 = style_str: { + const style = ct_desc.copyAttribute(.style_name) orelse + break :style_str ""; + defer style.release(); + + break :style_str style.cstring(&style_str_buf, .utf8) orelse ""; + }; + + // The first string in this slice will be used for the exact match, + // and for the fuzzy match, all matching substrings will increase + // the rank. + const desired_styles: []const [:0]const u8 = desired: { + if (desc.style) |s| break :desired &.{s}; + + // If we don't have an explicitly desired style name, we base + // it on the bold and italic properties, this isn't ideal since + // fonts may use style names other than these, but it helps in + // some edge cases. + if (desc.bold) { + if (desc.italic) break :desired &.{ "bold italic", "bold", "italic", "oblique" }; + break :desired &.{ "bold", "upright" }; + } else if (desc.italic) { + break :desired &.{ "italic", "regular", "oblique" }; + } + break :desired &.{ "regular", "upright" }; + }; + + self.exact_style = std.ascii.eqlIgnoreCase( + style_str, + desired_styles[0], + ); + // Our "fuzzy match" score is 0 if the desired style isn't present + // in the string, otherwise we give higher priority for styles that + // have fewer characters not in the desired_styles list. + const fuzzy_type = @TypeOf(self.fuzzy_style); + self.fuzzy_style = @intCast(style_str.len); + for (desired_styles) |s| { + if (std.ascii.indexOfIgnoreCase(style_str, s) != null) { + self.fuzzy_style -|= @intCast(s.len); + } + } + self.fuzzy_style = std.math.maxInt(fuzzy_type) -| self.fuzzy_style; + + return self; + } + }; pub const DiscoverIterator = struct { alloc: Allocator, @@ -837,3 +955,85 @@ test "coretext codepoint" { // Should have other codepoints too try testing.expect(face.hasCodepoint('B', null)); } + +test "coretext sorting" { + if (options.backend != .coretext and options.backend != .coretext_freetype) + return error.SkipZigTest; + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + // FIXME: Disabled for now because SF Pro is not available in CI + // The solution likely involves directly testing that the + // `sortMatchingDescriptors` function sorts a bundled test + // font correctly, instead of relying on the system fonts. + if (true) return error.SkipZigTest; + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!// + + const testing = std.testing; + const alloc = testing.allocator; + + var ct = CoreText.init(); + defer ct.deinit(); + + // We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro, + // which should be installed on all Macs, and has many styles which makes + // it a good test, since there will be many results for each discovery. + + // Regular + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular", name); + } + + // Regular Italic + // + // NOTE: This makes sure that we don't accidentally prefer "Thin Italic", + // which we previously did, because it has a shorter name. + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Regular Italic", name); + } + + // Bold + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold", name); + } + + // Bold Italic + { + var it = try ct.discover(alloc, .{ + .family = "SF Pro", + .size = 12, + .bold = true, + .italic = true, + }); + defer it.deinit(); + const res = (try it.next()).?; + var buf: [1024]u8 = undefined; + const name = try res.name(&buf); + try testing.expectEqualStrings("SF Pro Bold Italic", name); + } +} From 9ded668819910ae6f3245f78f17645131cac49fe Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 22:56:10 +0200 Subject: [PATCH 119/245] gtk(wayland,x11): remove even more redundant checks --- src/apprt/gtk/winproto/wayland.zig | 114 +++++++++++------------------ src/apprt/gtk/winproto/x11.zig | 7 +- 2 files changed, 45 insertions(+), 76 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 594a3382a..08f4858a5 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -76,9 +76,9 @@ pub const App = struct { registry.setListener(*Context, registryListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - if (context.kde_decoration_manager != null) { - // FIXME: Roundtrip again because we have to wait for the decoration - // manager to respond with the preferred default mode. Ew. + // Do another round-trip to get the default decoration mode + if (context.kde_decoration_manager) |deco_manager| { + deco_manager.setListener(*Context, decoManagerListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; } @@ -121,80 +121,54 @@ pub const App = struct { event: wl.Registry.Event, context: *Context, ) void { - switch (event) { - // https://wayland.app/protocols/wayland#wl_registry:event:global - .global => |global| { - log.debug("wl_registry.global: interface={s}", .{global.interface}); + inline for (@typeInfo(Context).@"struct".fields) |field| { + // Globals should be optional pointers + const T = switch (@typeInfo(field.type)) { + .optional => |o| switch (@typeInfo(o.child)) { + .pointer => |v| v.child, + else => continue, + }, + else => continue, + }; - if (registryBind( - org.KdeKwinBlurManager, - registry, - global, - )) |blur_manager| { - context.kde_blur_manager = blur_manager; - return; - } + // Only process Wayland interfaces + if (!@hasDecl(T, "interface")) continue; - if (registryBind( - org.KdeKwinServerDecorationManager, - registry, - global, - )) |deco_manager| { - context.kde_decoration_manager = deco_manager; - deco_manager.setListener(*Context, decoManagerListener, context); - return; - } + switch (event) { + .global => |v| global: { + if (std.mem.orderZ( + u8, + v.interface, + T.interface.name, + ) != .eq) break :global; - if (registryBind( - org.KdeKwinSlideManager, - registry, - global, - )) |slide_manager| { - context.kde_slide_manager = slide_manager; - return; - } + @field(context, field.name) = registry.bind( + v.name, + T, + T.generated_version, + ) catch |err| { + log.warn( + "error binding interface {s} error={}", + .{ v.interface, err }, + ); + return; + }; + }, - if (registryBind( - xdg.ActivationV1, - registry, - global, - )) |activation| { - context.xdg_activation = activation; - return; - } - }, - - // We don't handle removal events - .global_remove => {}, + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| remove: { + const global = @field(context, field.name) orelse break :remove; + if (global.getId() == v.name) { + global.destroy(); + @field(context, field.name) = null; + } + }, + } } } - /// Bind a Wayland interface to a global object. Returns non-null - /// if the binding was successful, otherwise null. - /// - /// The type T is the Wayland interface type that we're requesting. - /// This function will verify that the global object is the correct - /// interface and version before binding. - fn registryBind( - comptime T: type, - registry: *wl.Registry, - global: anytype, - ) ?*T { - if (std.mem.orderZ( - u8, - global.interface, - T.interface.name, - ) != .eq) return null; - - return registry.bind(global.name, T, T.generated_version) catch |err| { - log.warn("error binding interface {s} error={}", .{ - global.interface, - err, - }); - return null; - }; - } - fn decoManagerListener( _: *org.KdeKwinServerDecorationManager, event: org.KdeKwinServerDecorationManager.Event, diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 2c4925167..624de03f8 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -36,16 +36,11 @@ pub const App = struct { config: *const Config, ) !?App { // If the display isn't X11, then we don't need to do anything. - if (gobject.typeCheckInstanceIsA( - gdk_display.as(gobject.TypeInstance), - gdk_x11.X11Display.getGObjectType(), - ) == 0) return null; - - // Get our X11 display const gdk_x11_display = gobject.ext.cast( gdk_x11.X11Display, gdk_display, ) orelse return null; + const xlib_display = gdk_x11_display.getXdisplay(); const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| From f99c988b27e0022dbd48309d1915eeddcee1a51d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 30 May 2025 22:56:10 +0200 Subject: [PATCH 120/245] gtk(wayland): automatically bind globals --- src/apprt/gtk/winproto/wayland.zig | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 08f4858a5..cbe8c01a4 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -48,16 +48,11 @@ pub const App = struct { _ = config; _ = app_id; - // Check if we're actually on Wayland - if (gobject.typeCheckInstanceIsA( - gdk_display.as(gobject.TypeInstance), - gdk_wayland.WaylandDisplay.getGObjectType(), - ) == 0) return null; - const gdk_wayland_display = gobject.ext.cast( gdk_wayland.WaylandDisplay, gdk_display, - ) orelse return error.NoWaylandDisplay; + ) orelse return null; + const display: *wl.Display = @ptrCast(@alignCast( gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, )); From fd7132db7142515a63251b6522dbb019f6d16f9d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 30 May 2025 15:05:53 -0700 Subject: [PATCH 121/245] macos: quick terminal can equalize splits Fixes #7480 --- .../Terminal/BaseTerminalController.swift | 16 ++++++++++++++++ .../Features/Terminal/TerminalController.swift | 16 ---------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 62384586a..9862e1288 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -124,6 +124,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyMaximizeDidToggle(_:)), name: .ghosttyMaximizeDidToggle, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidEqualizeSplits(_:)), + name: Ghostty.Notification.didEqualizeSplits, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -249,6 +254,17 @@ class BaseTerminalController: NSWindowController, guard surfaceTree?.contains(view: surfaceView) ?? false else { return } window.zoom(nil) } + + @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + + // Check if target surface is in current controller's tree + guard surfaceTree?.contains(view: target) ?? false else { return } + + if case .split(let container) = surfaceTree { + _ = container.equalize() + } + } // MARK: Local Events diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cf2dd3348..f2868adb0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -85,12 +85,6 @@ class TerminalController: BaseTerminalController { selector: #selector(onFrameDidChange), name: NSView.frameDidChangeNotification, object: nil) - center.addObserver( - self, - selector: #selector(onEqualizeSplits), - name: Ghostty.Notification.didEqualizeSplits, - object: nil - ) center.addObserver( self, selector: #selector(onCloseWindow), @@ -875,16 +869,6 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - @objc private func onEqualizeSplits(_ notification: Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - - // Check if target surface is in current controller's tree - guard surfaceTree?.contains(view: target) ?? false else { return } - - if case .split(let container) = surfaceTree { - _ = container.equalize() - } - } struct DerivedConfig { let backgroundColor: Color From dd670f5107ea47cb7d3a76cd0e93955523f092d7 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 30 May 2025 17:52:31 -0600 Subject: [PATCH 122/245] font/sprite: rework `yQuads` and friends for better alignment with `draw_block` This improves "outer edge" alignment of octants and other elements drawn using `yQuads` and friends with blocks drawn with `draw_block` -- this should guarantee alignment along a continuous edge, but may result in a 1px overlap of opposing edges (such as a top half block followed by a bottom half block with an odd cell height, they will both have the center row filled). This is very necessary since several block elements are needed to complete the set of octants, since dedicated octant characters aren't included when they would be redundant. --- src/font/sprite/Box.zig | 90 +++++++++++++++++++------------ src/font/sprite/testdata/Box.ppm | Bin 1048593 -> 1048593 bytes 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index f3942b83d..dd02f701b 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -2488,10 +2488,10 @@ fn draw_sextant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { if (sex.tl) self.rect(canvas, 0, 0, x_halfs[0], y_thirds[0]); if (sex.tr) self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_thirds[0]); - if (sex.ml) self.rect(canvas, 0, y_thirds[0], x_halfs[0], y_thirds[1]); - if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[0], self.metrics.cell_width, y_thirds[1]); - if (sex.bl) self.rect(canvas, 0, y_thirds[1], x_halfs[0], self.metrics.cell_height); - if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, self.metrics.cell_height); + if (sex.ml) self.rect(canvas, 0, y_thirds[1], x_halfs[0], y_thirds[2]); + if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, y_thirds[2]); + if (sex.bl) self.rect(canvas, 0, y_thirds[3], x_halfs[0], self.metrics.cell_height); + if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[3], self.metrics.cell_width, self.metrics.cell_height); } fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { @@ -2545,42 +2545,58 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void { const oct = octants[cp - octant_min]; if (oct.@"1") self.rect(canvas, 0, 0, x_halfs[0], y_quads[0]); if (oct.@"2") self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_quads[0]); - if (oct.@"3") self.rect(canvas, 0, y_quads[0], x_halfs[0], y_quads[1]); - if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[0], self.metrics.cell_width, y_quads[1]); - if (oct.@"5") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]); - if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]); - if (oct.@"7") self.rect(canvas, 0, y_quads[2], x_halfs[0], self.metrics.cell_height); - if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[2], self.metrics.cell_width, self.metrics.cell_height); + if (oct.@"3") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]); + if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]); + if (oct.@"5") self.rect(canvas, 0, y_quads[3], x_halfs[0], y_quads[4]); + if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[3], self.metrics.cell_width, y_quads[4]); + if (oct.@"7") self.rect(canvas, 0, y_quads[5], x_halfs[0], self.metrics.cell_height); + if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[5], self.metrics.cell_width, self.metrics.cell_height); } +/// xHalfs[0] should be used as the right edge of a left-aligned half. +/// xHalfs[1] should be used as the left edge of a right-aligned half. fn xHalfs(self: Box) [2]u32 { + const float_width: f64 = @floatFromInt(self.metrics.cell_width); + const half_width: u32 = @intFromFloat(@round(0.5 * float_width)); + return .{ half_width, self.metrics.cell_width - half_width }; +} + +/// Use these values as such: +/// yThirds[0] bottom edge of the first third. +/// yThirds[1] top edge of the second third. +/// yThirds[2] bottom edge of the second third. +/// yThirds[3] top edge of the final third. +fn yThirds(self: Box) [4]u32 { + const float_height: f64 = @floatFromInt(self.metrics.cell_height); + const one_third_height: u32 = @intFromFloat(@round(one_third * float_height)); + const two_thirds_height: u32 = @intFromFloat(@round(two_thirds * float_height)); return .{ - @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2))), - @as(u32, @intFromFloat(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2)), + one_third_height, + self.metrics.cell_height - two_thirds_height, + two_thirds_height, + self.metrics.cell_height - one_third_height, }; } -fn yThirds(self: Box) [2]u32 { - return switch (@mod(self.metrics.cell_height, 3)) { - 0 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 }, - 1 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 + 1 }, - 2 => .{ self.metrics.cell_height / 3 + 1, 2 * self.metrics.cell_height / 3 }, - else => unreachable, - }; -} - -// assume octants might be striped across multiple rows of cells. to maximize -// distance between excess pixellines, we want (1) an arbitrary region (there -// will be a pattern of 1'-3-1'-3-1'-3 no matter what), (2) discontiguous -// regions (0 and 2 or 1 and 3), and (3) an arbitrary three regions (there will -// be a pattern of 3-1-3-1-3-1 no matter what). -fn yQuads(self: Box) [3]u32 { - return switch (@mod(self.metrics.cell_height, 4)) { - 0 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 }, - 1 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 }, - 2 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 + 1 }, - 3 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 }, - else => unreachable, +/// Use these values as such: +/// yQuads[0] bottom edge of first quarter. +/// yQuads[1] top edge of second quarter. +/// yQuads[2] bottom edge of second quarter. +/// yQuads[3] top edge of third quarter. +/// yQuads[4] bottom edge of third quarter +/// yQuads[5] top edge of fourth quarter. +fn yQuads(self: Box) [6]u32 { + const float_height: f64 = @floatFromInt(self.metrics.cell_height); + const quarter_height: u32 = @intFromFloat(@round(0.25 * float_height)); + const half_height: u32 = @intFromFloat(@round(0.50 * float_height)); + const three_quarters_height: u32 = @intFromFloat(@round(0.75 * float_height)); + return .{ + quarter_height, + self.metrics.cell_height - three_quarters_height, + half_height, + self.metrics.cell_height - half_height, + three_quarters_height, + self.metrics.cell_height - quarter_height, }; } @@ -2591,8 +2607,12 @@ fn draw_smooth_mosaic( ) !void { const y_thirds = self.yThirds(); const top: f64 = 0.0; - const upper: f64 = @floatFromInt(y_thirds[0]); - const lower: f64 = @floatFromInt(y_thirds[1]); + // We average the edge positions for the y_thirds boundaries here + // rather than having to deal with varying alignments depending on + // the surrounding pieces. The most this will be off by is half of + // a pixel, so hopefully it's not noticeable. + const upper: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[0])) + @as(f64, @floatFromInt(y_thirds[1]))); + const lower: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[2])) + @as(f64, @floatFromInt(y_thirds[3]))); const bottom: f64 = @floatFromInt(self.metrics.cell_height); const left: f64 = 0.0; const center: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2); diff --git a/src/font/sprite/testdata/Box.ppm b/src/font/sprite/testdata/Box.ppm index 0feb3ebe49e45f69c4e877b1a086a56822f4af20..d5a6cc72906318b235e33bc3ea53be8f88b67193 100644 GIT binary patch delta 1279 zcmdT?Jxd%>6vcJcoy@y;otVU+xa;U5Niif7@}Zy$s|Z2a1trZk%3vVPHnV*QV|ZOa z+z2_XjlF@uG#35?2Lf7HxSfTKjiQKA-t4%Frm$0+$K$x9a19kNlFWU2hwaCMe3 zY995H$Rd2j2MX1v0G)Tk;O984X0p-gjGEWCOC`JdpN9KC3UAJbo??KE$ZZ9?ykCKQ`!5?u*(RY|5`ZIcOSiI-!{ zuSV8xkxyZ683XXrq%AGiwFkkL#bTHE5?hHJ+VCd%N3<+~x4>wDJ5F|lz{VauiPvbH kO82koa)MWU{kG;>8=Jn^C8|-=w|fdKKaqL*kk04kIXWu8E&u=k delta 1380 zcmeH_ze^)g5XafeCY#I7=;DEe5_myGi-7BSc;KnGkpoR3s3@dSqOHvu4#YxkkyoD= z0{McVq!BDHsBD}6;I=s|1hKO;m1w2xx{ALMYy@o{k74FJ-}%fG9joYAj#T6a8Ih`t z%9zw-Tz-@ZsqfJF32I4hEQ%y*-8=-*K^w3eNQJ$?aX;1suZwd2F9p%Xxga0)W%>j! z$uU?3{^m}f(P`qQ(xbWk?o5<3Umj3tLEfYkyf#YRQgZFjyrq;Ul^DEvO2Txwr8IVI zKty>ER-7|WiZzE~J;4ZWZGeB?ce;7qyA|d>#qfQECtNSf+X{>fe?`5Q<{?(N{Srhi z%d37#+BL>m(?WcyoAD{mb;4@Zuh<4ej!@rAq>(?RHQ0UJST?Bh8ttg>l<@CB)p0nk z>(3hNuV1b~?2`er#FcQ`@*SV}ljTCN*iz+Ceak(9C6)a%OS|@OhJySLN?)=zS0YhV z=2%0ekDU-@K=^f3g~uU&@hH4i9 Date: Fri, 30 May 2025 16:08:57 -0700 Subject: [PATCH 123/245] gtk: clean up per-surface cgroup on close Fixes #6766 This ensures that during surface deinit the cgroup is removed. By the time the surface is deinitialized, the subprocess should already be dead so the cgroup can be safely removed. If the cgroup cannot be removed for any reason we log a warning. --- src/apprt/gtk/Surface.zig | 16 +++++++++++++++- src/os/cgroup.zig | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 3d16e9fbb..e51109015 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -746,7 +746,21 @@ pub fn deinit(self: *Surface) void { self.core_surface.deinit(); self.core_surface = undefined; - if (self.cgroup_path) |path| self.app.core_app.alloc.free(path); + // Remove the cgroup if we have one. We do this after deiniting the core + // surface to ensure all processes have exited. + if (self.cgroup_path) |path| { + internal_os.cgroup.remove(path) catch |err| { + // We don't want this to be fatal in any way so we just log + // and continue. A dangling empty cgroup is not a big deal + // and this should be rare. + log.warn( + "failed to remove cgroup for surface path={s} err={}", + .{ path, err }, + ); + }; + + self.app.core_app.alloc.free(path); + } // Free all our GTK stuff // diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 5645e337a..4f13921c5 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -56,6 +56,25 @@ pub fn create( } } +/// Remove a cgroup. This will only succeed if the cgroup is empty +/// (has no processes). The cgroup path should be relative to the +/// cgroup root (e.g. "/user.slice/surfaces/abc123.scope"). +pub fn remove(cgroup: []const u8) !void { + assert(cgroup.len > 0); + assert(cgroup[0] == '/'); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}", .{cgroup}); + std.fs.cwd().deleteDir(path) catch |err| switch (err) { + // If it doesn't exist, that's fine - maybe it was already cleaned up + error.FileNotFound => {}, + + // Any other error we failed to delete it so we want to notify + // the user. + else => return err, + }; +} + /// Move the given PID into the given cgroup. pub fn moveInto( cgroup: []const u8, From 5306e7cf567ccb37028701a00504bcf28484b155 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 2 Jun 2025 08:34:03 -0700 Subject: [PATCH 124/245] config: add launched-from to specify launch source Related to #7433 This extracts our "launched from desktop" logic into a config option. The default value is detection using the same logic as before, but now this can be overridden by the user. This also adds the systemd and dbus activation sources from #7433. There are a number of reasons why we decided to do this: 1. It automatically gets us caching since the configuration is only loaded once (per reload, a rare occurrence). 2. It allows us to override the logic when testing. Previously, we had to do more complex environment faking to get the same behavior. 3. It forces exhaustive switches in any desktop handling code, which will make it easier to ensure valid behaviors if we introduce new launch sources (as we are in #7433). 4. It lowers code complexity since callsites don't need to have N `launchedFromX()` checks and can use a single value. --- src/apprt/embedded.zig | 5 +++- src/apprt/gtk/App.zig | 5 +++- src/config/Config.zig | 66 +++++++++++++++++++++++++++++++++++++----- src/os/dbus.zig | 21 ++++++++++++++ src/os/locale.zig | 7 ++--- src/os/main.zig | 4 +++ src/os/systemd.zig | 65 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 src/os/dbus.zig create mode 100644 src/os/systemd.zig diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 97466e9b5..67aeeaf7c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -842,7 +842,10 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); + switch (self.app.config.@"launched-from".?) { + .desktop => env.remove("LANGUAGE"), + .dbus, .systemd, .cli => {}, + } } return env; diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index d1c8f2c59..d69102bda 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -273,7 +273,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { const single_instance = switch (config.@"gtk-single-instance") { .true => true, .false => false, - .desktop => internal_os.launchedFromDesktop(), + .desktop => switch (config.@"launched-from".?) { + .desktop, .systemd, .dbus => true, + .cli => false, + }, }; // Setup the flags for our application. diff --git a/src/config/Config.zig b/src/config/Config.zig index 344c118d7..bf6d26f4b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2428,6 +2428,23 @@ term: []const u8 = "xterm-ghostty", /// running. Defaults to an empty string if not set. @"enquiry-response": []const u8 = "", +/// The mechanism used to launch Ghostty. This should generally not be +/// set by users, see the warning below. +/// +/// WARNING: This is a low-level configuration that is not intended to be +/// modified by users. All the values will be automatically detected as they +/// are needed by Ghostty. This is only here in case our detection logic is +/// incorrect for your environment or for developers who want to test +/// Ghostty's behavior in different, forced environments. +/// +/// This is set using the standard `no-[value]`, `[value]` syntax separated +/// by commas. Example: "no-desktop,systemd". Specific details about the +/// available values are documented on LaunchProperties in the code. Since +/// this isn't intended to be modified by users, the documentation is +/// lighter than the other configurations and users are expected to +/// refer to the code for details. +@"launched-from": ?LaunchSource = null, + /// Configures the low-level API to use for async IO, eventing, etc. /// /// Most users should leave this set to `auto`. This will automatically detect @@ -3111,6 +3128,11 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); + // Ensure our launch source is properly set. + if (self.@"launched-from" == null) { + self.@"launched-from" = .detect(); + } + // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of @@ -3135,14 +3157,11 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse wd: { + const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) { // If we have no working directory set, our default depends on - // whether we were launched from the desktop or CLI. - if (internal_os.launchedFromDesktop()) { - break :wd "home"; - } - - break :wd "inherit"; + // whether we were launched from the desktop or elsewhere. + .desktop => "home", + .cli, .dbus, .systemd => "inherit", }; // If we are missing either a command or home directory, we need @@ -3165,7 +3184,10 @@ pub fn finalize(self: *Config) !void { // If we were launched from the desktop, our SHELL env var // will represent our SHELL at login time. We want to use the // latest shell from /etc/passwd or directory services. - if (internal_os.launchedFromDesktop()) break :shell_env; + switch (self.@"launched-from".?) { + .desktop, .dbus, .systemd => break :shell_env, + .cli => {}, + } if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { log.info("default shell source=env value={s}", .{value}); @@ -6595,6 +6617,34 @@ pub const Duration = struct { } }; +pub const LaunchSource = enum { + /// Ghostty was launched via the CLI. This is the default if + /// no other source is detected. + cli, + + /// Ghostty was launched in a desktop environment (not via the CLI). + /// This is used to determine some behaviors such as how to read + /// settings, whether single instance defaults to true, etc. + desktop, + + /// Ghostty was started via dbus activation. + dbus, + + /// Ghostty was started via systemd activation. + systemd, + + pub fn detect() LaunchSource { + return if (internal_os.launchedFromDesktop()) + .desktop + else if (internal_os.launchedByDbusActivation()) + .dbus + else if (internal_os.launchedBySystemd()) + .systemd + else + .cli; + } +}; + pub const WindowPadding = struct { const Self = @This(); diff --git a/src/os/dbus.zig b/src/os/dbus.zig new file mode 100644 index 000000000..99824db71 --- /dev/null +++ b/src/os/dbus.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Returns true if the program was launched by D-Bus activation. +/// +/// On Linux GTK, this returns true if the program was launched using D-Bus +/// activation. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedByDbusActivation() bool { + return switch (builtin.os.tag) { + // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and + // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present + // (no matter the value) we were launched by D-Bus activation. + .linux => std.posix.getenv("DBUS_STARTER_ADDRESS") != null and + std.posix.getenv("DBUS_STARTER_BUS_TYPE") != null, + + // No other system supports D-Bus so always return false. + else => false, + }; +} diff --git a/src/os/locale.zig b/src/os/locale.zig index 17e4d163c..b391d690f 100644 --- a/src/os/locale.zig +++ b/src/os/locale.zig @@ -108,11 +108,8 @@ fn setLangFromCocoa() void { } // Get our preferred languages and set that to the LANGUAGE - // env var in case our language differs from our locale. We only - // do this when the app is launched from the desktop because then - // we're in an app bundle and we are expected to read from our - // Bundle's preferred languages. - if (internal_os.launchedFromDesktop()) language: { + // env var in case our language differs from our locale. + language: { var buf: [1024]u8 = undefined; const pref_ = preferredLanguageFromCocoa( &buf, diff --git a/src/os/main.zig b/src/os/main.zig index 36833f427..582ac75cd 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -2,6 +2,7 @@ //! system. These aren't restricted to syscalls or low-level operations, but //! also OS-specific features and conventions. +const dbus = @import("dbus.zig"); const desktop = @import("desktop.zig"); const env = @import("env.zig"); const file = @import("file.zig"); @@ -12,6 +13,7 @@ const mouse = @import("mouse.zig"); const openpkg = @import("open.zig"); const pipepkg = @import("pipe.zig"); const resourcesdir = @import("resourcesdir.zig"); +const systemd = @import("systemd.zig"); // Namespaces pub const args = @import("args.zig"); @@ -35,6 +37,8 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const launchedByDbusActivation = dbus.launchedByDbusActivation; +pub const launchedBySystemd = systemd.launchedBySystemd; pub const desktopEnvironment = desktop.desktopEnvironment; pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; diff --git a/src/os/systemd.zig b/src/os/systemd.zig new file mode 100644 index 000000000..9b67296d6 --- /dev/null +++ b/src/os/systemd.zig @@ -0,0 +1,65 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const log = std.log.scoped(.systemd); + +/// Returns true if the program was launched as a systemd service. +/// +/// On Linux, this returns true if the program was launched as a systemd +/// service. It will return false if Ghostty was launched any other way. +/// +/// For other platforms and app runtimes, this returns false. +pub fn launchedBySystemd() bool { + return switch (builtin.os.tag) { + .linux => linux: { + // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the + // `JOURNAL_STREAM` (v231+) environment variables. If these + // environment variables are not present we were not launched by + // systemd. + if (std.posix.getenv("INVOCATION_ID") == null) break :linux false; + if (std.posix.getenv("JOURNAL_STREAM") == null) break :linux false; + + // If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure + // that our parent process is actually `systemd`, not some other terminal + // emulator that doesn't clean up those environment variables. + const ppid = std.os.linux.getppid(); + if (ppid == 1) break :linux true; + + // If the parent PID is not 1 we need to check to see if we were launched by + // a user systemd daemon. Do that by checking the `/proc//comm` + // to see if it ends with `systemd`. + var comm_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const comm_path = std.fmt.bufPrint(&comm_path_buf, "/proc/{d}/comm", .{ppid}) catch { + log.err("unable to format comm path for pid {d}", .{ppid}); + break :linux false; + }; + const comm_file = std.fs.openFileAbsolute(comm_path, .{ .mode = .read_only }) catch { + log.err("unable to open '{s}' for reading", .{comm_path}); + break :linux false; + }; + defer comm_file.close(); + + // The maximum length of the command name is defined by + // `TASK_COMM_LEN` in the Linux kernel. This is usually 16 + // bytes at the time of writing (Jun 2025) so its set to that. + // Also, since we only care to compare to "systemd", anything + // longer can be assumed to not be systemd. + const TASK_COMM_LEN = 16; + var comm_data_buf: [TASK_COMM_LEN]u8 = undefined; + const comm_size = comm_file.readAll(&comm_data_buf) catch { + log.err("problems reading from '{s}'", .{comm_path}); + break :linux false; + }; + const comm_data = comm_data_buf[0..comm_size]; + + break :linux std.mem.eql( + u8, + std.mem.trimRight(u8, comm_data, "\n"), + "systemd", + ); + }, + + // No other system supports systemd so always return false. + else => false, + }; +} From 85beda9c49066001df5c5fdbd351c106d6c3c2a7 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 1 Jun 2025 14:04:14 -0700 Subject: [PATCH 125/245] Fix reset zoom button visibility in macOS "tabs" mode when no tabs --- .../Features/Terminal/TerminalWindow.swift | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 62b8dc5bf..48384a827 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -372,21 +372,10 @@ class TerminalWindow: NSWindow { private func updateResetZoomTitlebarButtonVisibility() { guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return } - let isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed - - if titlebarTabs { - resetZoomToolbarButton.isHidden = isHidden - - for (index, vc) in titlebarAccessoryViewControllers.enumerated() { - guard vc == resetZoomTitlebarAccessoryViewController else { return } - removeTitlebarAccessoryViewController(at: index) - } - } else { - if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { - addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) - } - resetZoomTitlebarAccessoryViewController.view.isHidden = isHidden - } + if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { + addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) + } + resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed } private func generateResetZoomButton() -> NSButton { From 12a01c046031045b315c8c20146066511fc4cf00 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 1 Jun 2025 15:14:04 -0700 Subject: [PATCH 126/245] Hide main title when covered by tabs --- .../Features/Terminal/TerminalToolbar.swift | 10 ++++++++++ .../Features/Terminal/TerminalWindow.swift | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index aa4ca31cd..9da14562c 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -25,6 +25,16 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { } } + var titleIsHidden: Bool { + get { + titleTextField.isHidden + } + + set { + titleTextField.isHidden = newValue + } + } + override init(identifier: NSToolbar.Identifier) { super.init(identifier: identifier) diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 48384a827..9a2bdc60f 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -153,7 +153,7 @@ class TerminalWindow: NSWindow { // This is required because the removeTitlebarAccessoryViewController hook does not // catch the creation of a new window by "tearing off" a tab from a tabbed window. if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 { - hideCustomTabBarViews() + resetCustomTabBarViews() } super.becomeKey() @@ -538,17 +538,22 @@ class TerminalWindow: NSWindow { let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController super.removeTitlebarAccessoryViewController(at: index) if (isTabBar) { - hideCustomTabBarViews() + resetCustomTabBarViews() } } // To be called immediately after the tab bar is disabled. - private func hideCustomTabBarViews() { + private func resetCustomTabBarViews() { // Hide the window buttons backdrop. windowButtonsBackdrop?.isHidden = true // Hide the window drag handle. windowDragHandle?.isHidden = true + + // Reenable the main toolbar title + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleIsHidden = false + } } private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) { @@ -557,6 +562,11 @@ class TerminalWindow: NSWindow { generateToolbar() } + // The main title conflicts with titlebar tabs, so hide it + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleIsHidden = true + } + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ // If we don't do this then on launch windows with restored state with tabs will end // up with messed up tab bars that don't show all tabs. From 232a46d2dc155c5371b8265d7e66437cb480e65e Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 1 Jun 2025 14:02:09 -0700 Subject: [PATCH 127/245] Add option to hide macOS traffic lights --- .../Terminal/TerminalController.swift | 19 +++++++++++--- .../Features/Terminal/TerminalWindow.swift | 14 ++++++++++- macos/Sources/Ghostty/Ghostty.Config.swift | 11 ++++++++ macos/Sources/Ghostty/Package.swift | 6 +++++ src/config/Config.zig | 25 +++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f2868adb0..78245d5a6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -377,6 +377,14 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } + fileprivate func hideWindowButtons() { + guard let window else { return } + + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + } + fileprivate func applyHiddenTitlebarStyle() { guard let window else { return } @@ -398,9 +406,7 @@ class TerminalController: BaseTerminalController { 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 + hideWindowButtons() // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. window.tabbingMode = .disallowed @@ -456,6 +462,10 @@ class TerminalController: BaseTerminalController { y: config.windowPositionY, windowDecorations: config.windowDecorations) + if config.macosWindowButtons == .hidden { + hideWindowButtons() + } + // Make sure our theme is set on the window so styling is correct. if let windowTheme = config.windowTheme { window.windowTheme = .init(rawValue: windowTheme) @@ -872,17 +882,20 @@ class TerminalController: BaseTerminalController { struct DerivedConfig { let backgroundColor: Color + let macosWindowButtons: Ghostty.MacOSWindowButtons let macosTitlebarStyle: String let maximize: Bool init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) + self.macosWindowButtons = .visible self.macosTitlebarStyle = "system" self.maximize = false } init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor + self.macosWindowButtons = config.macosWindowButtons self.macosTitlebarStyle = config.macosTitlebarStyle self.maximize = config.maximize } diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 9a2bdc60f..5e90d0696 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -45,6 +45,18 @@ class TerminalWindow: NSWindow { }, ] + private var hasWindowButtons: Bool { + get { + if let close = standardWindowButton(.closeButton), + let miniaturize = standardWindowButton(.miniaturizeButton), + let zoom = standardWindowButton(.zoomButton) { + return !(close.isHidden && miniaturize.isHidden && zoom.isHidden) + } else { + return false + } + } + } + // Both of these must be true for windows without decorations to be able to // still become key/main and receive events. override var canBecomeKey: Bool { return true } @@ -613,7 +625,7 @@ class TerminalWindow: NSWindow { view.translatesAutoresizingMaskIntoConstraints = false view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true - view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 78).isActive = true + view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: hasWindowButtons ? 78 : 0).isActive = true view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true view.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d7be4eb5b..cce14ca0f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -250,6 +250,17 @@ extension Ghostty { return String(cString: ptr) } + var macosWindowButtons: MacOSWindowButtons { + let defaultValue = MacOSWindowButtons.visible + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "macos-window-buttons" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return MacOSWindowButtons(rawValue: str) ?? defaultValue + } + var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 30d5573df..82721c17e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -239,6 +239,12 @@ extension Ghostty { case chrome } + /// Enum for the macos-window-buttons config option + enum MacOSWindowButtons: String { + case visible + case hidden + } + /// Enum for the macos-titlebar-proxy-icon config option enum MacOSTitlebarProxyIcon: String { case visible diff --git a/src/config/Config.zig b/src/config/Config.zig index bf6d26f4b..03fc53321 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2053,6 +2053,25 @@ keybind: Keybinds = .{}, /// it will retain the previous setting until fullscreen is exited. @"macos-non-native-fullscreen": NonNativeFullscreen = .false, +/// Whether the window buttons in the macOS titlebar are visible. The window +/// buttons are the colored buttons in the upper left corner of most macOS apps, +/// also known as the traffic lights, that allow you to close, miniaturize, and +/// zoom the window. +/// +/// This setting has no effect when `window-decoration = false` or +/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in +/// these modes. +/// +/// Valid values are: +/// +/// * `visible` - Show the window buttons. +/// * `hidden` - Hide the window buttons. +/// +/// The default value is `visible`. +/// +/// Changing this option at runtime only applies to new windows. +@"macos-window-buttons": MacWindowButtons = .visible, + /// The style of the macOS titlebar. Available values are: "native", /// "transparent", "tabs", and "hidden". /// @@ -5803,6 +5822,12 @@ pub const WindowColorspace = enum { @"display-p3", }; +/// See macos-window-buttons +pub const MacWindowButtons = enum { + visible, + hidden, +}; + /// See macos-titlebar-style pub const MacTitlebarStyle = enum { native, From 5244f8d6ac5161f59457a4aa83d286192e5db7a7 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 2 Jun 2025 10:14:52 -0700 Subject: [PATCH 128/245] Follow-up to #7462: var -> let --- macos/Sources/Features/Global Keybinds/GlobalEventTap.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 644285c9a..ae77535be 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -141,7 +141,7 @@ fileprivate func cgEventFlagsChangedHandler( guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result } // Build our event input and call ghostty - var key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) if (ghostty_app_key(ghostty, key_ev)) { GlobalEventTap.logger.info("global key event handled event=\(event)") return nil From d1f1be883386fa68763fba512ce2c371afe5ea4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 2 Jun 2025 13:57:33 -0700 Subject: [PATCH 129/245] macos: fix small memory leak in surface tree when closing splits This fixes a small memory leak I found where the `SplitNode.Leaf` was not being deinitialized properly when closing a split. It would get deinitialized the next time a split was made or the window was closed, so the leak wasn't big. The surface view underneath the split was also properly deinitialized because we forced it, so again, the leak was quite small. But conceptually this is a big problem, because when we change the surface tree we expect the deinit chain to propagate properly through the whole thing, _including_ to the SurfaceView. This fixes that by removing the `id(node)` call. I don't find this to be necessary anymore. I don't know when that happened but we've changed quite a lot in our split system since it was introduced. I'm also not 100% sure why the `id(node)` was causing a strong reference to begin with... which bothers me a bit. AI note: While I manually hunted this down, I started up Claude Code and Codex in separate tabs to also hunt for the memory leak. They both failed to find it and offered solutions that didn't work. --- .../Terminal/BaseTerminalController.swift | 4 +--- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 15 +-------------- .../Sources/Ghostty/Ghostty.TerminalSplit.swift | 5 +---- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 16 ++++------------ 4 files changed, 7 insertions(+), 33 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9862e1288..fd5ca9ffb 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -149,10 +149,8 @@ class BaseTerminalController: NSWindowController, /// /// Subclasses should call super first. func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { - // If our surface tree becomes nil then ensure all surfaces - // in the old tree have closed. + // If our surface tree becomes nil then we have no focused surface. if (to == nil) { - from?.close() focusedSurface = nil } } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 95c019b1f..97b20acd3 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -102,19 +102,6 @@ extension Ghostty { } } - /// Close the surface associated with this node. This will likely deinitialize the - /// surface. At this point, the surface view in this node tree can never be used again. - func close() { - switch (self) { - case .leaf(let leaf): - leaf.surface.close() - - case .split(let container): - container.topLeft.close() - container.bottomRight.close() - } - } - /// Returns true if any surface in the split stack requires quit confirmation. func needsConfirmQuit() -> Bool { switch (self) { @@ -224,7 +211,7 @@ extension Ghostty { self.app = app self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid) } - + // MARK: - Hashable func hash(into hasher: inout Hasher) { diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 3e942d774..92528ace7 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -75,7 +75,6 @@ extension Ghostty { .onReceive(pubZoom) { onZoom(notification: $0) } } } - .id(node) // Needed for change detection on node } else { // On these events we want to reset the split state and call it. let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) @@ -289,7 +288,7 @@ extension Ghostty { let neighbors: SplitNode.Neighbors @Binding var node: SplitNode? - @StateObject var container: SplitNode.Container + @ObservedObject var container: SplitNode.Container var body: some View { SplitView( @@ -331,7 +330,6 @@ extension Ghostty { } // Closing - container.topLeft.close() node = container.bottomRight switch (node) { @@ -362,7 +360,6 @@ extension Ghostty { } // Closing - container.bottomRight.close() node = container.topLeft switch (node) { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8e8838471..99f901792 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -279,22 +279,14 @@ extension Ghostty { // Remove ourselves from secure input if we have to SecureInput.shared.removeScoped(ObjectIdentifier(self)) - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - } - - /// Close the surface early. This will free the associated Ghostty surface and the view will - /// no longer render. The view can never be used again. This is a way for us to free the - /// Ghostty resources while references may still be held to this view. I've found that SwiftUI - /// tends to hold this view longer than it should so we free the expensive stuff explicitly. - func close() { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - self.surface = nil + // Free our core surface resources + if let surface = self.surface { + ghostty_surface_free(surface) + } } func focusDidChange(_ focused: Bool) { From 652f551bec02e7dd5f9856ff24dbf20fa6e088ec Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 2 Jun 2025 20:03:08 -0400 Subject: [PATCH 130/245] macos: simplify some ServiceProvider code First, remove the always-inlined openTerminalFromPasteboard code and combine it with openTerminal. Now that we're doing a bit of work inside openTerminal, there's little better to having an intermediate, inlined function. Second, combine some type-casting operations (saving a .map() call). Lastly, adjust some variable names because a generic `objs` or `urls` was a little ambiguous now that we're all in one function scope. --- .../Features/Services/ServiceProvider.swift | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index a06e7d151..043f5d704 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -5,7 +5,7 @@ class ServiceProvider: NSObject { static private let errorNoString = NSString(string: "Could not load any text from the clipboard.") /// The target for an open operation - enum OpenTarget { + private enum OpenTarget { case tab case window } @@ -15,7 +15,7 @@ class ServiceProvider: NSObject { userData: String?, error: AutoreleasingUnsafeMutablePointer ) { - openTerminalFromPasteboard(pasteboard: pasteboard, target: .tab, error: error) + openTerminal(from: pasteboard, target: .tab, error: error) } @objc func openWindow( @@ -23,40 +23,33 @@ class ServiceProvider: NSObject { userData: String?, error: AutoreleasingUnsafeMutablePointer ) { - openTerminalFromPasteboard(pasteboard: pasteboard, target: .window, error: error) + openTerminal(from: pasteboard, target: .window, error: error) } - @inline(__always) - private func openTerminalFromPasteboard( - pasteboard: NSPasteboard, + private func openTerminal( + from pasteboard: NSPasteboard, target: OpenTarget, error: AutoreleasingUnsafeMutablePointer ) { - guard let objs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [NSURL] else { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + let terminalManager = delegate.terminalManager + + guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { error.pointee = Self.errorNoString return } - let urlObjects = objs.map { $0 as URL } - openTerminal(urlObjects, target: target) - } - - private func openTerminal(_ urls: [URL], target: OpenTarget) { - guard let delegateRaw = NSApp.delegate else { return } - guard let delegate = delegateRaw as? AppDelegate else { return } - let terminalManager = delegate.terminalManager - - let uniqueCwds: Set = Set( - urls.map { url -> URL in - // We only open in directories. + // Build a set of unique directory URLs to open. File paths are truncated + // to their directories because that's the only thing we can open. + let directoryURLs = Set( + pathURLs.map { url -> URL in url.hasDirectoryPath ? url : url.deletingLastPathComponent() } ) - for cwd in uniqueCwds { - // Build our config + for url in directoryURLs { var config = Ghostty.SurfaceConfiguration() - config.workingDirectory = cwd.path(percentEncoded: false) + config.workingDirectory = url.path(percentEncoded: false) switch (target) { case .window: From 58cece07f0e8f0fc4cf5cb42faa8b86d3cfbcf19 Mon Sep 17 00:00:00 2001 From: Leorize Date: Mon, 2 Jun 2025 20:22:41 -0500 Subject: [PATCH 131/245] gtk/GlobalShortcuts: don't request session with no shortcuts There aren't any reason to pay the D-Bus tax if you don't use global shortcuts. --- src/apprt/gtk/GlobalShortcuts.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig index 7d960d7bf..ac9dbaa8a 100644 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -117,7 +117,9 @@ pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void { ); } - try self.request(.create_session); + if (self.map.count() > 0) { + try self.request(.create_session); + } } fn shortcutActivated( From 1183ac897236fef95bd56af0778aa7c1c7272bac Mon Sep 17 00:00:00 2001 From: Leorize Date: Mon, 2 Jun 2025 21:02:16 -0500 Subject: [PATCH 132/245] flatpak: rename .Devel variant to .ghostty-debug This is done to match against the default application id when Ghostty is built using debug configuration, done to prepare the Flatpak version for D-Bus activation support. --- ...ellh.ghostty.Devel.yml => com.mitchellh.ghostty-debug.yml} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename flatpak/{com.mitchellh.ghostty.Devel.yml => com.mitchellh.ghostty-debug.yml} (95%) diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty-debug.yml similarity index 95% rename from flatpak/com.mitchellh.ghostty.Devel.yml rename to flatpak/com.mitchellh.ghostty-debug.yml index fe24a7c56..8a2c0056e 100644 --- a/flatpak/com.mitchellh.ghostty.Devel.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -1,4 +1,4 @@ -app-id: com.mitchellh.ghostty.Devel +app-id: com.mitchellh.ghostty-debug runtime: org.gnome.Platform runtime-version: "48" sdk: org.gnome.Sdk @@ -10,7 +10,7 @@ command: ghostty rename-desktop-file: com.mitchellh.ghostty.desktop rename-appdata-file: com.mitchellh.ghostty.metainfo.xml rename-icon: com.mitchellh.ghostty -desktop-file-name-suffix: " (Devel)" +desktop-file-name-suffix: " (Debug)" finish-args: # 3D rendering - --device=dri From 4e39144d39c0dbb88ebc0060dffc3b145556df3c Mon Sep 17 00:00:00 2001 From: Leorize Date: Tue, 3 Jun 2025 01:34:37 -0500 Subject: [PATCH 133/245] gtk/TabView: do not closeTab within close-page signal handler `TabView` assumes to be the sole owner of all `Tab`s within a Window. As such, it could close the managed `Window` once all tabs are removed from its widget. However, during `AdwTabView::close-page` signal triggered by libadwaita, the `Tab` to be closed will gain an another reference for the duration of the signal, breaking `TabView.closeTab` (called via `Tab.closeWithConfirmation`) assumptions that having no tabs meant they are all destroyed. This commit solves the issue by scheduling `Tab.closeWithConfirmation` to be run after `AdwTabView::close-page` signal has finished processing. --- src/apprt/gtk/TabView.zig | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig index 29a069a6d..8a4145b5f 100644 --- a/src/apprt/gtk/TabView.zig +++ b/src/apprt/gtk/TabView.zig @@ -7,6 +7,7 @@ const std = @import("std"); const gtk = @import("gtk"); const adw = @import("adw"); const gobject = @import("gobject"); +const glib = @import("glib"); const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); @@ -243,7 +244,14 @@ fn adwClosePage( const child = page.getChild().as(gobject.Object); const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); - if (!self.forcing_close) tab.closeWithConfirmation(); + if (!self.forcing_close) { + // We cannot trigger a close directly in here as the page will stay + // alive until this handler returns, breaking the assumption where + // no pages means they are all destroyed. + // + // Schedule the close request to happen in the next event cycle. + _ = glib.idleAddOnce(glibIdleOnceCloseTab, tab); + } return 1; } @@ -269,3 +277,8 @@ fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callcon const title = page.getTitle(); self.window.setTitle(std.mem.span(title)); } + +fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void { + const tab: *Tab = @ptrCast(@alignCast(data orelse return)); + tab.closeWithConfirmation(); +} From 037d4732a6f16c3e89b96b059f3bf83e69b82097 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 00:46:01 +0000 Subject: [PATCH 134/245] build(deps): bump namespacelabs/nscloud-cache-action from 1.2.7 to 1.2.8 Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.7 to 1.2.8. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/v1.2.7...v1.2.8) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/test.yml | 38 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 11521c9c6..a905531c2 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 6190bed16..db8049df7 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b4a341a5d..42626288c 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1401f8325..e0b0ded6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -98,7 +98,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -134,7 +134,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -163,7 +163,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -196,7 +196,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -240,7 +240,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -382,7 +382,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -492,7 +492,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -523,7 +523,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -568,7 +568,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -607,7 +607,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -662,7 +662,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -689,7 +689,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -716,7 +716,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -743,7 +743,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -770,7 +770,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -797,7 +797,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -832,7 +832,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix @@ -890,7 +890,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 27b35b441..2533285e6 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.7 + uses: namespacelabs/nscloud-cache-action@v1.2.8 with: path: | /nix From 2c8d6ba944bfb85d32e02ded4af693fa9e8ac911 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 4 Jun 2025 15:07:58 +0200 Subject: [PATCH 135/245] core: document keybind actions better The current documentation for actions are very sparse and would leave someone (even including contributors) as to what exactly they do. On top of that there are many stylistic and grammatical problems that are simply no longer in line with our current standards, and certainly not on par with our configuration options reference. Hence, I've taken it upon myself to add, clarify, supplement, edit and even rewrite the documentation for most of these actions, in a wider effort of trying to offer better, clearer documentation for our users. --- src/input/Binding.zig | 427 ++++++++++++++++++++++++++++-------------- src/input/command.zig | 2 +- 2 files changed, 285 insertions(+), 144 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e5d434265..7818fac1e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -222,114 +222,191 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { /// The set of actions that a keybinding can take. pub const Action = union(enum) { - /// Ignore this key combination, don't send it to the child process, - /// pretend that it never happened at the Ghostty level. The key - /// combination may still be processed by the OS or other - /// applications. + /// Ignore this key combination. + /// + /// Ghostty will not process this combination nor forward it to the child + /// process within the terminal, but it may still be processed by the OS or + /// other applications. ignore, - /// This action is used to flag that the binding should be removed from - /// the set. This should never exist in an active set and `set.put` has an - /// assertion to verify this. + /// Unbind a previously bound key binding. /// - /// This is only able to unbind bindings that were previously - /// bound to Ghostty. This cannot unbind bindings that were not - /// bound by Ghostty (e.g. bindings set by the OS or some other - /// application). + /// This cannot unbind bindings that were not bound by Ghostty or the user + /// (e.g. bindings set by the OS or some other application). unbind, - /// Send a CSI sequence. The value should be the CSI sequence without the - /// CSI header (`ESC [` or `\x1b[`). + /// Send a CSI sequence. + /// + /// The value should be the CSI sequence without the CSI header (`ESC [` or + /// `\x1b[`). + /// + /// For example, `csi:0m` can be sent to reset all styles of the current text. csi: []const u8, /// Send an `ESC` sequence. esc: []const u8, - /// Send the given text. Uses Zig string literal syntax. This is currently - /// not validated. If the text is invalid (i.e. contains an invalid escape - /// sequence), the error will currently only show up in logs. + /// Send the specified text. + /// + /// Uses Zig string literal syntax. This is currently not validated. + /// If the text is invalid (i.e. contains an invalid escape sequence), + /// the error will currently only show up in logs. text: []const u8, /// Send data to the pty depending on whether cursor key mode is enabled /// (`application`) or disabled (`normal`). cursor_key: CursorKey, - /// Reset the terminal. This can fix a lot of issues when a running - /// program puts the terminal into a broken state. This is equivalent to - /// when you type "reset" and press enter. + /// Reset the terminal. + /// + /// This can fix a lot of issues when a running program puts the terminal + /// into a broken state, equivalent to running the `reset` command. /// /// If you do this while in a TUI program such as vim, this may break /// the program. If you do this while in a shell, you may have to press /// enter after to get a new prompt. reset, - /// Copy and paste. + /// Copy the selected text to the clipboard. copy_to_clipboard, + + /// Paste the contents of the default clipboard. paste_from_clipboard, + + /// Paste the contents of the selection clipboard. paste_from_selection, - /// Copy the URL under the cursor to the clipboard. If there is no - /// URL under the cursor, this does nothing. + /// If there is a URL under the cursor, copy it to the default clipboard. copy_url_to_clipboard, - /// Increase/decrease the font size by a certain amount. + /// Increase the font size by the specified amount in points (pt). + /// + /// For example, `increase_font_size:1.5` will increase the font size + /// by 1.5 points. increase_font_size: f32, + + /// Decrease the font size by the specified amount in points (pt). + /// + /// For example, `decrease_font_size:1.5` will decrease the font size + /// by 1.5 points. decrease_font_size: f32, /// Reset the font size to the original configured size. reset_font_size, - /// Clear the screen. This also clears all scrollback. + /// Clear the screen and all scrollback. clear_screen, /// Select all text on the screen. select_all, - /// Scroll the screen varying amounts. + /// Scroll to the top of the screen. scroll_to_top, + + /// Scroll to the bottom of the screen. scroll_to_bottom, + + /// Scroll to the selected text. scroll_to_selection, + + /// Scroll the screen up by one page. scroll_page_up, + + /// Scroll the screen down by one page. scroll_page_down, + + /// Scroll the screen by the specified fraction of a page. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_fractional:0.5` would scroll the screen + /// downwards by half a page, while `scroll_page_fractional:-1.5` would + /// scroll it upwards by one and a half pages. scroll_page_fractional: f32, + + /// Scroll the screen by the specified amount of lines. + /// + /// Positive values scroll downwards, and negative values scroll upwards. + /// + /// For example, `scroll_page_lines:3` would scroll the screen downwards + /// by 3 lines, while `scroll_page_lines:-10` would scroll it upwards by 10 + /// lines. scroll_page_lines: i16, - /// Adjust the current selection in a given direction. Does nothing if no - /// selection exists. + /// Adjust the current selection in the given direction or position, + /// relative to the cursor. /// - /// Arguments: - /// - left, right, up, down, page_up, page_down, home, end, - /// beginning_of_line, end_of_line + /// WARNING: This does not create a new selection, and does nothing when + /// there currently isn't one. + /// + /// Valid arguments are: + /// + /// - `left`, `right` + /// + /// Adjust the selection one cell to the left or right respectively. + /// + /// - `up`, `down` + /// + /// Adjust the selection one line upwards or downwards respectively. + /// + /// - `page_up`, `page_down` + /// + /// Adjust the selection one page upwards or downwards respectively. + /// + /// - `home`, `end` + /// + /// Adjust the selection to the top-left or the bottom-right corner + /// of the screen respectively. + /// + /// - `beginning_of_line`, `end_of_line` + /// + /// Adjust the selection to the beginning or the end of the line + /// respectively. /// - /// Example: Extend selection to the right - /// keybind = shift+right=adjust_selection:right adjust_selection: AdjustSelection, - /// Jump the viewport forward or back by prompt. Positive number is the - /// number of prompts to jump forward, negative is backwards. + /// Jump the viewport forward or back by the given number of prompts. + /// + /// Requires shell integration. + /// + /// Positive values scroll downwards, and negative values scroll upwards. jump_to_prompt: i16, - /// Write the entire scrollback into a temporary file. The action - /// determines what to do with the filepath. Valid values are: + /// Write the entire scrollback into a temporary file with the specified + /// action. The action determines what to do with the filepath. + /// + /// Valid actions are: + /// + /// - `paste` + /// + /// Paste the file path into the terminal. + /// + /// - `open` + /// + /// Open the file in the default OS editor for text files. /// - /// - "paste": Paste the file path into the terminal. - /// - "open": Open the file in the default OS editor for text files. /// The default OS editor is determined by using `open` on macOS /// and `xdg-open` on Linux. /// write_scrollback_file: WriteScreenAction, - /// Same as write_scrollback_file but writes the full screen contents. - /// See write_scrollback_file for available values. + /// Write the contents of the screen into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. write_screen_file: WriteScreenAction, - /// Same as write_scrollback_file but writes the selected text. - /// If there is no selected text this does nothing (it doesn't - /// even create an empty file). See write_scrollback_file for - /// available values. + /// Write the currently selected text into a temporary file with the + /// specified action. + /// + /// See `write_scrollback_file` for possible actions. + /// + /// Does nothing when no text is selected. write_selection_file: WriteScreenAction, - /// Open a new window. If the application isn't currently focused, + /// Open a new window. + /// + /// If the application isn't currently focused, /// this will bring it to the front. new_window, @@ -342,190 +419,246 @@ pub const Action = union(enum) { /// Go to the next tab. next_tab, - /// Go to the last tab (the one with the highest index) + /// Go to the last tab. last_tab, - /// Go to the tab with the specific number, 1-indexed. If the tab number - /// is higher than the number of tabs, this will go to the last tab. + /// Go to the tab with the specific index, starting from 1. + /// + /// If the tab number is higher than the number of tabs, + /// this will go to the last tab. goto_tab: usize, /// Moves a tab by a relative offset. - /// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right. - /// If the new position is out of bounds, it wraps around cyclically within the tab range. + /// + /// Positive values move the tab forwards, and negative values move it + /// backwards. If the new position is out of bounds, it is wrapped around + /// cyclically within the tab list. + /// + /// For example, `move_tab:1` moves the tab one position forwards, and if + /// it was already the last tab in the list, it wraps around and becomes + /// the first tab in the list. Likewise, `move_tab:-1` moves the tab one + /// position backwards, and if it was the first tab, then it will become + /// the last tab. move_tab: isize, /// Toggle the tab overview. - /// This only works with libadwaita version 1.4.0 or newer. + /// + /// This is only supported on Linux and when the system's libadwaita + /// version is 1.4 or newer. The current libadwaita version can be + /// found by running `ghostty +version`. toggle_tab_overview, - /// Change the title of the current focused surface via a prompt. + /// Change the title of the current focused surface via a pop-up prompt. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. prompt_surface_title, - /// Create a new split in the given direction. + /// Create a new split in the specified direction. /// - /// Arguments: - /// - right, down, left, up, auto (splits along the larger direction) + /// Valid arguments: + /// + /// - `right`, `down`, `left`, `up` + /// + /// Creates a new split in the corresponding direction. + /// + /// - `auto` + /// + /// Creates a new split along the larger direction. + /// For example, if the parent split is currently wider than it is tall, + /// then a left-right split would be created, and vice versa. /// - /// Example: Create split on the right - /// keybind = cmd+shift+d=new_split:right new_split: SplitDirection, - /// Focus on a split in a given direction. For example `goto_split:up`. - /// Valid values are left, right, up, down, previous and next. + /// Focus on a split either in the specified direction (`right`, `down`, + /// `left` and `up`), or in the adjacent split in the order of creation + /// (`previous` and `next`). goto_split: SplitFocusDirection, - /// zoom/unzoom the current split. + /// Zoom in or out of the current split. + /// + /// When a split is zoomed into, it will take up the entire space in + /// the current tab, hiding other splits. The tab or tab bar would also + /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, - /// Resize the current split in a given direction. - /// - /// Arguments: - /// - up, down, left, right - /// - the number of pixels to resize the split by - /// - /// Example: Move divider up 10 pixels - /// keybind = cmd+shift+up=resize_split:up,10 + /// Resize the current split in the specified direction and amount in + /// pixels. The two arguments should be joined with a comma (`,`), + /// like in `resize_split:up,10`. resize_split: SplitResizeParameter, - /// Equalize all splits in the current window + /// Equalize the size of all splits in the current window. equalize_splits, /// Reset the window to the default size. The "default size" is the /// size that a new window would be created with. This has no effect /// if the window is fullscreen. + /// + /// Only implemented on macOS. reset_window_size, - /// Control the terminal inspector visibility. + /// Control the visibility of the terminal inspector. /// - /// Arguments: - /// - toggle, show, hide - /// - /// Example: Toggle inspector visibility - /// keybind = cmd+i=inspector:toggle + /// Valid arguments: `toggle`, `show`, `hide`. inspector: InspectorMode, /// Show the GTK inspector. + /// + /// Has no effect on macOS. show_gtk_inspector, - /// Open the configuration file in the default OS editor. If your default OS - /// editor isn't configured then this will fail. Currently, any failures to - /// open the configuration will show up only in the logs. + /// Open the configuration file in the default OS editor. + /// + /// If your default OS editor isn't configured then this will fail. + /// Currently, any failures to open the configuration will show up only in + /// the logs. open_config, - /// Reload the configuration. The exact meaning depends on the app runtime - /// in use but this usually involves re-reading the configuration file - /// and applying any changes. Note that not all changes can be applied at - /// runtime. + /// Reload the configuration. + /// + /// The exact meaning depends on the app runtime in use, but this usually + /// involves re-reading the configuration file and applying any changes + /// Note that not all changes can be applied at runtime. reload_config, /// Close the current "surface", whether that is a window, tab, split, etc. - /// This only closes ONE surface. This will trigger close confirmation as - /// configured. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab, regardless of how many splits there may be. - /// This will trigger close confirmation as configured. + /// Close the current tab and all splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_tab, - /// Close the window, regardless of how many tabs or splits there may be. - /// This will trigger close confirmation as configured. + /// Close the current window and all tabs and splits therein. + /// + /// This might trigger a close confirmation popup, depending on the value + /// of the `confirm-close-surface` configuration setting. close_window, - /// Close all windows. This will trigger close confirmation as configured. - /// This only works for macOS currently. + /// Close all windows. + /// + /// WARNING: This action has been deprecated and has no effect on either + /// Linux or macOS. Users are instead encouraged to use `all:close_window` + /// instead. close_all_windows, - /// Toggle maximized window state. This only works on Linux. + /// Maximize or unmaximize the current window. + /// + /// This has no effect on macOS as it does not have the concept of + /// maximized windows. toggle_maximize, - /// Toggle fullscreen mode of window. + /// Fullscreen or unfullscreen the current window. toggle_fullscreen, - /// Toggle window decorations on and off. This only works on Linux. + /// Toggle window decorations (titlebar, buttons, etc.) for the current window. + /// + /// Only implemented on Linux. toggle_window_decorations, - /// Toggle whether the terminal window is always on top of other - /// windows even when it is not focused. Terminal windows always start - /// as normal (not always on top) windows. + /// Toggle whether the terminal window should always float on top of other + /// windows even when unfocused. /// - /// This only works on macOS. + /// Terminal windows always start as normal (not float-on-top) windows. + /// + /// Only implemented on macOS. toggle_window_float_on_top, - /// Toggle secure input mode on or off. This is used to prevent apps - /// that monitor input from seeing what you type. This is useful for - /// entering passwords or other sensitive information. + /// Toggle secure input mode. /// - /// This applies to the entire application, not just the focused - /// terminal. You must toggle it off to disable it, or quit Ghostty. + /// This is used to prevent apps from monitoring your keyboard input + /// when entering passwords or other sensitive information. /// - /// This only works on macOS, since this is a system API on macOS. + /// This applies to the entire application, not just the focused terminal. + /// You must manually untoggle it or quit Ghostty entirely to disable it. + /// + /// Only implemented on macOS, as this uses a built-in system API. toggle_secure_input, - /// Toggle the command palette. The command palette is a UI element - /// that lets you see what actions you can perform, their associated - /// keybindings (if any), a search bar to filter the actions, and - /// the ability to then execute the action. + /// Toggle the command palette. + /// + /// The command palette is a popup that lets you see what actions + /// you can perform, their associated keybindings (if any), a search bar + /// to filter the actions, and the ability to then execute the action. + /// + /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita + /// version can be found by running `ghostty +version`. toggle_command_palette, - /// Toggle the "quick" terminal. The quick terminal is a terminal that - /// appears on demand from a keybinding, often sliding in from a screen - /// edge such as the top. This is useful for quick access to a terminal - /// without having to open a new window or tab. + /// Toggle the quick terminal. /// - /// When the quick terminal loses focus, it disappears. The terminal state - /// is preserved between appearances, so you can always press the keybinding - /// to bring it back up. + /// The quick terminal, also known as the "Quake-style" or drop-down + /// terminal, is a terminal window that appears on demand from a keybinding, + /// often sliding in from a screen edge such as the top. This is useful for + /// quick access to a terminal without having to open a new window or tab. /// - /// To enable the quick terminal globally so that Ghostty doesn't - /// have to be focused, prefix your keybind with `global`. Example: + /// The terminal state is preserved between appearances, so showing the + /// quick terminal after it was already hidden would display the same + /// window instead of creating a new one. + /// + /// As quick terminals are often useful when other windows are currently + /// focused, they are best used with *global* keybinds. For example, one + /// can define the following key bind to toggle the quick terminal from + /// anywhere within the system by pressing `` Cmd+` ``: /// /// ```ini - /// keybind = global:cmd+grave_accent=toggle_quick_terminal + /// keybind = global:cmd+backquote=toggle_quick_terminal /// ``` /// /// The quick terminal has some limitations: /// - /// - It is a singleton; only one instance can exist at a time. - /// - It does not support tabs, but it does support splits. - /// - It will not be restored when the application is restarted - /// (for systems that support window restoration). - /// - It supports fullscreen, but fullscreen will always be a non-native - /// fullscreen (macos-non-native-fullscreen = true). This only applies - /// to the quick terminal window. This is a requirement due to how - /// the quick terminal is rendered. + /// - Only one quick terminal instance can exist at a time. + /// + /// - Unlike normal terminal windows, the quick terminal will not be + /// restored when the application is restarted on systems that support + /// window restoration like macOS. + /// + /// - On Linux, the quick terminal is only supported on Wayland and not + /// X11, and only on Wayland compositors that support the `wlr-layer-shell-v1` + /// protocol. In practice, this means that only GNOME users would not be + /// able to use this feature. + /// + /// - On Linux, slide-in animations are only supported on KDE, and when + /// the "Sliding Popups" KWin plugin is enabled. + /// + /// If you do not have this plugin enabled, open System Settings > Apps + /// & Windows > Window Management > Desktop Effects, and enable the + /// plugin in the plugin list. Ghostty would then need to be restarted + /// fully for this to take effect. + /// + /// - Quick terminal tabs are only supported on Linux and not on macOS. + /// This is because tabs on macOS require a title bar. + /// + /// - On macOS, a fullscreened quick terminal will always be in non-native + /// fullscreen mode. This is a requirement due to how the quick terminal + /// is rendered. /// /// See the various configurations for the quick terminal in the /// configuration file to customize its behavior. - /// - /// Supported on macOS and some desktop environments on Linux, namely - /// those that support the `wlr-layer-shell` Wayland protocol - /// (i.e. most desktop environments and window managers except GNOME). - /// - /// Slide-in animations on Linux are only supported on KDE when the - /// "Sliding Popups" KWin plugin is enabled. If you do not have this - /// plugin enabled, open System Settings > Apps & Windows > Window - /// Management > Desktop Effects, and enable the plugin in the plugin list. - /// Ghostty would then need to be restarted for this to take effect. toggle_quick_terminal, - /// Show/hide all windows. If all windows become shown, we also ensure + /// Show or hide all windows. If all windows become shown, we also ensure /// Ghostty becomes focused. When hiding all windows, focus is yielded /// to the next application as determined by the OS. /// /// Note: When the focused surface is fullscreen, this method does nothing. /// - /// This currently only works on macOS. + /// Only implemented on macOS. toggle_visibility, /// Check for updates. /// - /// This currently only works on macOS. + /// Only implemented on macOS. check_for_updates, - /// Quit ghostty. + /// Quit Ghostty. quit, - /// Crash ghostty in the desired thread for the focused surface. + /// Crash Ghostty in the desired thread for the focused surface. /// /// WARNING: This is a hard crash (panic) and data can be lost. /// @@ -535,9 +668,17 @@ pub const Action = union(enum) { /// /// The value determines the crash location: /// - /// - "main" - crash on the main (GUI) thread. - /// - "io" - crash on the IO thread for the focused surface. - /// - "render" - crash on the render thread for the focused surface. + /// - `main` + /// + /// Crash on the main (GUI) thread. + /// + /// - `io` + /// + /// Crash on the IO thread for the focused surface. + /// + /// - `render` + /// + /// Crash on the render thread for the focused surface. /// crash: CrashThread, diff --git a/src/input/command.zig b/src/input/command.zig index 53d1b6b3d..1ce6aa7cb 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -119,7 +119,7 @@ fn actionCommands(action: Action.Key) []const Command { .paste_from_clipboard => comptime &.{.{ .action = .paste_from_clipboard, .title = "Paste from Clipboard", - .description = "Paste the contents of the clipboard.", + .description = "Paste the contents of the main clipboard.", }}, .paste_from_selection => comptime &.{.{ From 77479feee6aefd039254b071613416a4cfd448e8 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Tue, 3 Jun 2025 12:18:13 +0200 Subject: [PATCH 136/245] gtk: make requesting attention configurable --- src/apprt/gtk/Surface.zig | 12 +++++++----- src/config/Config.zig | 34 ++++++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index e51109015..1e5b1bfe8 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2454,6 +2454,13 @@ pub fn ringBell(self: *Surface) !void { media_stream.play(); } + if (features.attention) { + // Request user attention + window.winproto.setUrgent(true) catch |err| { + log.err("failed to request user attention={}", .{err}); + }; + } + // Mark tab as needing attention if (self.container.tab()) |tab| tab: { const page = window.notebook.getTabPage(tab) orelse break :tab; @@ -2461,11 +2468,6 @@ pub fn ringBell(self: *Surface) !void { // Need attention if we're not the currently selected tab if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); } - - // Request user attention - window.winproto.setUrgent(true) catch |err| { - log.err("failed to request user attention={}", .{err}); - }; } /// Handle a stream that is in an error state. diff --git a/src/config/Config.zig b/src/config/Config.zig index 344c118d7..094058c5d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1963,7 +1963,7 @@ keybind: Keybinds = .{}, /// /// * `system` /// -/// Instructs the system to notify the user using built-in system functions. +/// Instruct the system to notify the user using built-in system functions. /// This could result in an audiovisual effect, a notification, or something /// else entirely. Changing these effects require altering system settings: /// for instance under the "Sound > Alert Sound" setting in GNOME, @@ -1973,15 +1973,31 @@ keybind: Keybinds = .{}, /// /// Play a custom sound. (GTK only) /// -/// Example: `audio`, `no-audio`, `system`, `no-system`: +/// * `attention` *(enabled by default)* /// -/// On macOS, if the app is unfocused, it will bounce the app icon in the dock -/// once. Additionally, the title of the window with the alerted terminal -/// surface will contain a bell emoji (🔔) until the terminal is focused -/// or a key is pressed. These are not currently configurable since they're -/// considered unobtrusive. +/// Request the user's attention when Ghostty is unfocused, until it has +/// received focus again. On macOS, this will bounce the app icon in the +/// dock once. On Linux, the behavior depends on the desktop environment +/// and/or the window manager/compositor: /// -/// By default, no bell features are enabled. +/// - On KDE, the background of the desktop icon in the task bar would be +/// highlighted; +/// +/// - On GNOME, you may receive a notification that, when clicked, would +/// bring the Ghostty window into focus; +/// +/// - On Sway, the window may be decorated with a distinctly colored border; +/// +/// - On other systems this may have no effect at all. +/// +/// * `title` *(enabled by default)* +/// +/// Prepend a bell emoji (🔔) to the title of the alerted surface until the +/// terminal is re-focused or interacted with (such as on keyboard input). +/// +/// Only implemented on macOS. +/// +/// Example: `audio`, `no-audio`, `system`, `no-system` @"bell-features": BellFeatures = .{}, /// If `audio` is an enabled bell feature, this is a path to an audio file. If @@ -5857,6 +5873,8 @@ pub const AppNotifications = packed struct { pub const BellFeatures = packed struct { system: bool = false, audio: bool = false, + attention: bool = true, + title: bool = true, }; /// See mouse-shift-capture From 170715944185885ea404ed3dc4c12513f9a4678e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 2 Jun 2025 16:56:57 -0700 Subject: [PATCH 137/245] new SplitTree --- macos/Ghostty.xcodeproj/project.pbxproj | 16 + macos/Sources/Features/Splits/SplitTree.swift | 314 ++++++++++++++++++ .../Splits/TerminalSplitTreeView.swift | 62 ++++ .../Terminal/BaseTerminalController.swift | 70 +++- .../Terminal/TerminalController.swift | 3 + .../Features/Terminal/TerminalView.swift | 10 +- .../Features/Terminal/TerminalWindow.swift | 2 + 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 macos/Sources/Features/Splits/SplitTree.swift create mode 100644 macos/Sources/Features/Splits/TerminalSplitTreeView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a34c4685f..459b2b994 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -59,6 +59,8 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; + A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; + A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -164,6 +166,8 @@ A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; + A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -275,6 +279,7 @@ A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, + A58636622DEF955100E04A10 /* Splits */, A53A29742DB2E04900B6E02C /* Command Palette */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, @@ -428,6 +433,15 @@ path = "Secure Input"; sourceTree = ""; }; + A58636622DEF955100E04A10 /* Splits */ = { + isa = PBXGroup; + children = ( + A586365E2DEE6C2100E04A10 /* SplitTree.swift */, + A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */, + ); + path = Splits; + sourceTree = ""; + }; A5874D9B2DAD781100E83852 /* Private */ = { isa = PBXGroup; children = ( @@ -685,6 +699,7 @@ A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, + A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, @@ -734,6 +749,7 @@ A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */, A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */, + A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */, A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */, AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */, C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */, diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift new file mode 100644 index 000000000..6f98dfefc --- /dev/null +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -0,0 +1,314 @@ +import AppKit + +/// SplitTree represents a tree of views that can be divided. +struct SplitTree { + /// The root of the tree. This can be nil to indicate the tree is empty. + let root: Node? + + /// The node that is currently zoomed. A zoomed split is expected to take up the full + /// size of the view area where the splits are shown. + let zoomed: Node? + + /// A single node in the tree is either a leaf node (a view) or a split (has a + /// left/right or top/bottom). + indirect enum Node { + case leaf(view: NSView) + case split(Split) + + struct Split: Equatable { + let direction: Direction + let ratio: Double + let left: Node + let right: Node + } + } + + enum Direction { + case horizontal // Splits are laid out left and right + case vertical // Splits are laid out top and bottom + } + + /// The path to a specific node in the tree. + struct Path { + let path: [Component] + + var isEmpty: Bool { path.isEmpty } + + enum Component { + case left + case right + } + } + + enum SplitError: Error { + case viewNotFound + } + + enum NewDirection { + case left + case right + case down + case up + } +} + +// MARK: SplitTree + +extension SplitTree { + var isEmpty: Bool { + root == nil + } + + init() { + self.init(root: nil, zoomed: nil) + } + + init(view: NSView) { + self.init(root: .leaf(view: view), zoomed: nil) + } + + /// Insert a new view at the given view point by creating a split in the given direction. + func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + return .init( + root: try root.insert(view: view, at: at, direction: direction), + zoomed: zoomed) + } + + /// Remove a node from the tree. If the node being removed is part of a split, + /// the sibling node takes the place of the parent split. + func remove(_ target: Node) -> Self { + guard let root else { return self } + + // If we're removing the root itself, return an empty tree + if root == target { + return .init(root: nil, zoomed: nil) + } + + // Otherwise, try to remove from the tree + let newRoot = root.remove(target) + + // Update zoomed if it was the removed node + let newZoomed = (zoomed == target) ? nil : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } +} + +// MARK: SplitTree.Node + +extension SplitTree.Node { + typealias Node = SplitTree.Node + typealias NewDirection = SplitTree.NewDirection + typealias SplitError = SplitTree.SplitError + typealias Path = SplitTree.Path + + /// Returns the node in the tree that contains the given view. + func node(view: NSView) -> Node? { + switch (self) { + case .leaf(view): + return self + + case .split(let split): + if let result = split.left.node(view: view) { + return result + } else if let result = split.right.node(view: view) { + return result + } + + return nil + + default: + return nil + } + } + + /// Returns the path to a given node in the tree. If the returned value is nil then the + /// node doesn't exist. + func path(to node: Self) -> Path? { + var components: [Path.Component] = [] + func search(_ current: Self) -> Bool { + if current == node { + return true + } + + switch current { + case .leaf: + return false + + case .split(let split): + // Try left branch + components.append(.left) + if search(split.left) { + return true + } + components.removeLast() + + // Try right branch + components.append(.right) + if search(split.right) { + return true + } + components.removeLast() + + return false + } + } + + return search(self) ? Path(path: components) : nil + } + + /// Inserts a new view into the split tree by creating a split at the location of an existing view. + /// + /// This method creates a new split node containing both the existing view and the new view, + /// The position of the new view relative to the existing view is determined by the direction parameter. + /// + /// - Parameters: + /// - view: The new view to insert into the tree + /// - at: The existing view at whose location the split should be created + /// - direction: The direction relative to the existing view where the new view should be placed + /// + /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should + /// maybe throw instead but at the moment we just do nothing. + func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + // Get the path to our insertion point. If it doesn't exist we do + // nothing. + guard let path = path(to: .leaf(view: at)) else { + throw SplitError.viewNotFound + } + + // Determine split direction and which side the new view goes on + let splitDirection: SplitTree.Direction + let newViewOnLeft: Bool + switch direction { + case .left: + splitDirection = .horizontal + newViewOnLeft = true + case .right: + splitDirection = .horizontal + newViewOnLeft = false + case .up: + splitDirection = .vertical + newViewOnLeft = true + case .down: + splitDirection = .vertical + newViewOnLeft = false + } + + // Create the new split node + let newNode: Node = .leaf(view: view) + let existingNode: Node = .leaf(view: at) + let newSplit: Node = .split(.init( + direction: splitDirection, + ratio: 0.5, + left: newViewOnLeft ? newNode : existingNode, + right: newViewOnLeft ? existingNode : newNode + )) + + // Replace the node at the path with the new split + return try replaceNode(at: path, with: newSplit) + } + + /// Helper function to replace a node at the given path from the root + private func replaceNode(at path: Path, with newNode: Self) throws -> Self { + // If path is empty, replace the root + if path.isEmpty { + return newNode + } + + // Otherwise, we need to replace the proper left/right all along + // the way since Node is a value type (enum). To do that, we need + // recursion. We can't use a simple iterative approach because we + // can't update in-place. + func replaceInner(current: Node, pathOffset: Int) throws -> Node { + // Base case: if we've consumed the entire path, replace this node + if pathOffset >= path.path.count { + return newNode + } + + // We need to go deeper, so current must be a split for the path + // to be valid. Otherwise, the path is invalid. + guard case .split(let split) = current else { + throw SplitError.viewNotFound + } + + let component = path.path[pathOffset] + switch component { + case .left: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: try replaceInner(current: split.left, pathOffset: pathOffset + 1), + right: split.right + )) + case .right: + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: split.left, + right: try replaceInner(current: split.right, pathOffset: pathOffset + 1) + )) + } + } + + return try replaceInner(current: self, pathOffset: 0) + } + + /// Remove a node from the tree. Returns the modified tree, or nil if removing + /// the node results in an empty tree. + func remove(_ target: Node) -> Node? { + // If we're removing ourselves, return nil + if self == target { + return nil + } + + switch self { + case .leaf: + // A leaf that isn't the target stays as is + return self + + case .split(let split): + // Neither child is directly the target, so we need to recursively + // try to remove from both children + let newLeft = split.left.remove(target) + let newRight = split.right.remove(target) + + // If both are nil then we remove everything. This shouldn't ever + // happen because duplicate nodes shouldn't exist, but we want to + // be robust against it. + if newLeft == nil && newRight == nil { + return nil + } else if newLeft == nil { + return newRight + } else if newRight == nil { + return newLeft + } + + // Both children still exist after removal + return .split(.init( + direction: split.direction, + ratio: split.ratio, + left: newLeft!, + right: newRight! + )) + } + } +} + +// MARK: SplitTree.Node Protocols + +extension SplitTree.Node: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.leaf(leftView), .leaf(rightView)): + // Compare NSView instances by object identity + return leftView === rightView + + case let (.split(split1), .split(split2)): + return split1 == split2 + + default: + return false + } + } +} diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift new file mode 100644 index 000000000..c55192e44 --- /dev/null +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct TerminalSplitTreeView: View { + let tree: SplitTree + + var body: some View { + if let node = tree.root { + TerminalSplitSubtreeView(node: node, isRoot: true) + } + } +} + +struct TerminalSplitSubtreeView: View { + let node: SplitTree.Node + var isRoot: Bool = false + + var body: some View { + switch (node) { + case .leaf(let leafView): + // TODO: Fix the as! + Ghostty.InspectableSurface( + surfaceView: leafView as! Ghostty.SurfaceView, + isSplit: !isRoot) + + case .split(let split): + TerminalSplitSplitView(split: split) + } + } +} + +struct TerminalSplitSplitView: View { + @EnvironmentObject var ghostty: Ghostty.App + + let split: SplitTree.Node.Split + + private var splitViewDirection: SplitViewDirection { + switch (split.direction) { + case .horizontal: .horizontal + case .vertical: .vertical + } + } + + var body: some View { + SplitView( + splitViewDirection, + .init(get: { + CGFloat(split.ratio) + }, set: { _ in + // TODO + }), + dividerColor: ghostty.config.splitDividerColor, + resizeIncrements: .init(width: 1, height: 1), + resizePublisher: .init(), + left: { + TerminalSplitSubtreeView(node: split.left) + }, + right: { + TerminalSplitSubtreeView(node: split.right) + } + ) + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index fd5ca9ffb..628c0acbf 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -46,6 +46,8 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } + @Published var surfaceTree2: SplitTree = .init() + /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false @@ -97,6 +99,9 @@ class BaseTerminalController: NSWindowController, guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + let firstView = Ghostty.SurfaceView(ghostty_app, baseConfig: base) + self.surfaceTree2 = .init(view: firstView) + // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( @@ -124,6 +129,18 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyMaximizeDidToggle(_:)), name: .ghosttyMaximizeDidToggle, object: nil) + + // Splits + center.addObserver( + self, + selector: #selector(ghosttyDidCloseSurface(_:)), + name: Ghostty.Notification.ghosttyCloseSurface, + object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidNewSplit(_:)), + name: Ghostty.Notification.ghosttyNewSplit, + object: nil) center.addObserver( self, selector: #selector(ghosttyDidEqualizeSplits(_:)), @@ -252,7 +269,58 @@ class BaseTerminalController: NSWindowController, guard surfaceTree?.contains(view: surfaceView) ?? false else { return } window.zoom(nil) } - + + @objc private func ghosttyDidCloseSurface(_ notification: Notification) { + // The target must be within our tree + guard let oldView = notification.object as? Ghostty.SurfaceView else { return } + guard let node = surfaceTree2.root?.node(view: oldView) else { return } + + // Remove it + surfaceTree2 = surfaceTree2.remove(node) + + // TODO: fix focus + } + + @objc private func ghosttyDidNewSplit(_ notification: Notification) { + // The target must be within our tree + guard let oldView = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree2.root?.node(view: oldView) != nil else { return } + + // Notification must contain our base config + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + // Determine our desired direction + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_action_split_direction_e else { return } + let splitDirection: SplitTree.NewDirection + switch (direction) { + case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right + case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left + case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down + case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up + default: return + } + + // Create a new surface view + guard let ghostty_app = ghostty.app else { return } + let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + + // Do the split + do { + surfaceTree2 = try surfaceTree2.insert(view: newView, at: oldView, direction: splitDirection) + } catch { + // If splitting fails for any reason (it should not), then we just log + // and return. The new view we created will be deinitialized and its + // no big deal. + // TODO: log + return + } + + // Once we've split, we need to move focus to the new split + Ghostty.moveFocus(to: newView, from: oldView) + } + @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f2868adb0..d8f42bb1a 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -228,6 +228,9 @@ class TerminalController: BaseTerminalController { // Update our window light/darkness based on our updated background color window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + // Sync our zoom state for splits + window.surfaceIsZoomed = surfaceTree2.zoomed != nil + // If our window is not visible, then we do nothing. Some things such as blurring // have no effect if the window is not visible. Ultimately, we'll have this called // at some point when a surface becomes focused. diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 7caceb071..d2f4d8bdb 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -31,7 +31,7 @@ protocol TerminalViewDelegate: AnyObject { protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. - var surfaceTree: Ghostty.SplitNode? { get set } + var surfaceTree2: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } @@ -81,7 +81,7 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(node: $viewModel.surfaceTree) + TerminalSplitTreeView(tree: viewModel.surfaceTree2) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } @@ -100,12 +100,6 @@ struct TerminalView: View { guard let size = newValue else { return } self.delegate?.cellSizeDidChange(to: size) } - .onChange(of: viewModel.surfaceTree?.hashValue) { _ in - // This is funky, but its the best way I could think of to detect - // ANY CHANGE within the deeply nested surface tree -- detecting a change - // in the hash value. - self.delegate?.surfaceTreeDidChange() - } .onChange(of: zoomedSplit) { newValue in self.delegate?.zoomStateDidChange(to: newValue ?? false) } diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 9a2bdc60f..f244b95ee 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -30,6 +30,7 @@ class TerminalWindow: NSWindow { observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in guard let tabGroup = self?.tabGroup else { return } + Ghostty.logger.warning("WOW \(window.surfaceIsZoomed)") self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed self?.updateResetZoomTitlebarButtonVisibility() }, @@ -375,6 +376,7 @@ class TerminalWindow: NSWindow { if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) } + resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed } From e3bc3422dce86b58834e83e181b6640451eee4c3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 15:36:40 -0700 Subject: [PATCH 138/245] macos: handle split resizing --- macos/Sources/Features/Splits/SplitTree.swift | 40 +++++++++++- .../Splits/TerminalSplitTreeView.swift | 62 ++++++++----------- .../Terminal/BaseTerminalController.swift | 15 +++-- .../Terminal/TerminalController.swift | 10 ++- .../Features/Terminal/TerminalView.swift | 11 ++-- .../Features/Terminal/TerminalWindow.swift | 1 - 6 files changed, 86 insertions(+), 53 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 6f98dfefc..6829a742e 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -93,6 +93,24 @@ extension SplitTree { return .init(root: newRoot, zoomed: newZoomed) } + + /// Replace a node in the tree with a new node. + func replace(node: Node, with newNode: Node) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + + // Get the path to the node we want to replace + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + // Replace the node + let newRoot = try root.replaceNode(at: path, with: newNode) + + // Update zoomed if it was the replaced node + let newZoomed = (zoomed == node) ? newNode : zoomed + + return .init(root: newRoot, zoomed: newZoomed) + } } // MARK: SplitTree.Node @@ -210,7 +228,7 @@ extension SplitTree.Node { } /// Helper function to replace a node at the given path from the root - private func replaceNode(at path: Path, with newNode: Self) throws -> Self { + func replaceNode(at path: Path, with newNode: Self) throws -> Self { // If path is empty, replace the root if path.isEmpty { return newNode @@ -293,6 +311,26 @@ extension SplitTree.Node { )) } } + + /// Resize a split node to the specified ratio. + /// For leaf nodes, this returns the node unchanged. + /// For split nodes, this creates a new split with the updated ratio. + func resize(to ratio: Double) -> Self { + switch self { + case .leaf: + // Leaf nodes don't have a ratio to resize + return self + + case .split(let split): + // Create a new split with the updated ratio + return .split(.init( + direction: split.direction, + ratio: ratio, + left: split.left, + right: split.right + )) + } + } } // MARK: SplitTree.Node Protocols diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index c55192e44..8f78dcbf8 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -2,17 +2,21 @@ import SwiftUI struct TerminalSplitTreeView: View { let tree: SplitTree + let onResize: (SplitTree.Node, Double) -> Void var body: some View { if let node = tree.root { - TerminalSplitSubtreeView(node: node, isRoot: true) + TerminalSplitSubtreeView(node: node, isRoot: true, onResize: onResize) } } } struct TerminalSplitSubtreeView: View { + @EnvironmentObject var ghostty: Ghostty.App + let node: SplitTree.Node var isRoot: Bool = false + let onResize: (SplitTree.Node, Double) -> Void var body: some View { switch (node) { @@ -23,40 +27,28 @@ struct TerminalSplitSubtreeView: View { isSplit: !isRoot) case .split(let split): - TerminalSplitSplitView(split: split) - } - } -} - -struct TerminalSplitSplitView: View { - @EnvironmentObject var ghostty: Ghostty.App - - let split: SplitTree.Node.Split - - private var splitViewDirection: SplitViewDirection { - switch (split.direction) { - case .horizontal: .horizontal - case .vertical: .vertical - } - } - - var body: some View { - SplitView( - splitViewDirection, - .init(get: { - CGFloat(split.ratio) - }, set: { _ in - // TODO - }), - dividerColor: ghostty.config.splitDividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: .init(), - left: { - TerminalSplitSubtreeView(node: split.left) - }, - right: { - TerminalSplitSubtreeView(node: split.right) + let splitViewDirection: SplitViewDirection = switch (split.direction) { + case .horizontal: .horizontal + case .vertical: .vertical } - ) + + SplitView( + splitViewDirection, + .init(get: { + CGFloat(split.ratio) + }, set: { + onResize(node, $0) + }), + dividerColor: ghostty.config.splitDividerColor, + resizeIncrements: .init(width: 1, height: 1), + resizePublisher: .init(), + left: { + TerminalSplitSubtreeView(node: split.left, onResize: onResize) + }, + right: { + TerminalSplitSubtreeView(node: split.right, onResize: onResize) + } + ) + } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 628c0acbf..cb5a15f1b 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -366,11 +366,6 @@ class BaseTerminalController: NSWindowController, // MARK: TerminalViewDelegate - // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called - // when the currently set value changed in place and the from:to: variant is called - // when the variable was set. - func surfaceTreeDidChange() {} - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { let lastFocusedSurface = focusedSurface focusedSurface = to @@ -420,6 +415,16 @@ class BaseTerminalController: NSWindowController, func zoomStateDidChange(to: Bool) {} + func splitDidResize(node: SplitTree.Node, to newRatio: Double) { + let resizedNode = node.resize(to: newRatio) + do { + surfaceTree2 = try surfaceTree2.replace(node: node, with: resizedNode) + } catch { + // TODO: log + return + } + } + func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { guard let surface = surfaceView.surface else { return } let len = action.utf8CString.count diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index d8f42bb1a..82491e76d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -107,6 +107,10 @@ class TerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { super.surfaceTreeDidChange(from: from, to: to) + + // Whenever our surface tree changes in any way (new split, close split, etc.) + // we want to invalidate our state. + invalidateRestorableState() // If our surface tree is now nil then we close our window. if (to == nil) { @@ -696,12 +700,6 @@ class TerminalController: BaseTerminalController { } } - override func surfaceTreeDidChange() { - // Whenever our surface tree changes in any way (new split, close split, etc.) - // we want to invalidate our state. - invalidateRestorableState() - } - override func zoomStateDidChange(to: Bool) { guard let window = window as? TerminalWindow else { return } window.surfaceIsZoomed = to diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index d2f4d8bdb..2970f19c6 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -14,15 +14,14 @@ protocol TerminalViewDelegate: AnyObject { /// The cell size changed. func cellSizeDidChange(to: NSSize) - /// The surface tree did change in some way, i.e. a split was added, removed, etc. This is - /// not called initially. - func surfaceTreeDidChange() - /// This is called when a split is zoomed. func zoomStateDidChange(to: Bool) /// Perform an action. At the time of writing this is only triggered by the command palette. func performAction(_ action: String, on: Ghostty.SurfaceView) + + /// A split is resizing to a given value. + func splitDidResize(node: SplitTree.Node, to newRatio: Double) } /// The view model is a required implementation for TerminalView callers. This contains @@ -81,7 +80,9 @@ struct TerminalView: View { DebugBuildWarningView() } - TerminalSplitTreeView(tree: viewModel.surfaceTree2) + TerminalSplitTreeView( + tree: viewModel.surfaceTree2, + onResize: { delegate?.splitDidResize(node: $0, to: $1) }) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index f244b95ee..1c440be33 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -30,7 +30,6 @@ class TerminalWindow: NSWindow { observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in guard let tabGroup = self?.tabGroup else { return } - Ghostty.logger.warning("WOW \(window.surfaceIsZoomed)") self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed self?.updateResetZoomTitlebarButtonVisibility() }, From 672d276276931400cc3d1ba46476c66de8ff33ce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 15:52:50 -0700 Subject: [PATCH 139/245] macos: confirm close on split close --- .../Terminal/BaseTerminalController.swift | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index cb5a15f1b..b30cc7afb 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -272,13 +272,55 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidCloseSurface(_ notification: Notification) { // The target must be within our tree - guard let oldView = notification.object as? Ghostty.SurfaceView else { return } - guard let node = surfaceTree2.root?.node(view: oldView) else { return } - - // Remove it - surfaceTree2 = surfaceTree2.remove(node) + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let node = surfaceTree2.root?.node(view: target) else { return } // TODO: fix focus + + var processAlive = false + if let valueAny = notification.userInfo?["process_alive"] { + if let value = valueAny as? Bool { + processAlive = value + } + } + + // If the child process is not alive, then we exit immediately + guard processAlive else { + surfaceTree2 = surfaceTree2.remove(node) + return + } + + // If we don't have a window to attach our modal to, we also exit immediately. + // This should NOT happen. + guard let window = target.window else { + surfaceTree2 = surfaceTree2.remove(node) + return + } + + // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog + // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that + // confirmationDialog allows the user to Cmd-W close the alert, but when doing + // so SwiftUI does not update any of the bindings to note that window is no longer + // being shown, and provides no callback to detect this. + let alert = NSAlert() + alert.messageText = "Close Terminal?" + alert.informativeText = "The terminal still has a running process. If you close the " + + "terminal the process will be killed." + alert.addButton(withTitle: "Close the Terminal") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window, completionHandler: { [weak self] response in + switch (response) { + case .alertFirstButtonReturn: + alert.window.orderOut(nil) + if let self { + self.surfaceTree2 = self.surfaceTree2.remove(node) + } + + default: + break + } + }) } @objc private func ghosttyDidNewSplit(_ notification: Notification) { From 33d94521ea77efb42b39ae8037a23b8dcfec0952 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 15:57:01 -0700 Subject: [PATCH 140/245] macos: setup sequence for SplitTree --- macos/Sources/Features/Splits/SplitTree.swift | 27 ++++++++++ .../Terminal/BaseTerminalController.swift | 52 +++++++++---------- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 2 +- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 6829a742e..c3b9afa28 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -350,3 +350,30 @@ extension SplitTree.Node: Equatable { } } } + +// MARK: SplitTree Sequences + +extension SplitTree.Node { + /// Returns all leaf views in this subtree + func leaves() -> [NSView] { + switch self { + case .leaf(let view): + return [view] + + case .split(let split): + return split.left.leaves() + split.right.leaves() + } + } +} + +extension SplitTree: Sequence { + func makeIterator() -> [NSView].Iterator { + return root?.leaves().makeIterator() ?? [].makeIterator() + } +} + +extension SplitTree.Node: Sequence { + func makeIterator() -> [NSView].Iterator { + return leaves().makeIterator() + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b30cc7afb..a93896732 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -175,16 +175,16 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { - guard let tree = self.surfaceTree else { return } - - for leaf in tree { - // Our focus state requires that this window is key and our currently - // focused surface is the surface in this leaf. - let focused: Bool = (window?.isKeyWindow ?? false) && - !commandPaletteIsShowing && - focusedSurface != nil && - leaf.surface == focusedSurface! - leaf.surface.focusDidChange(focused) + for view in surfaceTree2 { + if let surfaceView = view as? Ghostty.SurfaceView { + // Our focus state requires that this window is key and our currently + // focused surface is the surface in this view. + let focused: Bool = (window?.isKeyWindow ?? false) && + !commandPaletteIsShowing && + focusedSurface != nil && + surfaceView == focusedSurface! + surfaceView.focusDidChange(focused) + } } } @@ -387,20 +387,18 @@ class BaseTerminalController: NSWindowController, } private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { - // Go through all our surfaces and notify it that the flags changed. - if let surfaceTree { - var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface } - - // If we're the main window receiving key input, then we want to avoid - // calling this on our focused surface because that'll trigger a double - // flagsChanged call. - if NSApp.mainWindow == window { - surfaces = surfaces.filter { $0 != focusedSurface } - } - - for surface in surfaces { - surface.flagsChanged(with: event) - } + // Also update surfaceTree2 + var surfaces2: [Ghostty.SurfaceView] = surfaceTree2.compactMap { $0 as? Ghostty.SurfaceView } + + // If we're the main window receiving key input, then we want to avoid + // calling this on our focused surface because that'll trigger a double + // flagsChanged call. + if NSApp.mainWindow == window { + surfaces2 = surfaces2.filter { $0 != focusedSurface } + } + + for surface in surfaces2 { + surface.flagsChanged(with: event) } return event @@ -675,10 +673,10 @@ class BaseTerminalController: NSWindowController, } func windowDidChangeOcclusionState(_ notification: Notification) { - guard let surfaceTree = self.surfaceTree else { return } let visible = self.window?.occlusionState.contains(.visible) ?? false - for leaf in surfaceTree { - if let surface = leaf.surface.surface { + for view in surfaceTree2 { + if let surfaceView = view as? Ghostty.SurfaceView, + let surface = surfaceView.surface { ghostty_surface_set_occlusion(surface, visible) } } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 97b20acd3..ff60e7c56 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -11,7 +11,7 @@ extension Ghostty { /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These /// values can further be split infinitely. /// - enum SplitNode: Equatable, Hashable, Codable, Sequence { + enum SplitNode: Equatable, Hashable, Codable { case leaf(Leaf) case split(Container) From d1dce1e37213282981738dd9f127a9c78d11c687 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 16:05:23 -0700 Subject: [PATCH 141/245] macos: restoration for new split tree --- macos/Sources/Features/Splits/SplitTree.swift | 64 +++++++++++++++---- .../Splits/TerminalSplitTreeView.swift | 11 ++-- .../Terminal/BaseTerminalController.swift | 42 ++++++------ .../Terminal/TerminalController.swift | 5 +- .../Features/Terminal/TerminalManager.swift | 5 +- .../Terminal/TerminalRestorable.swift | 34 ++++++++-- .../Features/Terminal/TerminalView.swift | 4 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 31 ++++++++- 8 files changed, 142 insertions(+), 54 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index c3b9afa28..a66d4abe7 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,7 +1,7 @@ import AppKit /// SplitTree represents a tree of views that can be divided. -struct SplitTree { +struct SplitTree: Codable { /// The root of the tree. This can be nil to indicate the tree is empty. let root: Node? @@ -11,11 +11,11 @@ struct SplitTree { /// A single node in the tree is either a leaf node (a view) or a split (has a /// left/right or top/bottom). - indirect enum Node { - case leaf(view: NSView) + indirect enum Node: Codable { + case leaf(view: ViewType) case split(Split) - struct Split: Equatable { + struct Split: Equatable, Codable { let direction: Direction let ratio: Double let left: Node @@ -23,7 +23,7 @@ struct SplitTree { } } - enum Direction { + enum Direction: Codable { case horizontal // Splits are laid out left and right case vertical // Splits are laid out top and bottom } @@ -63,12 +63,12 @@ extension SplitTree { self.init(root: nil, zoomed: nil) } - init(view: NSView) { + init(view: ViewType) { self.init(root: .leaf(view: view), zoomed: nil) } /// Insert a new view at the given view point by creating a split in the given direction. - func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { guard let root else { throw SplitError.viewNotFound } return .init( root: try root.insert(view: view, at: at, direction: direction), @@ -122,7 +122,7 @@ extension SplitTree.Node { typealias Path = SplitTree.Path /// Returns the node in the tree that contains the given view. - func node(view: NSView) -> Node? { + func node(view: ViewType) -> Node? { switch (self) { case .leaf(view): return self @@ -188,7 +188,7 @@ extension SplitTree.Node { /// /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should /// maybe throw instead but at the moment we just do nothing. - func insert(view: NSView, at: NSView, direction: NewDirection) throws -> Self { + func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { // Get the path to our insertion point. If it doesn't exist we do // nothing. guard let path = path(to: .leaf(view: at)) else { @@ -351,11 +351,51 @@ extension SplitTree.Node: Equatable { } } +// MARK: SplitTree Codable + +extension SplitTree.Node { + enum CodingKeys: String, CodingKey { + case view + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.contains(.view) { + let view = try container.decode(ViewType.self, forKey: .view) + self = .leaf(view: view) + } else if container.contains(.split) { + let split = try container.decode(Split.self, forKey: .split) + self = .split(split) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No valid node type found" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .leaf(let view): + try container.encode(view, forKey: .view) + + case .split(let split): + try container.encode(split, forKey: .split) + } + } +} + // MARK: SplitTree Sequences extension SplitTree.Node { /// Returns all leaf views in this subtree - func leaves() -> [NSView] { + func leaves() -> [ViewType] { switch self { case .leaf(let view): return [view] @@ -367,13 +407,13 @@ extension SplitTree.Node { } extension SplitTree: Sequence { - func makeIterator() -> [NSView].Iterator { + func makeIterator() -> [ViewType].Iterator { return root?.leaves().makeIterator() ?? [].makeIterator() } } extension SplitTree.Node: Sequence { - func makeIterator() -> [NSView].Iterator { + func makeIterator() -> [ViewType].Iterator { return leaves().makeIterator() } } diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 8f78dcbf8..3969b2e74 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -1,8 +1,8 @@ import SwiftUI struct TerminalSplitTreeView: View { - let tree: SplitTree - let onResize: (SplitTree.Node, Double) -> Void + let tree: SplitTree + let onResize: (SplitTree.Node, Double) -> Void var body: some View { if let node = tree.root { @@ -14,16 +14,15 @@ struct TerminalSplitTreeView: View { struct TerminalSplitSubtreeView: View { @EnvironmentObject var ghostty: Ghostty.App - let node: SplitTree.Node + let node: SplitTree.Node var isRoot: Bool = false - let onResize: (SplitTree.Node, Double) -> Void + let onResize: (SplitTree.Node, Double) -> Void var body: some View { switch (node) { case .leaf(let leafView): - // TODO: Fix the as! Ghostty.InspectableSurface( - surfaceView: leafView as! Ghostty.SurfaceView, + surfaceView: leafView, isSplit: !isRoot) case .split(let split): diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index a93896732..b3409c437 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -46,7 +46,7 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } - @Published var surfaceTree2: SplitTree = .init() + @Published var surfaceTree2: SplitTree = .init() /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false @@ -88,7 +88,8 @@ class BaseTerminalController: NSWindowController, init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree tree: Ghostty.SplitNode? = nil, + surfaceTree2 tree2: SplitTree? = nil ) { self.ghostty = ghostty self.derivedConfig = DerivedConfig(ghostty.config) @@ -98,9 +99,7 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) - - let firstView = Ghostty.SurfaceView(ghostty_app, baseConfig: base) - self.surfaceTree2 = .init(view: firstView) + self.surfaceTree2 = tree2 ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -175,16 +174,14 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { - for view in surfaceTree2 { - if let surfaceView = view as? Ghostty.SurfaceView { - // Our focus state requires that this window is key and our currently - // focused surface is the surface in this view. - let focused: Bool = (window?.isKeyWindow ?? false) && - !commandPaletteIsShowing && - focusedSurface != nil && - surfaceView == focusedSurface! - surfaceView.focusDidChange(focused) - } + for surfaceView in surfaceTree2 { + // Our focus state requires that this window is key and our currently + // focused surface is the surface in this view. + let focused: Bool = (window?.isKeyWindow ?? false) && + !commandPaletteIsShowing && + focusedSurface != nil && + surfaceView == focusedSurface! + surfaceView.focusDidChange(focused) } } @@ -335,7 +332,7 @@ class BaseTerminalController: NSWindowController, // Determine our desired direction guard let directionAny = notification.userInfo?["direction"] else { return } guard let direction = directionAny as? ghostty_action_split_direction_e else { return } - let splitDirection: SplitTree.NewDirection + let splitDirection: SplitTree.NewDirection switch (direction) { case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left @@ -388,16 +385,16 @@ class BaseTerminalController: NSWindowController, private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { // Also update surfaceTree2 - var surfaces2: [Ghostty.SurfaceView] = surfaceTree2.compactMap { $0 as? Ghostty.SurfaceView } - + var surfaces: [Ghostty.SurfaceView] = surfaceTree2.map { $0 } + // If we're the main window receiving key input, then we want to avoid // calling this on our focused surface because that'll trigger a double // flagsChanged call. if NSApp.mainWindow == window { - surfaces2 = surfaces2.filter { $0 != focusedSurface } + surfaces = surfaces.filter { $0 != focusedSurface } } - for surface in surfaces2 { + for surface in surfaces { surface.flagsChanged(with: event) } @@ -455,7 +452,7 @@ class BaseTerminalController: NSWindowController, func zoomStateDidChange(to: Bool) {} - func splitDidResize(node: SplitTree.Node, to newRatio: Double) { + func splitDidResize(node: SplitTree.Node, to newRatio: Double) { let resizedNode = node.resize(to: newRatio) do { surfaceTree2 = try surfaceTree2.replace(node: node, with: resizedNode) @@ -675,8 +672,7 @@ class BaseTerminalController: NSWindowController, func windowDidChangeOcclusionState(_ notification: Notification) { let visible = self.window?.occlusionState.contains(.visible) ?? false for view in surfaceTree2 { - if let surfaceView = view as? Ghostty.SurfaceView, - let surface = surfaceView.surface { + if let surface = view.surface { ghostty_surface_set_occlusion(surface, visible) } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 82491e76d..5c2f58dab 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil + withSurfaceTree tree: Ghostty.SplitNode? = nil, + withSurfaceTree2 tree2: SplitTree? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -44,7 +45,7 @@ class TerminalController: BaseTerminalController { // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree: tree) + super.init(ghostty, baseConfig: base, surfaceTree: tree, surfaceTree2: tree2) // Setup our notifications for behaviors let center = NotificationCenter.default diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 07735cb58..2968f8abd 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -197,9 +197,10 @@ class TerminalManager { /// Creates a window controller, adds it to our managed list, and returns it. func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController { + withSurfaceTree tree: Ghostty.SplitNode? = nil, + withSurfaceTree2 tree2: SplitTree? = nil) -> TerminalController { // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) + let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree, withSurfaceTree2: tree2) // Create a listener for when the window is closed so we can remove it. let pubClose = NotificationCenter.default.publisher( diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index b9d9b0ac0..5531494a5 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,14 +4,16 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 2 + static let version: Int = 3 let focusedSurface: String? let surfaceTree: Ghostty.SplitNode? + let surfaceTree2: SplitTree? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString self.surfaceTree = controller.surfaceTree + self.surfaceTree2 = controller.surfaceTree2 } init?(coder aDecoder: NSCoder) { @@ -27,6 +29,7 @@ class TerminalRestorableState: Codable { } self.surfaceTree = v.value.surfaceTree + self.surfaceTree2 = v.value.surfaceTree2 self.focusedSurface = v.value.focusedSurface } @@ -83,18 +86,37 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree) + let c = appDelegate.terminalManager.createWindow( + withSurfaceTree: state.surfaceTree, + withSurfaceTree2: state.surfaceTree2 + ) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return } // Setup our restored state on the controller + // First try to find the focused surface in surfaceTree2 if let focusedStr = state.focusedSurface, - let focusedUUID = UUID(uuidString: focusedStr), - let view = c.surfaceTree?.findUUID(uuid: focusedUUID) { - c.focusedSurface = view - restoreFocus(to: view, inWindow: window) + let focusedUUID = UUID(uuidString: focusedStr) { + // Try surfaceTree2 first + var foundView: Ghostty.SurfaceView? + for view in c.surfaceTree2 { + if view.uuid.uuidString == focusedStr { + foundView = view + break + } + } + + // Fall back to surfaceTree if not found + if foundView == nil { + foundView = c.surfaceTree?.findUUID(uuid: focusedUUID) + } + + if let view = foundView { + c.focusedSurface = view + restoreFocus(to: view, inWindow: window) + } } completionHandler(window, nil) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 2970f19c6..d13de4a72 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -21,7 +21,7 @@ protocol TerminalViewDelegate: AnyObject { func performAction(_ action: String, on: Ghostty.SurfaceView) /// A split is resizing to a given value. - func splitDidResize(node: SplitTree.Node, to newRatio: Double) + func splitDidResize(node: SplitTree.Node, to newRatio: Double) } /// The view model is a required implementation for TerminalView callers. This contains @@ -30,7 +30,7 @@ protocol TerminalViewDelegate: AnyObject { protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. - var surfaceTree2: SplitTree { get set } + var surfaceTree2: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 99f901792..0aecef6ad 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -6,7 +6,7 @@ import GhosttyKit extension Ghostty { /// The NSView implementation for a terminal surface. - class SurfaceView: OSView, ObservableObject { + class SurfaceView: OSView, ObservableObject, Codable { /// Unique ID per surface let uuid: UUID @@ -1431,6 +1431,35 @@ extension Ghostty { self.windowAppearance = .init(ghosttyConfig: config) } } + + // MARK: - Codable + + enum CodingKeys: String, CodingKey { + case pwd + case uuid + } + + required convenience init(from decoder: Decoder) throws { + // Decoding uses the global Ghostty app + guard let del = NSApplication.shared.delegate, + let appDel = del as? AppDelegate, + let app = appDel.ghostty.app else { + throw TerminalRestoreError.delegateInvalid + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) + var config = Ghostty.SurfaceConfiguration() + config.workingDirectory = try container.decode(String?.self, forKey: .pwd) + + self.init(app, baseConfig: config, uuid: uuid) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(pwd, forKey: .pwd) + try container.encode(uuid.uuidString, forKey: .uuid) + } } } From b84b715ddbf6de59c74d969bcb1597c06aaaa945 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 16:43:20 -0700 Subject: [PATCH 142/245] macos: unify confirm close in our terminal controllers --- .../Terminal/BaseTerminalController.swift | 92 ++++++++++--------- .../Terminal/TerminalController.swift | 23 ----- 2 files changed, 47 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b3409c437..1ffea9b4f 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -194,6 +194,40 @@ class BaseTerminalController: NSWindowController, savedFrame = .init(window: window.frame, screen: screen.visibleFrame) } + func confirmClose( + messageText: String, + informativeText: String, + completion: @escaping () -> Void + ) { + // If we already have an alert, we need to wait for that one. + guard alert == nil else { return } + + // If there is no window to attach the modal then we assume success + // since we'll never be able to show the modal. + guard let window else { + completion() + return + } + + // If we need confirmation by any, show one confirmation for all windows + // in the tab group. + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) { response in + self.alert = nil + if response == .alertFirstButtonReturn { + completion() + } + } + + // Store our alert so we only ever show one. + self.alert = alert + } + // MARK: Notifications @objc private func didChangeScreenParametersNotification(_ notification: Notification) { @@ -287,37 +321,19 @@ class BaseTerminalController: NSWindowController, return } - // If we don't have a window to attach our modal to, we also exit immediately. - // This should NOT happen. - guard let window = target.window else { - surfaceTree2 = surfaceTree2.remove(node) - return - } - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that // confirmationDialog allows the user to Cmd-W close the alert, but when doing // so SwiftUI does not update any of the bindings to note that window is no longer // being shown, and provides no callback to detect this. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { [weak self] response in - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - if let self { - self.surfaceTree2 = self.surfaceTree2.remove(node) - } - - default: - break + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { [weak self] in + if let self { + self.surfaceTree2 = self.surfaceTree2.remove(node) } - }) + } } @objc private func ghosttyDidNewSplit(_ notification: Notification) { @@ -624,26 +640,12 @@ class BaseTerminalController: NSWindowController, if (!node.needsConfirmQuit()) { return true } // We require confirmation, so show an alert as long as we aren't already. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - self.alert = nil - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - window.close() - - default: - break - } - }) - - self.alert = alert + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { + window.close() + } return false } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5c2f58dab..8e88952f0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -587,27 +587,6 @@ class TerminalController: BaseTerminalController { ghostty.newTab(surface: surface) } - private func confirmClose( - window: NSWindow, - messageText: String, - informativeText: String, - completion: @escaping () -> Void - ) { - // If we need confirmation by any, show one confirmation for all windows - // in the tab group. - let alert = NSAlert() - alert.messageText = messageText - alert.informativeText = informativeText - alert.addButton(withTitle: "Close") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window) { response in - if response == .alertFirstButtonReturn { - completion() - } - } - } - @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } guard window.tabGroup != nil else { @@ -618,7 +597,6 @@ class TerminalController: BaseTerminalController { if surfaceTree?.needsConfirmQuit() ?? false { confirmClose( - window: window, messageText: "Close Tab?", informativeText: "The terminal still has a running process. If you close the tab the process will be killed." ) { @@ -664,7 +642,6 @@ class TerminalController: BaseTerminalController { } confirmClose( - window: window, messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { From 0fb58298a78979e75c67717d0587ffc3c94430e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Jun 2025 19:41:21 -0700 Subject: [PATCH 143/245] macos: focus split previous/next --- macos/Ghostty.xcodeproj/project.pbxproj | 12 ++++ macos/Sources/Features/Splits/SplitTree.swift | 72 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 37 ++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 3 +- .../Helpers/Extensions/Array+Extension.swift | 19 +++++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Helpers/Extensions/Array+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 459b2b994..38e29a60e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; + A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -168,6 +169,7 @@ A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; + A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -292,6 +294,7 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, A5A6F7292CC41B8700B232A5 /* Xcode.swift */, @@ -442,6 +445,14 @@ path = Splits; sourceTree = ""; }; + A58636692DF0A98100E04A10 /* Extensions */ = { + isa = PBXGroup; + children = ( + A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; A5874D9B2DAD781100E83852 /* Private */ = { isa = PBXGroup; children = ( @@ -721,6 +732,7 @@ A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, + A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index a66d4abe7..a093934d8 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -50,6 +50,20 @@ struct SplitTree: Codable { case down case up } + + /// The direction that focus can move from a node. + enum FocusDirection { + // Follow a consistent tree-like structure. + case previous + case next + + // Geospatially-aware navigation targets. These take into account the + // dimensions of the view to find the correct node to go to. + case up + case down + case left + case right + } } // MARK: SplitTree @@ -111,6 +125,44 @@ extension SplitTree { return .init(root: newRoot, zoomed: newZoomed) } + + /// Find the next view to focus based on the current focused node and direction + func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { + guard let root else { return nil } + + switch direction { + case .previous: + // For previous, we traverse in order and find the previous leaf from our leftmost + let allLeaves = root.leaves() + let currentView = currentNode.leftmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + // Shouldn't be possible leftmostLeaf can't return something that doesn't exist! + return nil + } + let index = allLeaves.indexWrapping(before: currentIndex) + return allLeaves[index] + + case .next: + // For previous, we traverse in order and find the next leaf from our rightmost + let allLeaves = root.leaves() + let currentView = currentNode.rightmostLeaf() + guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else { + return nil + } + let index = allLeaves.indexWrapping(after: currentIndex) + return allLeaves[index] + + case .up, .down, .left, .right: + // For directional movement, we need to traverse the tree structure + return directionalTarget(for: direction, from: currentNode) + } + } + + /// Find focus target in a specific direction by traversing split boundaries + private func directionalTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { + // TODO + return nil + } } // MARK: SplitTree.Node @@ -331,6 +383,26 @@ extension SplitTree.Node { )) } } + + /// Get the leftmost leaf in this subtree + func leftmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.left.leftmostLeaf() + } + } + + /// Get the rightmost leaf in this subtree + func rightmostLeaf() -> ViewType { + switch self { + case .leaf(let view): + return view + case .split(let split): + return split.right.rightmostLeaf() + } + } } // MARK: SplitTree.Node Protocols diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1ffea9b4f..b6b745e82 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -145,6 +145,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidEqualizeSplits(_:)), name: Ghostty.Notification.didEqualizeSplits, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidFocusSplit(_:)), + name: Ghostty.Notification.ghosttyFocusSplit, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -386,6 +391,38 @@ class BaseTerminalController: NSWindowController, _ = container.equalize() } } + + @objc private func ghosttyDidFocusSplit(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree2.root?.node(view: target) != nil else { return } + + // Get the direction from the notification + guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } + + // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection + let focusDirection: SplitTree.FocusDirection + switch direction { + case .previous: focusDirection = .previous + case .next: focusDirection = .next + case .up: focusDirection = .up + case .down: focusDirection = .down + case .left: focusDirection = .left + case .right: focusDirection = .right + } + + // Find the node for the target surface + guard let targetNode = surfaceTree2.root?.node(view: target) else { return } + + // Find the next surface to focus + guard let nextSurface = surfaceTree2.focusTarget(for: focusDirection, from: targetNode) else { + return + } + + // Move focus to the next surface + Ghostty.moveFocus(to: nextSurface, from: target) + } // MARK: Local Events diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index d8fdaa3ec..95e04fc1e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -921,7 +921,8 @@ extension Ghostty { // we should only be returning true if we actually performed the action, // but this handles the most common case of caring about goto_split performability // which is the no-split case. - guard controller.surfaceTree?.isSplit ?? false else { return false } + // TODO: fix this + //guard controller.surfaceTree?.isSplit ?? false else { return false } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift new file mode 100644 index 000000000..6f005a349 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -0,0 +1,19 @@ +extension Array { + /// Returns the index before i, with wraparound. Assumes i is a valid index. + func indexWrapping(before i: Int) -> Int { + if i == 0 { + return count - 1 + } + + return i - 1 + } + + /// Returns the index after i, with wraparound. Assumes i is a valid index. + func indexWrapping(after i: Int) -> Int { + if i == count - 1 { + return 0 + } + + return i + 1 + } +} From 7dcfebcd5d99fe76c5d861e2c30ba4eb92f16fd5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 09:28:18 -0700 Subject: [PATCH 144/245] macos: isSplit guarding on focus split directions works --- macos/Sources/Features/Splits/SplitTree.swift | 7 ++++++- macos/Sources/Ghostty/Ghostty.App.swift | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index a093934d8..c3a4e3097 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -72,7 +72,12 @@ extension SplitTree { var isEmpty: Bool { root == nil } - + + /// Returns true if this tree is split. + var isSplit: Bool { + if case .split = root { true } else { false } + } + init() { self.init(root: nil, zoomed: nil) } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 95e04fc1e..08c284b04 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -921,8 +921,7 @@ extension Ghostty { // we should only be returning true if we actually performed the action, // but this handles the most common case of caring about goto_split performability // which is the no-split case. - // TODO: fix this - //guard controller.surfaceTree?.isSplit ?? false else { return false } + guard controller.surfaceTree2.isSplit else { return false } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, From aef61661a01f6e4d08012f775161c0e46358a5f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 09:42:12 -0700 Subject: [PATCH 145/245] macos: fix up command palette, focusing --- macos/Ghostty.xcodeproj/project.pbxproj | 2 +- .../Features/Terminal/BaseTerminalController.swift | 6 +++--- .../Features/Terminal/TerminalController.swift | 8 ++++---- .../Helpers/{ => Extensions}/NSView+Extension.swift | 13 +++++++++++++ 4 files changed, 21 insertions(+), 8 deletions(-) rename macos/Sources/Helpers/{ => Extensions}/NSView+Extension.swift (77%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 38e29a60e..8c73d55c5 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -314,7 +314,6 @@ A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, - C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, @@ -449,6 +448,7 @@ isa = PBXGroup; children = ( A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + C1F26EA62B738B9900404083 /* NSView+Extension.swift */, ); path = Extensions; sourceTree = ""; diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b6b745e82..6429f70a3 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -295,14 +295,14 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree2.contains(surfaceView) else { return } toggleCommandPalette(nil) } @objc private func ghosttyMaximizeDidToggle(_ notification: Notification) { guard let window else { return } guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree2.contains(surfaceView) else { return } window.zoom(nil) } @@ -468,7 +468,7 @@ class BaseTerminalController: NSWindowController, // want to care if the surface is in the tree so we don't listen to titles of // closed surfaces. if let titleSurface = focusedSurface ?? lastFocusedSurface, - surfaceTree?.contains(view: titleSurface) ?? false { + surfaceTree2.contains(titleSurface) { // If we have a surface, we want to listen for title changes. titleSurface.$title .sink { [weak self] in self?.titleDidChange(to: $0) } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 8e88952f0..a2687e1fe 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -159,7 +159,7 @@ class TerminalController: BaseTerminalController { // This is a surface-level config update. If we have the surface, we // update our appearance based on it. guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: surfaceView) ?? false else { return } + guard surfaceTree2.contains(surfaceView) else { return } // We can't use surfaceView.derivedConfig because it may not be updated // yet since it also responds to notifications. @@ -815,19 +815,19 @@ class TerminalController: BaseTerminalController { @objc private func onCloseTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree2.contains(target) else { return } closeTab(self) } @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree2.contains(target) else { return } closeWindow(self) } @objc private func onResetWindowSize(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree2.contains(target) else { return } returnToDefaultSize(nil) } diff --git a/macos/Sources/Helpers/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift similarity index 77% rename from macos/Sources/Helpers/NSView+Extension.swift rename to macos/Sources/Helpers/Extensions/NSView+Extension.swift index b9234a49a..48284df74 100644 --- a/macos/Sources/Helpers/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -1,6 +1,19 @@ import AppKit extension NSView { + /// Returns true if this view is currently in the responder chain + var isInResponderChain: Bool { + var responder = window?.firstResponder + while let currentResponder = responder { + if currentResponder === self { + return true + } + responder = currentResponder.nextResponder + } + + return false + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { From a389926ca7345be7da1ee1b4608fe63776e97aac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 09:47:55 -0700 Subject: [PATCH 146/245] macos: use surfaceTree2 needsConfirmQuit --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 4 ++-- macos/Sources/Features/Terminal/TerminalController.swift | 4 ++-- macos/Sources/Features/Terminal/TerminalManager.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6429f70a3..6c5718371 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -668,13 +668,13 @@ class BaseTerminalController: NSWindowController, guard let window = self.window else { return true } // If we have no surfaces, close. - guard let node = self.surfaceTree else { return true } + if surfaceTree2.isEmpty { return true } // If we already have an alert, continue with it guard alert == nil else { return false } // If our surfaces don't require confirmation, close. - if (!node.needsConfirmQuit()) { return true } + if !surfaceTree2.contains(where: { $0.needsConfirmQuit }) { return true } // We require confirmation, so show an alert as long as we aren't already. confirmClose( diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a2687e1fe..78bb58cc8 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -595,7 +595,7 @@ class TerminalController: BaseTerminalController { return } - if surfaceTree?.needsConfirmQuit() ?? false { + if surfaceTree2.contains(where: { $0.needsConfirmQuit }) { confirmClose( messageText: "Close Tab?", informativeText: "The terminal still has a running process. If you close the tab the process will be killed." @@ -632,7 +632,7 @@ class TerminalController: BaseTerminalController { guard let controller = tabWindow.windowController as? TerminalController else { return false } - return controller.surfaceTree?.needsConfirmQuit() ?? false + return controller.surfaceTree2.contains(where: { $0.needsConfirmQuit }) } // If none need confirmation then we can just close all the windows. diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 2968f8abd..475b70ac9 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -269,7 +269,7 @@ class TerminalManager { func closeAllWindows() { var needsConfirm: Bool = false for w in self.windows { - if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) { + if w.controller.surfaceTree2.contains(where: { $0.needsConfirmQuit }) { needsConfirm = true break } From ec7fd94d0ff325e2d7a6ead1ee792911b0e3b136 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 10:00:37 -0700 Subject: [PATCH 147/245] macos: equalize splits works with new tree --- macos/Sources/Features/Splits/SplitTree.swift | 44 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 7 ++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index c3a4e3097..ed4e2dba3 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -168,6 +168,14 @@ extension SplitTree { // TODO return nil } + + /// Equalize all splits in the tree so that each split's ratio is based on the + /// relative weight (number of leaves) of its children. + func equalize() -> Self { + guard let root else { return self } + let newRoot = root.equalize() + return .init(root: newRoot, zoomed: zoomed) + } } // MARK: SplitTree.Node @@ -408,6 +416,42 @@ extension SplitTree.Node { return split.right.rightmostLeaf() } } + + /// Equalize this node and all its children, returning a new node with splits + /// adjusted so that each split's ratio is based on the relative weight + /// (number of leaves) of its children. + func equalize() -> Node { + let (equalizedNode, _) = equalizeWithWeight() + return equalizedNode + } + + /// Internal helper that equalizes and returns both the node and its weight. + private func equalizeWithWeight() -> (node: Node, weight: Int) { + switch self { + case .leaf: + // A leaf has weight 1 and doesn't change + return (self, 1) + + case .split(let split): + // Recursively equalize children + let (leftNode, leftWeight) = split.left.equalizeWithWeight() + let (rightNode, rightWeight) = split.right.equalizeWithWeight() + + // Calculate new ratio based on relative weights + let totalWeight = leftWeight + rightWeight + let newRatio = Double(leftWeight) / Double(totalWeight) + + // Create new split with equalized ratio + let newSplit = Split( + direction: split.direction, + ratio: newRatio, + left: leftNode, + right: rightNode + ) + + return (.split(newSplit), totalWeight) + } + } } // MARK: SplitTree.Node Protocols diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6c5718371..5558aefe3 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -385,11 +385,10 @@ class BaseTerminalController: NSWindowController, guard let target = notification.object as? Ghostty.SurfaceView else { return } // Check if target surface is in current controller's tree - guard surfaceTree?.contains(view: target) ?? false else { return } + guard surfaceTree2.contains(target) else { return } - if case .split(let container) = surfaceTree { - _ = container.equalize() - } + // Equalize the splits + surfaceTree2 = surfaceTree2.equalize() } @objc private func ghosttyDidFocusSplit(_ notification: Notification) { From b7c01b5b4a6b1bc5a907807d91c9e9e70ab6af83 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 10:04:03 -0700 Subject: [PATCH 148/245] macos: spatial focus navigation --- macos/Sources/Features/Splits/SplitTree.swift | 356 +++++++++++++++++- .../Terminal/BaseTerminalController.swift | 8 +- 2 files changed, 343 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index ed4e2dba3..f458b5dee 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -40,6 +40,29 @@ struct SplitTree: Codable { } } + /// Spatial representation of the split tree. This can be used to better understand + /// its physical representation to perform tasks such as navigation. + struct Spatial { + let slots: [Slot] + + /// A single slot within the spatial mapping of a tree. Note that the bounds are + /// _relative_. They can't be mapped to physical pixels because the SplitTree + /// isn't aware of actual rendering. But relative to each other the bounds are + /// correct. + struct Slot { + let node: Node + let bounds: CGRect + } + + /// Direction for spatial navigation within the split tree. + enum Direction { + case left + case right + case up + case down + } + } + enum SplitError: Error { case viewNotFound } @@ -57,12 +80,10 @@ struct SplitTree: Codable { case previous case next - // Geospatially-aware navigation targets. These take into account the - // dimensions of the view to find the correct node to go to. - case up - case down - case left - case right + // Spatially-aware navigation targets. These take into account the + // layout to find the spatially correct node to move to. Spatial navigation + // is always from the top-left corner for now. + case spatial(Spatial.Direction) } } @@ -134,7 +155,7 @@ extension SplitTree { /// Find the next view to focus based on the current focused node and direction func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { guard let root else { return nil } - + switch direction { case .previous: // For previous, we traverse in order and find the previous leaf from our leftmost @@ -157,18 +178,33 @@ extension SplitTree { let index = allLeaves.indexWrapping(after: currentIndex) return allLeaves[index] - case .up, .down, .left, .right: - // For directional movement, we need to traverse the tree structure - return directionalTarget(for: direction, from: currentNode) + case .spatial(let spatialDirection): + // Get spatial representation and find best candidate + let spatial = root.spatial() + let nodes = spatial.slots(in: spatialDirection, from: currentNode) + + // If we have no nodes in the direction specified then we don't do + // anything. + if nodes.isEmpty { + return nil + } + + // Extract the view from the best candidate node + let bestNode = nodes[0].node + switch bestNode { + case .leaf(let view): + return view + case .split: + // If the best candidate is a split node, use its the leaf/rightmost + // depending on our spatial direction. + return switch (spatialDirection) { + case .up, .left: bestNode.leftmostLeaf() + case .down, .right: bestNode.rightmostLeaf() + } + } } } - - /// Find focus target in a specific direction by traversing split boundaries - private func directionalTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { - // TODO - return nil - } - + /// Equalize all splits in the tree so that each split's ratio is based on the /// relative weight (number of leaves) of its children. func equalize() -> Self { @@ -452,6 +488,292 @@ extension SplitTree.Node { return (.split(newSplit), totalWeight) } } + + /// Calculate the bounds of all views in this subtree based on split ratios + func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { + switch self { + case .leaf(let view): + return [(view, bounds)] + + case .split(let split): + // Calculate bounds for left and right based on split direction and ratio + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + // Split horizontally: left | right + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + + case .vertical: + // Split vertically: top / bottom + // Note: In our normalized coordinate system, Y increases upward + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + rightBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + } + + // Recursively calculate bounds for children + return split.left.calculateViewBounds(in: leftBounds) + + split.right.calculateViewBounds(in: rightBounds) + } + } +} + +// MARK: SplitTree.Node Spatial + +extension SplitTree.Node { + /// Returns the spatial representation of this node and its subtree. + /// + /// This method creates a `Spatial` representation that maps the logical split tree structure + /// to 2D coordinate space. The coordinate system uses (0,0) as the top-left corner with + /// positive X extending right and positive Y extending down. + /// + /// The spatial representation provides: + /// - Relative bounds for each node based on split ratios + /// - Grid-like dimensions where each split adds 1 to the column/row count + /// - Accurate positioning that reflects the actual layout structure + /// + /// The bounds are pixel perfect based on assuming that each row and column are 1 pixel + /// tall or wide, respectively. This needs to be scaled up to the proper bounds for a real + /// layout. + /// + /// Example: + /// ``` + /// // For a layout like: + /// // +--------+----+ + /// // | A | B | + /// // +--------+----+ + /// // | C | D | + /// // +--------+----+ + /// // + /// // The spatial representation would have: + /// // - Total dimensions: (width: 2, height: 2) + /// // - Node bounds based on actual split ratios + /// ``` + /// + /// - Returns: A `Spatial` struct containing all slots with their calculated bounds + func spatial() -> SplitTree.Spatial { + // First, calculate the total dimensions needed + let dimensions = dimensions() + + // Calculate slots with relative bounds + let slots = spatialSlots( + in: CGRect(x: 0, y: 0, width: Double(dimensions.width), height: Double(dimensions.height)) + ) + + return SplitTree.Spatial(slots: slots) + } + + /// Calculates the grid dimensions (columns and rows) needed to represent this subtree. + /// + /// This method recursively analyzes the split tree structure to determine how many + /// columns and rows are needed to represent the layout in a 2D grid. Each leaf node + /// occupies one grid cell (1×1), and each split extends the grid in one direction: + /// + /// - **Horizontal splits**: Add columns (increase width) + /// - **Vertical splits**: Add rows (increase height) + /// + /// The calculation rules are: + /// - **Leaf nodes**: Always (1, 1) - one column, one row + /// - **Horizontal splits**: Width = sum of children widths, Height = max of children heights + /// - **Vertical splits**: Width = max of children widths, Height = sum of children heights + /// + /// Example: + /// ``` + /// // Single leaf: (1, 1) + /// // Horizontal split with 2 leaves: (2, 1) + /// // Vertical split with 2 leaves: (1, 2) + /// // Complex layout with both: (2, 2) or larger + /// ``` + /// + /// - Returns: A tuple containing (width: columns, height: rows) as unsigned integers + private func dimensions() -> (width: UInt, height: UInt) { + switch self { + case .leaf: + return (1, 1) + + case .split(let split): + let leftDimensions = split.left.dimensions() + let rightDimensions = split.right.dimensions() + + switch split.direction { + case .horizontal: + // Horizontal split: width is sum, height is max + return ( + width: leftDimensions.width + rightDimensions.width, + height: Swift.max(leftDimensions.height, rightDimensions.height) + ) + + case .vertical: + // Vertical split: height is sum, width is max + return ( + width: Swift.max(leftDimensions.width, rightDimensions.width), + height: leftDimensions.height + rightDimensions.height + ) + } + } + } + + /// Calculates the spatial slots (nodes with bounds) for this subtree within the given bounds. + /// + /// This method recursively traverses the split tree and calculates the precise bounds + /// for each node based on the split ratios and directions. The bounds are calculated + /// relative to the provided bounds rectangle. + /// + /// The calculation process: + /// 1. **Leaf nodes**: Create a single slot with the provided bounds + /// 2. **Split nodes**: + /// - Divide the bounds according to the split ratio and direction + /// - Create a slot for the split node itself + /// - Recursively calculate slots for both children + /// - Return all slots combined + /// + /// Split ratio interpretation: + /// - **Horizontal splits**: Ratio determines left/right width distribution + /// - Left child gets `ratio * width` + /// - Right child gets `(1 - ratio) * width` + /// - **Vertical splits**: Ratio determines top/bottom height distribution + /// - Top (left) child gets `ratio * height` + /// - Bottom (right) child gets `(1 - ratio) * height` + /// + /// Coordinate system: (0,0) is top-left, positive X goes right, positive Y goes down. + /// + /// - Parameter bounds: The bounding rectangle to subdivide for this subtree + /// - Returns: An array of `Spatial.Slot` objects, each containing a node and its bounds + private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] { + switch self { + case .leaf: + // A leaf takes up our full bounds. + return [.init(node: self, bounds: bounds)] + + case .split(let split): + let leftBounds: CGRect + let rightBounds: CGRect + + switch split.direction { + case .horizontal: + // Split horizontally: left | right using the ratio + let splitX = bounds.minX + bounds.width * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width * split.ratio, + height: bounds.height + ) + rightBounds = CGRect( + x: splitX, + y: bounds.minY, + width: bounds.width * (1 - split.ratio), + height: bounds.height + ) + + case .vertical: + // Split vertically: top / bottom using the ratio + // Top-left is (0,0), so top (left) gets the upper portion + let splitY = bounds.minY + bounds.height * split.ratio + leftBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: bounds.height * split.ratio + ) + rightBounds = CGRect( + x: bounds.minX, + y: splitY, + width: bounds.width, + height: bounds.height * (1 - split.ratio) + ) + } + + // Recursively calculate slots for children and include a slot for this split + var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)] + slots += split.left.spatialSlots(in: leftBounds) + slots += split.right.spatialSlots(in: rightBounds) + + return slots + } + } +} + +// MARK: SplitTree.Spatial + +extension SplitTree.Spatial { + /// Returns all slots in the specified direction relative to the reference node. + /// + /// This method finds all slots positioned in the given direction from the reference node: + /// - **Left**: Slots with bounds to the left of the reference node + /// - **Right**: Slots with bounds to the right of the reference node + /// - **Up**: Slots with bounds above the reference node (Y=0 is top) + /// - **Down**: Slots with bounds below the reference node + /// + /// Results are sorted by distance from the reference node, with closest slots first. + /// Distance is calculated as the gap between the reference node and the candidate slot + /// in the direction of movement. + /// + /// - Parameters: + /// - direction: The direction to search for slots + /// - referenceNode: The node to use as the reference point + /// - Returns: An array of slots in the specified direction, sorted by distance (closest first) + func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { + guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } + + return switch direction { + case .left: + // Slots to the left: their right edge is at or left of reference's left edge + slots.filter { + $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX + }.sorted { + (refSlot.bounds.minX - $0.bounds.maxX) < (refSlot.bounds.minX - $1.bounds.maxX) + } + + case .right: + // Slots to the right: their left edge is at or right of reference's right edge + slots.filter { + $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX + }.sorted { + ($0.bounds.minX - refSlot.bounds.maxX) < ($1.bounds.minX - refSlot.bounds.maxX) + } + + case .up: + // Slots above: their bottom edge is at or above reference's top edge + slots.filter { + $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY + }.sorted { + (refSlot.bounds.minY - $0.bounds.maxY) < (refSlot.bounds.minY - $1.bounds.maxY) + } + + case .down: + // Slots below: their top edge is at or below reference's bottom edge + slots.filter { + $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY + }.sorted { + ($0.bounds.minY - refSlot.bounds.maxY) < ($1.bounds.minY - refSlot.bounds.maxY) + } + } + } } // MARK: SplitTree.Node Protocols diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5558aefe3..9b65854ab 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -405,10 +405,10 @@ class BaseTerminalController: NSWindowController, switch direction { case .previous: focusDirection = .previous case .next: focusDirection = .next - case .up: focusDirection = .up - case .down: focusDirection = .down - case .left: focusDirection = .left - case .right: focusDirection = .right + case .up: focusDirection = .spatial(.up) + case .down: focusDirection = .spatial(.down) + case .left: focusDirection = .spatial(.left) + case .right: focusDirection = .spatial(.right) } // Find the node for the target surface From ea1ff438f897be634f975adb96f0ae9a7b31789b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 11:23:55 -0700 Subject: [PATCH 149/245] macos: handle split zooming --- .../Splits/TerminalSplitTreeView.swift | 7 ++++-- .../Terminal/BaseTerminalController.swift | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 3969b2e74..4a41afc42 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -5,8 +5,11 @@ struct TerminalSplitTreeView: View { let onResize: (SplitTree.Node, Double) -> Void var body: some View { - if let node = tree.root { - TerminalSplitSubtreeView(node: node, isRoot: true, onResize: onResize) + if let node = tree.zoomed ?? tree.root { + TerminalSplitSubtreeView( + node: node, + isRoot: node == tree.root, + onResize: onResize) } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9b65854ab..86522ac9a 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -150,6 +150,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidFocusSplit(_:)), name: Ghostty.Notification.ghosttyFocusSplit, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidToggleSplitZoom(_:)), + name: Ghostty.Notification.didToggleSplitZoom, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -422,6 +427,24 @@ class BaseTerminalController: NSWindowController, // Move focus to the next surface Ghostty.moveFocus(to: nextSurface, from: target) } + + @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree2.root?.node(view: target) else { return } + + // Toggle the zoomed state + if surfaceTree2.zoomed == targetNode { + // Already zoomed, unzoom it + surfaceTree2 = SplitTree(root: surfaceTree2.root, zoomed: nil) + } else { + // Not zoomed or different node zoomed, zoom this node + surfaceTree2 = SplitTree(root: surfaceTree2.root, zoomed: targetNode) + } + + // Ensure focus stays on the target surface + Ghostty.moveFocus(to: target) + } // MARK: Local Events From 8b979d6dceae29d16d9806a171030ffb95cb6382 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 11:28:22 -0700 Subject: [PATCH 150/245] macos: handle surfaceTreeDidChange --- .../QuickTerminalController.swift | 4 ++-- .../Terminal/BaseTerminalController.swift | 21 ++++++++++--------- .../Terminal/TerminalController.swift | 16 +++++++------- .../Features/Terminal/TerminalView.swift | 7 ------- .../Ghostty/Ghostty.TerminalSplit.swift | 1 - macos/Sources/Ghostty/SurfaceView.swift | 9 -------- 6 files changed, 21 insertions(+), 37 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 1abe30da1..6de33e14f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -185,11 +185,11 @@ class QuickTerminalController: BaseTerminalController { // MARK: Base Controller Overrides - override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) // If our surface tree is nil then we animate the window out. - if (to == nil) { + if (to.isEmpty) { animateOut() } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 86522ac9a..f039e17ad 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -42,11 +42,11 @@ class BaseTerminalController: NSWindowController, } /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil { - didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } - } + @Published var surfaceTree: Ghostty.SplitNode? = nil - @Published var surfaceTree2: SplitTree = .init() + @Published var surfaceTree2: SplitTree = .init() { + didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree2) } + } /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false @@ -174,9 +174,9 @@ class BaseTerminalController: NSWindowController, /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. - func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { // If our surface tree becomes nil then we have no focused surface. - if (to == nil) { + if (to.isEmpty) { focusedSurface = nil } } @@ -442,8 +442,11 @@ class BaseTerminalController: NSWindowController, surfaceTree2 = SplitTree(root: surfaceTree2.root, zoomed: targetNode) } - // Ensure focus stays on the target surface - Ghostty.moveFocus(to: target) + // Ensure focus stays on the target surface. We lose focus when we do + // this so we need to grab it again. + DispatchQueue.main.async { + Ghostty.moveFocus(to: target) + } } // MARK: Local Events @@ -525,8 +528,6 @@ class BaseTerminalController: NSWindowController, self.window?.contentResizeIncrements = to } - func zoomStateDidChange(to: Bool) {} - func splitDidResize(node: SplitTree.Node, to newRatio: Double) { let resizedNode = node.resize(to: newRatio) do { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 78bb58cc8..120ac6377 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -106,15 +106,20 @@ class TerminalController: BaseTerminalController { // MARK: Base Controller Overrides - override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() + // Update our zoom state + if let window = window as? TerminalWindow { + window.surfaceIsZoomed = to.zoomed != nil + } + // If our surface tree is now nil then we close our window. - if (to == nil) { + if (to.isEmpty) { self.window?.close() } } @@ -677,12 +682,7 @@ class TerminalController: BaseTerminalController { toolbar.titleText = to } } - - override func zoomStateDidChange(to: Bool) { - guard let window = window as? TerminalWindow else { return } - window.surfaceIsZoomed = to - } - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index d13de4a72..6c990d496 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -14,9 +14,6 @@ protocol TerminalViewDelegate: AnyObject { /// The cell size changed. func cellSizeDidChange(to: NSSize) - /// This is called when a split is zoomed. - func zoomStateDidChange(to: Bool) - /// Perform an action. At the time of writing this is only triggered by the command palette. func performAction(_ action: String, on: Ghostty.SurfaceView) @@ -56,7 +53,6 @@ struct TerminalView: View { // Various state values sent back up from the currently focused terminals. @FocusedValue(\.ghosttySurfaceView) private var focusedSurface @FocusedValue(\.ghosttySurfacePwd) private var surfacePwd - @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit @FocusedValue(\.ghosttySurfaceCellSize) private var cellSize // The pwd of the focused surface as a URL @@ -101,9 +97,6 @@ struct TerminalView: View { guard let size = newValue else { return } self.delegate?.cellSizeDidChange(to: size) } - .onChange(of: zoomedSplit) { newValue in - self.delegate?.zoomStateDidChange(to: newValue ?? false) - } } // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 92528ace7..ccb7cca38 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -30,7 +30,6 @@ extension Ghostty { InspectableSurface(surfaceView: surfaceView) } } - .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil) } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 1e9a4cfef..513e5af46 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -502,15 +502,6 @@ extension FocusedValues { typealias Value = String } - var ghosttySurfaceZoomed: Bool? { - get { self[FocusedGhosttySurfaceZoomed.self] } - set { self[FocusedGhosttySurfaceZoomed.self] = newValue } - } - - struct FocusedGhosttySurfaceZoomed: FocusedValueKey { - typealias Value = Bool - } - var ghosttySurfaceCellSize: OSSize? { get { self[FocusedGhosttySurfaceCellSize.self] } set { self[FocusedGhosttySurfaceCellSize.self] = newValue } From 22819f8a296e799b75904f03ddf1d4204fd9c6be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 11:40:54 -0700 Subject: [PATCH 151/245] macos: transfer doesBorderTop --- macos/Sources/Features/Splits/SplitTree.swift | 33 +++++++++++++++++++ .../Terminal/TerminalController.swift | 10 ++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index f458b5dee..78bed7120 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -774,6 +774,39 @@ extension SplitTree.Spatial { } } } + + /// Returns whether the given node borders the specified side of the spatial bounds. + /// + /// This method checks if a node's bounds touch the edge of the overall spatial area: + /// - **Up**: Node's top edge touches the top of the spatial area (Y=0) + /// - **Down**: Node's bottom edge touches the bottom of the spatial area (Y=maxY) + /// - **Left**: Node's left edge touches the left of the spatial area (X=0) + /// - **Right**: Node's right edge touches the right of the spatial area (X=maxX) + /// + /// - Parameters: + /// - side: The side of the spatial bounds to check + /// - node: The node to check if it borders the specified side + /// - Returns: True if the node borders the specified side, false otherwise + func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool { + // Find the slot for this node + guard let slot = slots.first(where: { $0.node == node }) else { return false } + + // Calculate the overall bounds of all slots + let overallBounds = slots.reduce(CGRect.null) { result, slot in + result.union(slot.bounds) + } + + return switch side { + case .up: + slot.bounds.minY == overallBounds.minY + case .down: + slot.bounds.maxY == overallBounds.maxY + case .left: + slot.bounds.minX == overallBounds.minX + case .right: + slot.bounds.maxX == overallBounds.maxX + } + } } // MARK: SplitTree.Node Protocols diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 120ac6377..eb140b2a3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -282,15 +282,19 @@ class TerminalController: BaseTerminalController { // If it does, we match the focused surface. If it doesn't, we use the app // configuration. let backgroundColor: OSColor - if let surfaceTree { - if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { + if !surfaceTree2.isEmpty { + if let focusedSurface = focusedSurface, + let treeRoot = surfaceTree2.root, + let focusedNode = treeRoot.node(view: focusedSurface), + treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { // 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. - backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor) + let topLeftSurface = surfaceTree2.root?.leftmostLeaf() + backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor) } } else { backgroundColor = OSColor(self.derivedConfig.backgroundColor) From f1ed07caf441909871cde91e51a124930fa3f1f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 11:51:38 -0700 Subject: [PATCH 152/245] macos: Remove the legacy SurfaceTree --- macos/Sources/App/macOS/AppDelegate.swift | 6 ++++-- .../QuickTerminal/QuickTerminalController.swift | 17 +++++++++-------- .../Terminal/BaseTerminalController.swift | 10 +++------- .../Features/Terminal/TerminalController.swift | 8 ++++---- .../Features/Terminal/TerminalManager.swift | 3 +-- .../Features/Terminal/TerminalRestorable.swift | 17 +++-------------- 6 files changed, 24 insertions(+), 37 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 38b26f606..b5023370b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -741,8 +741,10 @@ class AppDelegate: NSObject, func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in terminalManager.windows { - if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) { - return v + for view in c.controller.surfaceTree2 { + if view.uuid == uuid { + return view + } } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 6de33e14f..b338f8b47 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -30,11 +30,11 @@ class QuickTerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil + surfaceTree2 tree2: SplitTree? = nil ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree: tree) + super.init(ghostty, baseConfig: base, surfaceTree2: tree2) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -233,13 +233,14 @@ class QuickTerminalController: BaseTerminalController { // Animate the window in animateWindowIn(window: window, from: position) - // If our surface tree is nil then we initialize a new terminal. The surface - // tree can be nil if for example we run "eixt" in the terminal and force + // If our surface tree is empty then we initialize a new terminal. The surface + // tree can be empty if for example we run "exit" in the terminal and force // animate out. - if (surfaceTree == nil) { - let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) - surfaceTree = .leaf(leaf) - focusedSurface = leaf.surface + if surfaceTree2.isEmpty, + let ghostty_app = ghostty.app { + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) + surfaceTree2 = SplitTree(view: view) + focusedSurface = view } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f039e17ad..028a4bece 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -41,9 +41,7 @@ class BaseTerminalController: NSWindowController, didSet { syncFocusToSurfaceTree() } } - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil - + /// The tree of splits within this terminal window. @Published var surfaceTree2: SplitTree = .init() { didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree2) } } @@ -88,7 +86,6 @@ class BaseTerminalController: NSWindowController, init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: Ghostty.SplitNode? = nil, surfaceTree2 tree2: SplitTree? = nil ) { self.ghostty = ghostty @@ -98,7 +95,6 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) self.surfaceTree2 = tree2 ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors @@ -171,11 +167,11 @@ class BaseTerminalController: NSWindowController, } } - /// Called when the surfaceTree variable changed. + /// Called when the surfaceTree2 variable changed. /// /// Subclasses should call super first. func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { - // If our surface tree becomes nil then we have no focused surface. + // If our surface tree becomes empty then we have no focused surface. if (to.isEmpty) { focusedSurface = nil } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index eb140b2a3..29f2710fe 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -45,7 +45,7 @@ class TerminalController: BaseTerminalController { // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree: tree, surfaceTree2: tree2) + super.init(ghostty, baseConfig: base, surfaceTree2: tree2) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -154,7 +154,7 @@ class TerminalController: BaseTerminalController { // 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 - if surfaceTree == nil { + if surfaceTree2.isEmpty { syncAppearance(.init(config)) } @@ -456,10 +456,10 @@ class TerminalController: BaseTerminalController { // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. - if case let .leaf(leaf) = surfaceTree { + if case let .leaf(view) = surfaceTree2.root { // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. - focusedSurface = leaf.surface + focusedSurface = view if let defaultSize { window.setFrame(defaultSize, display: true) diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 475b70ac9..28b969d36 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -197,10 +197,9 @@ class TerminalManager { /// Creates a window controller, adds it to our managed list, and returns it. func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil, withSurfaceTree2 tree2: SplitTree? = nil) -> TerminalController { // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree, withSurfaceTree2: tree2) + let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree2: tree2) // Create a listener for when the window is closed so we can remove it. let pubClose = NotificationCenter.default.publisher( diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 5531494a5..6d5289955 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -7,12 +7,10 @@ class TerminalRestorableState: Codable { static let version: Int = 3 let focusedSurface: String? - let surfaceTree: Ghostty.SplitNode? - let surfaceTree2: SplitTree? + let surfaceTree2: SplitTree init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString - self.surfaceTree = controller.surfaceTree self.surfaceTree2 = controller.surfaceTree2 } @@ -28,7 +26,6 @@ class TerminalRestorableState: Codable { return nil } - self.surfaceTree = v.value.surfaceTree self.surfaceTree2 = v.value.surfaceTree2 self.focusedSurface = v.value.focusedSurface } @@ -87,7 +84,6 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // createWindow so that AppKit can place the window wherever it should // be. let c = appDelegate.terminalManager.createWindow( - withSurfaceTree: state.surfaceTree, withSurfaceTree2: state.surfaceTree2 ) guard let window = c.window else { @@ -96,10 +92,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } // Setup our restored state on the controller - // First try to find the focused surface in surfaceTree2 - if let focusedStr = state.focusedSurface, - let focusedUUID = UUID(uuidString: focusedStr) { - // Try surfaceTree2 first + // Find the focused surface in surfaceTree2 + if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? for view in c.surfaceTree2 { if view.uuid.uuidString == focusedStr { @@ -108,11 +102,6 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } } - // Fall back to surfaceTree if not found - if foundView == nil { - foundView = c.surfaceTree?.findUUID(uuid: focusedUUID) - } - if let view = foundView { c.focusedSurface = view restoreFocus(to: view, inWindow: window) From 77458ef3089214e8a073671fb6a56de565545033 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 11:53:19 -0700 Subject: [PATCH 153/245] macos: rename surfaceTree2 to surfaceTree --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../QuickTerminalController.swift | 8 +-- .../Terminal/BaseTerminalController.swift | 57 +++++++++---------- .../Terminal/TerminalController.swift | 29 +++++----- .../Features/Terminal/TerminalManager.swift | 6 +- .../Terminal/TerminalRestorable.swift | 12 ++-- .../Features/Terminal/TerminalView.swift | 4 +- macos/Sources/Ghostty/Ghostty.App.swift | 2 +- 8 files changed, 59 insertions(+), 61 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b5023370b..c6816d50c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -741,7 +741,7 @@ class AppDelegate: NSObject, func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in terminalManager.windows { - for view in c.controller.surfaceTree2 { + for view in c.controller.surfaceTree { if view.uuid == uuid { return view } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index b338f8b47..0dcfce204 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -30,11 +30,11 @@ class QuickTerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree2 tree2: SplitTree? = nil + surfaceTree tree: SplitTree? = nil ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree2: tree2) + super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -236,10 +236,10 @@ class QuickTerminalController: BaseTerminalController { // If our surface tree is empty then we initialize a new terminal. The surface // tree can be empty if for example we run "exit" in the terminal and force // animate out. - if surfaceTree2.isEmpty, + if surfaceTree.isEmpty, let ghostty_app = ghostty.app { let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) - surfaceTree2 = SplitTree(view: view) + surfaceTree = SplitTree(view: view) focusedSurface = view } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 028a4bece..3cc5843a5 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -42,8 +42,8 @@ class BaseTerminalController: NSWindowController, } /// The tree of splits within this terminal window. - @Published var surfaceTree2: SplitTree = .init() { - didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree2) } + @Published var surfaceTree: SplitTree = .init() { + didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } /// This can be set to show/hide the command palette. @@ -86,7 +86,7 @@ class BaseTerminalController: NSWindowController, init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree2 tree2: SplitTree? = nil + surfaceTree tree: SplitTree? = nil ) { self.ghostty = ghostty self.derivedConfig = DerivedConfig(ghostty.config) @@ -95,7 +95,7 @@ class BaseTerminalController: NSWindowController, // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree2 = tree2 ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) + self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -167,7 +167,7 @@ class BaseTerminalController: NSWindowController, } } - /// Called when the surfaceTree2 variable changed. + /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { @@ -180,7 +180,7 @@ class BaseTerminalController: NSWindowController, /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { - for surfaceView in surfaceTree2 { + for surfaceView in surfaceTree { // Our focus state requires that this window is key and our currently // focused surface is the surface in this view. let focused: Bool = (window?.isKeyWindow ?? false) && @@ -296,21 +296,21 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.contains(surfaceView) else { return } + guard surfaceTree.contains(surfaceView) else { return } toggleCommandPalette(nil) } @objc private func ghosttyMaximizeDidToggle(_ notification: Notification) { guard let window else { return } guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.contains(surfaceView) else { return } + guard surfaceTree.contains(surfaceView) else { return } window.zoom(nil) } @objc private func ghosttyDidCloseSurface(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard let node = surfaceTree2.root?.node(view: target) else { return } + guard let node = surfaceTree.root?.node(view: target) else { return } // TODO: fix focus @@ -323,7 +323,7 @@ class BaseTerminalController: NSWindowController, // If the child process is not alive, then we exit immediately guard processAlive else { - surfaceTree2 = surfaceTree2.remove(node) + surfaceTree = surfaceTree.remove(node) return } @@ -337,7 +337,7 @@ class BaseTerminalController: NSWindowController, informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." ) { [weak self] in if let self { - self.surfaceTree2 = self.surfaceTree2.remove(node) + self.surfaceTree = self.surfaceTree.remove(node) } } } @@ -345,7 +345,7 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidNewSplit(_ notification: Notification) { // The target must be within our tree guard let oldView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.root?.node(view: oldView) != nil else { return } + guard surfaceTree.root?.node(view: oldView) != nil else { return } // Notification must contain our base config let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] @@ -369,7 +369,7 @@ class BaseTerminalController: NSWindowController, // Do the split do { - surfaceTree2 = try surfaceTree2.insert(view: newView, at: oldView, direction: splitDirection) + surfaceTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) } catch { // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its @@ -386,16 +386,16 @@ class BaseTerminalController: NSWindowController, guard let target = notification.object as? Ghostty.SurfaceView else { return } // Check if target surface is in current controller's tree - guard surfaceTree2.contains(target) else { return } + guard surfaceTree.contains(target) else { return } // Equalize the splits - surfaceTree2 = surfaceTree2.equalize() + surfaceTree = surfaceTree.equalize() } @objc private func ghosttyDidFocusSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.root?.node(view: target) != nil else { return } + guard surfaceTree.root?.node(view: target) != nil else { return } // Get the direction from the notification guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } @@ -413,10 +413,10 @@ class BaseTerminalController: NSWindowController, } // Find the node for the target surface - guard let targetNode = surfaceTree2.root?.node(view: target) else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Find the next surface to focus - guard let nextSurface = surfaceTree2.focusTarget(for: focusDirection, from: targetNode) else { + guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { return } @@ -427,15 +427,15 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard let targetNode = surfaceTree2.root?.node(view: target) else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Toggle the zoomed state - if surfaceTree2.zoomed == targetNode { + if surfaceTree.zoomed == targetNode { // Already zoomed, unzoom it - surfaceTree2 = SplitTree(root: surfaceTree2.root, zoomed: nil) + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) } else { // Not zoomed or different node zoomed, zoom this node - surfaceTree2 = SplitTree(root: surfaceTree2.root, zoomed: targetNode) + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) } // Ensure focus stays on the target surface. We lose focus when we do @@ -458,8 +458,7 @@ class BaseTerminalController: NSWindowController, } private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { - // Also update surfaceTree2 - var surfaces: [Ghostty.SurfaceView] = surfaceTree2.map { $0 } + var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 } // If we're the main window receiving key input, then we want to avoid // calling this on our focused surface because that'll trigger a double @@ -489,7 +488,7 @@ class BaseTerminalController: NSWindowController, // want to care if the surface is in the tree so we don't listen to titles of // closed surfaces. if let titleSurface = focusedSurface ?? lastFocusedSurface, - surfaceTree2.contains(titleSurface) { + surfaceTree.contains(titleSurface) { // If we have a surface, we want to listen for title changes. titleSurface.$title .sink { [weak self] in self?.titleDidChange(to: $0) } @@ -527,7 +526,7 @@ class BaseTerminalController: NSWindowController, func splitDidResize(node: SplitTree.Node, to newRatio: Double) { let resizedNode = node.resize(to: newRatio) do { - surfaceTree2 = try surfaceTree2.replace(node: node, with: resizedNode) + surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) } catch { // TODO: log return @@ -687,13 +686,13 @@ class BaseTerminalController: NSWindowController, guard let window = self.window else { return true } // If we have no surfaces, close. - if surfaceTree2.isEmpty { return true } + if surfaceTree.isEmpty { return true } // If we already have an alert, continue with it guard alert == nil else { return false } // If our surfaces don't require confirmation, close. - if !surfaceTree2.contains(where: { $0.needsConfirmQuit }) { return true } + if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true } // We require confirmation, so show an alert as long as we aren't already. confirmClose( @@ -729,7 +728,7 @@ class BaseTerminalController: NSWindowController, func windowDidChangeOcclusionState(_ notification: Notification) { let visible = self.window?.occlusionState.contains(.visible) ?? false - for view in surfaceTree2 { + for view in surfaceTree { if let surface = view.surface { ghostty_surface_set_occlusion(surface, visible) } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 29f2710fe..42eb7eca4 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -32,8 +32,7 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: Ghostty.SplitNode? = nil, - withSurfaceTree2 tree2: SplitTree? = nil + withSurfaceTree tree: SplitTree? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -45,7 +44,7 @@ class TerminalController: BaseTerminalController { // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree2: tree2) + super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -154,7 +153,7 @@ class TerminalController: BaseTerminalController { // 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 - if surfaceTree2.isEmpty { + if surfaceTree.isEmpty { syncAppearance(.init(config)) } @@ -164,7 +163,7 @@ class TerminalController: BaseTerminalController { // This is a surface-level config update. If we have the surface, we // update our appearance based on it. guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.contains(surfaceView) else { return } + guard surfaceTree.contains(surfaceView) else { return } // We can't use surfaceView.derivedConfig because it may not be updated // yet since it also responds to notifications. @@ -239,7 +238,7 @@ class TerminalController: BaseTerminalController { window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor // Sync our zoom state for splits - window.surfaceIsZoomed = surfaceTree2.zoomed != nil + window.surfaceIsZoomed = surfaceTree.zoomed != nil // If our window is not visible, then we do nothing. Some things such as blurring // have no effect if the window is not visible. Ultimately, we'll have this called @@ -282,9 +281,9 @@ class TerminalController: BaseTerminalController { // If it does, we match the focused surface. If it doesn't, we use the app // configuration. let backgroundColor: OSColor - if !surfaceTree2.isEmpty { + if !surfaceTree.isEmpty { if let focusedSurface = focusedSurface, - let treeRoot = surfaceTree2.root, + let treeRoot = surfaceTree.root, let focusedNode = treeRoot.node(view: focusedSurface), treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { // Similar to above, an alpha component of "0" causes compositor issues, so @@ -293,7 +292,7 @@ class TerminalController: BaseTerminalController { } 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. - let topLeftSurface = surfaceTree2.root?.leftmostLeaf() + let topLeftSurface = surfaceTree.root?.leftmostLeaf() backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor) } } else { @@ -456,7 +455,7 @@ class TerminalController: BaseTerminalController { // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. - if case let .leaf(view) = surfaceTree2.root { + if case let .leaf(view) = surfaceTree.root { // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. focusedSurface = view @@ -604,7 +603,7 @@ class TerminalController: BaseTerminalController { return } - if surfaceTree2.contains(where: { $0.needsConfirmQuit }) { + if surfaceTree.contains(where: { $0.needsConfirmQuit }) { confirmClose( messageText: "Close Tab?", informativeText: "The terminal still has a running process. If you close the tab the process will be killed." @@ -641,7 +640,7 @@ class TerminalController: BaseTerminalController { guard let controller = tabWindow.windowController as? TerminalController else { return false } - return controller.surfaceTree2.contains(where: { $0.needsConfirmQuit }) + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) } // If none need confirmation then we can just close all the windows. @@ -819,19 +818,19 @@ class TerminalController: BaseTerminalController { @objc private func onCloseTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.contains(target) else { return } + guard surfaceTree.contains(target) else { return } closeTab(self) } @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.contains(target) else { return } + guard surfaceTree.contains(target) else { return } closeWindow(self) } @objc private func onResetWindowSize(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree2.contains(target) else { return } + guard surfaceTree.contains(target) else { return } returnToDefaultSize(nil) } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 28b969d36..805ae6e93 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -197,9 +197,9 @@ class TerminalManager { /// Creates a window controller, adds it to our managed list, and returns it. func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree2 tree2: SplitTree? = nil) -> TerminalController { + withSurfaceTree tree: SplitTree? = nil) -> TerminalController { // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree2: tree2) + let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) // Create a listener for when the window is closed so we can remove it. let pubClose = NotificationCenter.default.publisher( @@ -268,7 +268,7 @@ class TerminalManager { func closeAllWindows() { var needsConfirm: Bool = false for w in self.windows { - if w.controller.surfaceTree2.contains(where: { $0.needsConfirmQuit }) { + if w.controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) { needsConfirm = true break } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 6d5289955..5229dc46e 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -7,11 +7,11 @@ class TerminalRestorableState: Codable { static let version: Int = 3 let focusedSurface: String? - let surfaceTree2: SplitTree + let surfaceTree: SplitTree init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.uuid.uuidString - self.surfaceTree2 = controller.surfaceTree2 + self.surfaceTree = controller.surfaceTree } init?(coder aDecoder: NSCoder) { @@ -26,7 +26,7 @@ class TerminalRestorableState: Codable { return nil } - self.surfaceTree2 = v.value.surfaceTree2 + self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface } @@ -84,7 +84,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // createWindow so that AppKit can place the window wherever it should // be. let c = appDelegate.terminalManager.createWindow( - withSurfaceTree2: state.surfaceTree2 + withSurfaceTree: state.surfaceTree ) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) @@ -92,10 +92,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } // Setup our restored state on the controller - // Find the focused surface in surfaceTree2 + // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? - for view in c.surfaceTree2 { + for view in c.surfaceTree { if view.uuid.uuidString == focusedStr { foundView = view break diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 6c990d496..cb6f11bce 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -27,7 +27,7 @@ protocol TerminalViewDelegate: AnyObject { protocol TerminalViewModel: ObservableObject { /// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView /// and children. This should be @Published. - var surfaceTree2: SplitTree { get set } + var surfaceTree: SplitTree { get set } /// The command palette state. var commandPaletteIsShowing: Bool { get set } @@ -77,7 +77,7 @@ struct TerminalView: View { } TerminalSplitTreeView( - tree: viewModel.surfaceTree2, + tree: viewModel.surfaceTree, onResize: { delegate?.splitDidResize(node: $0, to: $1) }) .environmentObject(ghostty) .focused($focused) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 08c284b04..4a9dc0ea6 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -921,7 +921,7 @@ extension Ghostty { // we should only be returning true if we actually performed the action, // but this handles the most common case of caring about goto_split performability // which is the no-split case. - guard controller.surfaceTree2.isSplit else { return false } + guard controller.surfaceTree.isSplit else { return false } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, From 6c97e4a59a22d8272acaf4147b0b093ef4826ff7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 12:01:50 -0700 Subject: [PATCH 154/245] macos: fix focus after closing splits --- .../Terminal/BaseTerminalController.swift | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 3cc5843a5..dfc8a2221 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -234,6 +234,39 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + // MARK: Focus Management + + /// Find the next surface to focus when a node is being closed. + /// Goes to previous split unless we're the leftmost leaf, then goes to next. + private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { + guard let root = surfaceTree.root else { return nil } + + // If we're the leftmost, then we move to the next surface after closing. + // Otherwise, we move to the previous. + if root.leftmostLeaf() == node.leftmostLeaf() { + return surfaceTree.focusTarget(for: .next, from: node) + } else { + return surfaceTree.focusTarget(for: .previous, from: node) + } + } + + /// Remove a node from the surface tree and move focus appropriately. + private func removeSurfaceAndMoveFocus(_ node: SplitTree.Node) { + let nextTarget = findNextFocusTargetAfterClosing(node: node) + let oldFocused = focusedSurface + let focused = node.contains { $0 == focusedSurface } + + // Remove the node from the tree + surfaceTree = surfaceTree.remove(node) + + // Move focus if the closed surface was focused and we have a next target + if let nextTarget, focused { + DispatchQueue.main.async { + Ghostty.moveFocus(to: nextTarget, from: oldFocused) + } + } + } + // MARK: Notifications @objc private func didChangeScreenParametersNotification(_ notification: Notification) { @@ -312,8 +345,6 @@ class BaseTerminalController: NSWindowController, guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let node = surfaceTree.root?.node(view: target) else { return } - // TODO: fix focus - var processAlive = false if let valueAny = notification.userInfo?["process_alive"] { if let value = valueAny as? Bool { @@ -323,7 +354,7 @@ class BaseTerminalController: NSWindowController, // If the child process is not alive, then we exit immediately guard processAlive else { - surfaceTree = surfaceTree.remove(node) + removeSurfaceAndMoveFocus(node) return } @@ -337,7 +368,7 @@ class BaseTerminalController: NSWindowController, informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." ) { [weak self] in if let self { - self.surfaceTree = self.surfaceTree.remove(node) + self.removeSurfaceAndMoveFocus(node) } } } From 19a9156ae1d562250b42023d1914f2482805cec4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 12:11:33 -0700 Subject: [PATCH 155/245] macos: address remaining todos --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 4 ++-- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index dfc8a2221..be4b59e7a 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -405,7 +405,7 @@ class BaseTerminalController: NSWindowController, // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its // no big deal. - // TODO: log + Ghostty.logger.warning("failed to insert split: \(error)") return } @@ -559,7 +559,7 @@ class BaseTerminalController: NSWindowController, do { surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) } catch { - // TODO: log + Ghostty.logger.warning("failed to replace node during split resize: \(error)") return } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 42eb7eca4..9c1e82b69 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -152,7 +152,7 @@ class TerminalController: BaseTerminalController { // 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 + // don't call this because focused surface changes will trigger appearance updates. if surfaceTree.isEmpty { syncAppearance(.init(config)) } From 5299f10e13ec73e47d0e67843e9e56bf560bdbd5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 12:17:50 -0700 Subject: [PATCH 156/245] macos: unzoom on new split and focus change --- macos/Sources/Features/Splits/SplitTree.swift | 3 ++- macos/Sources/Features/Terminal/BaseTerminalController.swift | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 78bed7120..d47e51bec 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -108,11 +108,12 @@ extension SplitTree { } /// Insert a new view at the given view point by creating a split in the given direction. + /// This will always reset the zoomed state of the tree. func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { guard let root else { throw SplitError.viewNotFound } return .init( root: try root.insert(view: view, at: at, direction: direction), - zoomed: zoomed) + zoomed: nil) } /// Remove a node from the tree. If the node being removed is part of a split, diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index be4b59e7a..ba57fbf70 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -451,6 +451,11 @@ class BaseTerminalController: NSWindowController, return } + // Remove the zoomed state for this surface tree. + if surfaceTree.zoomed != nil { + surfaceTree = .init(root: surfaceTree.root, zoomed: nil) + } + // Move focus to the next surface Ghostty.moveFocus(to: nextSurface, from: target) } From 69c3c359cb65cf0e09f8a3c8e7013b02f23c6005 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 12:53:31 -0700 Subject: [PATCH 157/245] macos: resize split keybind handling --- macos/Sources/Features/Splits/SplitTree.swift | 176 +++++++++++++++++- .../Terminal/BaseTerminalController.swift | 37 ++++ 2 files changed, 206 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index d47e51bec..ab4b387a4 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -213,6 +213,108 @@ extension SplitTree { let newRoot = root.equalize() return .init(root: newRoot, zoomed: zoomed) } + + /// Resize a node in the tree by the given pixel amount in the specified direction. + /// + /// This method adjusts the split ratios of the tree to accommodate the requested resize + /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts + /// its ratio. For left/right resizing, it finds the nearest parent horizontal split. + /// The bounds parameter is used to construct the spatial tree representation which is + /// needed to calculate the current pixel dimensions. + /// + /// This will always reset the zoomed state. + /// + /// - Parameters: + /// - node: The node to resize + /// - by: The number of pixels to resize by + /// - direction: The direction to resize in (up, down, left, right) + /// - bounds: The bounds used to construct the spatial tree representation + /// - Returns: A new SplitTree with the adjusted split ratios + /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists + func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { + guard let root else { throw SplitError.viewNotFound } + + // Find the path to the target node + guard let path = root.path(to: node) else { + throw SplitError.viewNotFound + } + + // Determine which type of split we need to find based on resize direction + let targetSplitDirection: Direction = switch direction { + case .up, .down: .vertical + case .left, .right: .horizontal + } + + // Find the nearest parent split of the correct type by walking up the path + var splitPath: Path? + var splitNode: Node? + + for i in stride(from: path.path.count - 1, through: 0, by: -1) { + let parentPath = Path(path: Array(path.path.prefix(i))) + if let parent = root.node(at: parentPath), case .split(let split) = parent { + if split.direction == targetSplitDirection { + splitPath = parentPath + splitNode = parent + break + } + } + } + + guard let splitPath = splitPath, + let splitNode = splitNode, + case .split(let split) = splitNode else { + throw SplitError.viewNotFound + } + + // Get current spatial representation to calculate pixel dimensions + let spatial = root.spatial(within: bounds.size) + guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { + throw SplitError.viewNotFound + } + + // Calculate the new ratio based on pixel change + let pixelOffset = Double(pixels) + let newRatio: Double + + switch (split.direction, direction) { + case (.horizontal, .left): + // Moving left boundary: decrease left side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) + case (.horizontal, .right): + // Moving right boundary: increase left side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) + case (.vertical, .up): + // Moving top boundary: decrease top side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height))) + case (.vertical, .down): + // Moving bottom boundary: increase top side + newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height))) + default: + // Direction doesn't match split type - shouldn't happen due to earlier logic + throw SplitError.viewNotFound + } + + // Create new split with adjusted ratio + let newSplit = Node.Split( + direction: split.direction, + ratio: newRatio, + left: split.left, + right: split.right + ) + + // Replace the split node with the new one + let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) + return .init(root: newRoot, zoomed: nil) + } + + /// Returns the total bounds of the split hierarchy using NSView bounds. + /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. + /// Also ignores any possible padding between views. + /// - Returns: The total width and height needed to contain all views + func viewBounds() -> CGSize { + guard let root else { return .zero } + return root.viewBounds() + } } // MARK: SplitTree.Node @@ -277,6 +379,27 @@ extension SplitTree.Node { return search(self) ? Path(path: components) : nil } + + /// Returns the node at the given path from this node as root. + func node(at path: Path) -> Node? { + if path.isEmpty { + return self + } + + guard case .split(let split) = self else { + return nil + } + + let component = path.path[0] + let remainingPath = Path(path: Array(path.path.dropFirst())) + + switch component { + case .left: + return split.left.node(at: remainingPath) + case .right: + return split.right.node(at: remainingPath) + } + } /// Inserts a new view into the split tree by creating a split at the location of an existing view. /// @@ -541,6 +664,36 @@ extension SplitTree.Node { split.right.calculateViewBounds(in: rightBounds) } } + + /// Returns the total bounds of this subtree using NSView bounds. + /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. + /// - Returns: The total width and height needed to contain all views in this subtree + func viewBounds() -> CGSize { + switch self { + case .leaf(let view): + return view.bounds.size + + case .split(let split): + let leftBounds = split.left.viewBounds() + let rightBounds = split.right.viewBounds() + + switch split.direction { + case .horizontal: + // Horizontal split: width is sum, height is max + return CGSize( + width: leftBounds.width + rightBounds.width, + height: Swift.max(leftBounds.height, rightBounds.height) + ) + + case .vertical: + // Vertical split: height is sum, width is max + return CGSize( + width: Swift.max(leftBounds.width, rightBounds.width), + height: leftBounds.height + rightBounds.height + ) + } + } + } } // MARK: SplitTree.Node Spatial @@ -575,16 +728,25 @@ extension SplitTree.Node { /// // - Node bounds based on actual split ratios /// ``` /// + /// - Parameter bounds: Optional size constraints for the spatial representation. If nil, uses artificial dimensions based + /// on grid layout /// - Returns: A `Spatial` struct containing all slots with their calculated bounds - func spatial() -> SplitTree.Spatial { - // First, calculate the total dimensions needed - let dimensions = dimensions() + func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial { + // If we're not given bounds, we use artificial dimensions based on + // the total width/height in columns/rows. + let width: Double + let height: Double + if let bounds { + width = bounds.width + height = bounds.height + } else { + let (w, h) = self.dimensions() + width = Double(w) + height = Double(h) + } // Calculate slots with relative bounds - let slots = spatialSlots( - in: CGRect(x: 0, y: 0, width: Double(dimensions.width), height: Double(dimensions.height)) - ) - + let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height)) return SplitTree.Spatial(slots: slots) } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ba57fbf70..5e2777195 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -151,6 +151,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidToggleSplitZoom(_:)), name: Ghostty.Notification.didToggleSplitZoom, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidResizeSplit(_:)), + name: Ghostty.Notification.didResizeSplit, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -480,6 +485,38 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: target) } } + + @objc private func ghosttyDidResizeSplit(_ notification: Notification) { + // The target must be within our tree + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // Extract direction and amount from notification + guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } + guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } + + guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } + guard let amount = amountAny as? UInt16 else { return } + + // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction + let spatialDirection: SplitTree.Spatial.Direction + switch direction { + case .up: spatialDirection = .up + case .down: spatialDirection = .down + case .left: spatialDirection = .left + case .right: spatialDirection = .right + } + + // Use viewBounds for the spatial calculation bounds + let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds()) + + // Perform the resize using the new SplitTree resize method + do { + surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds) + } catch { + Ghostty.logger.warning("failed to resize split: \(error)") + } + } // MARK: Local Events From 9474092f77164d5e85be395bd1b614a257923399 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 13:20:14 -0700 Subject: [PATCH 158/245] macos: remove the old split implementation --- macos/Ghostty.xcodeproj/project.pbxproj | 8 - macos/Sources/Ghostty/Ghostty.SplitNode.swift | 481 ------------------ .../Ghostty/Ghostty.TerminalSplit.swift | 468 ----------------- macos/Sources/Ghostty/SurfaceView.swift | 54 ++ 4 files changed, 54 insertions(+), 957 deletions(-) delete mode 100644 macos/Sources/Ghostty/Ghostty.SplitNode.swift delete mode 100644 macos/Sources/Ghostty/Ghostty.TerminalSplit.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 8c73d55c5..bb9e860f3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -70,8 +70,6 @@ A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; - A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; }; - A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; }; A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; }; @@ -178,8 +176,6 @@ A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; - A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = ""; }; - A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = ""; }; A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = ""; }; @@ -409,8 +405,6 @@ A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, - A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, @@ -690,7 +684,6 @@ buildActionMask = 2147483647; files = ( A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */, - A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, @@ -737,7 +730,6 @@ A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, - A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift deleted file mode 100644 index ff60e7c56..000000000 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ /dev/null @@ -1,481 +0,0 @@ -import SwiftUI -import Combine -import GhosttyKit - -extension Ghostty { - /// This enum represents the possible states that a node in the split tree can be in. It is either: - /// - /// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single - /// terminal surface to render. - /// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a - /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These - /// values can further be split infinitely. - /// - enum SplitNode: Equatable, Hashable, Codable { - case leaf(Leaf) - case split(Container) - - /// The parent of this node. - var parent: Container? { - get { - switch (self) { - case .leaf(let leaf): - return leaf.parent - - case .split(let container): - return container.parent - } - } - - set { - switch (self) { - case .leaf(let leaf): - leaf.parent = newValue - - case .split(let container): - container.parent = newValue - } - } - } - - /// Returns true if the tree is split. - var isSplit: Bool { - return if case .leaf = self { - false - } else { - true - } - } - - func topLeft() -> SurfaceView { - switch (self) { - case .leaf(let leaf): - return leaf.surface - - case .split(let container): - return container.topLeft.topLeft() - } - } - - /// 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 = .up) -> SurfaceView { - let container: Container - switch (self) { - case .leaf(let leaf): - // noSplit is easy because there is only one thing to focus - return leaf.surface - - case .split(let c): - container = c - } - - let node: SplitNode - switch (direction) { - case .previous, .up, .left: - node = container.bottomRight - - case .next, .down, .right: - node = container.topLeft - } - - return node.preferredFocus(direction) - } - - /// When direction is either next or previous, return the first or last - /// leaf. This can be used when the focus needs to move to a leaf even - /// after hitting the bottom-right-most or top-left-most surface. - /// When the direction is not next or previous (such as top, bottom, - /// left, right), it will be ignored and no leaf will be returned. - func firstOrLast(_ direction: SplitFocusDirection) -> Leaf? { - // If there is no parent, simply ignore. - guard let root = self.parent?.rootContainer() else { return nil } - - switch (direction) { - case .next: - return root.firstLeaf() - case .previous: - return root.lastLeaf() - default: - return nil - } - } - - /// Returns true if any surface in the split stack requires quit confirmation. - func needsConfirmQuit() -> Bool { - switch (self) { - case .leaf(let leaf): - return leaf.surface.needsConfirmQuit - - case .split(let container): - return container.topLeft.needsConfirmQuit() || - container.bottomRight.needsConfirmQuit() - } - } - - /// Returns true if the split tree contains the given view. - func contains(view: SurfaceView) -> Bool { - return leaf(for: view) != nil - } - - /// Find a surface view by UUID. - func findUUID(uuid: UUID) -> SurfaceView? { - switch (self) { - case .leaf(let leaf): - if (leaf.surface.uuid == uuid) { - return leaf.surface - } - - return nil - - case .split(let container): - return container.topLeft.findUUID(uuid: uuid) ?? - container.bottomRight.findUUID(uuid: uuid) - } - } - - /// Returns true if the surface borders the top. Assumes the view is in the tree. - func doesBorderTop(view: SurfaceView) -> Bool { - switch (self) { - case .leaf(let leaf): - return leaf.surface == view - - case .split(let container): - switch (container.direction) { - case .vertical: - return container.topLeft.doesBorderTop(view: view) - - case .horizontal: - return container.topLeft.doesBorderTop(view: view) || - container.bottomRight.doesBorderTop(view: view) - } - } - } - - /// Return the node for the given view if its in the tree. - func leaf(for view: SurfaceView) -> Leaf? { - switch (self) { - case .leaf(let leaf): - if leaf.surface == view { - return leaf - } else { - return nil - } - - case .split(let container): - return container.topLeft.leaf(for: view) ?? - container.bottomRight.leaf(for: view) - } - } - - // MARK: - Sequence - - func makeIterator() -> IndexingIterator<[Leaf]> { - return leaves().makeIterator() - } - - /// Return all the leaves in this split node. This isn't very efficient but our split trees are never super - /// deep so its not an issue. - private func leaves() -> [Leaf] { - switch (self) { - case .leaf(let leaf): - return [leaf] - - case .split(let container): - return container.topLeft.leaves() + container.bottomRight.leaves() - } - } - - // MARK: - Equatable - - static func == (lhs: SplitNode, rhs: SplitNode) -> Bool { - switch (lhs, rhs) { - case (.leaf(let lhs_v), .leaf(let rhs_v)): - return lhs_v === rhs_v - case (.split(let lhs_v), .split(let rhs_v)): - return lhs_v === rhs_v - default: - return false - } - } - - class Leaf: ObservableObject, Equatable, Hashable, Codable { - let app: ghostty_app_t - @Published var surface: SurfaceView - - weak var parent: SplitNode.Container? - - /// Initialize a new leaf which creates a new terminal surface. - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { - self.app = app - self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid) - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(surface) - } - - // MARK: - Equatable - - static func == (lhs: Leaf, rhs: Leaf) -> Bool { - return lhs.app == rhs.app && lhs.surface === rhs.surface - } - - // MARK: - Codable - - enum CodingKeys: String, CodingKey { - case pwd - case uuid - } - - required convenience init(from decoder: Decoder) throws { - // Decoding uses the global Ghostty app - guard let del = NSApplication.shared.delegate, - let appDel = del as? AppDelegate, - let app = appDel.ghostty.app else { - throw TerminalRestoreError.delegateInvalid - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid)) - var config = SurfaceConfiguration() - config.workingDirectory = try container.decode(String?.self, forKey: .pwd) - - self.init(app, baseConfig: config, uuid: uuid) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(surface.pwd, forKey: .pwd) - try container.encode(surface.uuid.uuidString, forKey: .uuid) - } - } - - class Container: ObservableObject, Equatable, Hashable, Codable { - let app: ghostty_app_t - let direction: SplitViewDirection - - @Published var topLeft: SplitNode - @Published var bottomRight: SplitNode - @Published var split: CGFloat = 0.5 - - var resizeEvent: PassthroughSubject = .init() - - weak var parent: SplitNode.Container? - - /// A container is always initialized from some prior leaf because a split has to originate - /// from a non-split value. When initializing, we inherit the leaf's surface and then - /// initialize a new surface for the new pane. - init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) { - self.app = from.app - self.direction = direction - self.parent = from.parent - - // Initially, both topLeft and bottomRight are in the "nosplit" - // state since this is a new split. - self.topLeft = .leaf(from) - - let bottomRight: Leaf = .init(app, baseConfig: baseConfig) - self.bottomRight = .leaf(bottomRight) - - from.parent = self - bottomRight.parent = self - } - - // Move the top left node to the bottom right and vice versa, - // preserving the size. - func swap() { - let topLeft: SplitNode = self.topLeft - self.topLeft = bottomRight - self.bottomRight = topLeft - self.split = 1 - self.split - } - - /// Resize the split by moving the split divider in the given - /// direction by the given amount. If this container is not split - /// in the given direction, navigate up the tree until we find a - /// container that is - func resize(direction: SplitResizeDirection, amount: UInt16) { - // We send a resize event to our publisher which will be - // received by the SplitView. - switch (self.direction) { - case .horizontal: - switch (direction) { - case .left: resizeEvent.send(-Double(amount)) - case .right: resizeEvent.send(Double(amount)) - default: parent?.resize(direction: direction, amount: amount) - } - case .vertical: - switch (direction) { - case .up: resizeEvent.send(-Double(amount)) - case .down: resizeEvent.send(Double(amount)) - default: parent?.resize(direction: direction, amount: amount) - } - } - } - - /// Equalize the splits in this container. Each split is equalized - /// based on its weight, i.e. the number of leaves it contains. - /// This function returns the weight of this container. - func equalize() -> UInt { - let topLeftWeight: UInt - switch (topLeft) { - case .leaf: - topLeftWeight = 1 - case .split(let c): - topLeftWeight = c.equalize() - } - - let bottomRightWeight: UInt - switch (bottomRight) { - case .leaf: - bottomRightWeight = 1 - case .split(let c): - bottomRightWeight = c.equalize() - } - - let weight = topLeftWeight + bottomRightWeight - split = Double(topLeftWeight) / Double(weight) - return weight - } - - /// Returns the top most parent, or this container. Because this - /// would fall back to use to self, the return value is guaranteed. - func rootContainer() -> Container { - guard let parent = self.parent else { return self } - return parent.rootContainer() - } - - /// Returns the first leaf from the given container. This is most - /// useful for root container, so that we can find the top-left-most - /// leaf. - func firstLeaf() -> Leaf { - switch (self.topLeft) { - case .leaf(let leaf): - return leaf - case .split(let s): - return s.firstLeaf() - } - } - - /// Returns the last leaf from the given container. This is most - /// useful for root container, so that we can find the bottom-right- - /// most leaf. - func lastLeaf() -> Leaf { - switch (self.bottomRight) { - case .leaf(let leaf): - return leaf - case .split(let s): - return s.lastLeaf() - } - } - - // MARK: - Hashable - - func hash(into hasher: inout Hasher) { - hasher.combine(app) - hasher.combine(direction) - hasher.combine(topLeft) - hasher.combine(bottomRight) - } - - // MARK: - Equatable - - static func == (lhs: Container, rhs: Container) -> Bool { - return lhs.app == rhs.app && - lhs.direction == rhs.direction && - lhs.topLeft == rhs.topLeft && - lhs.bottomRight == rhs.bottomRight - } - - // MARK: - Codable - - enum CodingKeys: String, CodingKey { - case direction - case split - case topLeft - case bottomRight - } - - required init(from decoder: Decoder) throws { - // Decoding uses the global Ghostty app - guard let del = NSApplication.shared.delegate, - let appDel = del as? AppDelegate, - let app = appDel.ghostty.app else { - throw TerminalRestoreError.delegateInvalid - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - self.app = app - self.direction = try container.decode(SplitViewDirection.self, forKey: .direction) - self.split = try container.decode(CGFloat.self, forKey: .split) - self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft) - self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight) - - // Fix up the parent references - self.topLeft.parent = self - self.bottomRight.parent = self - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(direction, forKey: .direction) - try container.encode(split, forKey: .split) - try container.encode(topLeft, forKey: .topLeft) - try container.encode(bottomRight, forKey: .bottomRight) - } - } - - /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right - /// nodes. This is purposely weak so we don't have to worry about memory management - /// with this (although, it should always be correct). - struct Neighbors { - var left: SplitNode? - var right: 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 up (same for next). - var previous: SplitNode? - var next: SplitNode? - - /// No neighbors, used by the root node. - static let empty: Self = .init() - - /// Get the node for a given direction. - func get(direction: SplitFocusDirection) -> SplitNode? { - let map: [SplitFocusDirection : KeyPath] = [ - .previous: \.previous, - .next: \.next, - .up: \.up, - .down: \.down, - .left: \.left, - .right: \.right, - ] - - guard let path = map[direction] else { return nil } - return self[keyPath: path] - } - - /// Update multiple keys and return a new copy. - func update(_ attrs: [WritableKeyPath: SplitNode?]) -> Self { - var clone = self - attrs.forEach { (key, value) in - clone[keyPath: key] = value - } - return clone - } - - /// True if there are no neighbors - func isEmpty() -> Bool { - return self.previous == nil && self.next == nil - } - } - } -} diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift deleted file mode 100644 index ccb7cca38..000000000 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ /dev/null @@ -1,468 +0,0 @@ -import SwiftUI -import GhosttyKit - -extension Ghostty { - /// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the - /// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the - /// split direction by splitting the terminal. - /// - /// This also allows one split to be "zoomed" at any time. - struct TerminalSplit: View { - /// The current state of the root node. This can be set to nil when all surfaces are closed. - @Binding var node: SplitNode? - - /// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface - /// becomes "full screen" on the split tree. - @State private var zoomedSurface: SurfaceView? = nil - - var body: some View { - ZStack { - TerminalSplitRoot( - node: $node, - zoomedSurface: $zoomedSurface - ) - - // If we have a zoomed surface, we overlay that on top of our split - // root. Our split root will become clear when there is a zoomed - // surface. We need to keep the split root around so that we don't - // lose all of the surface state so this must be a ZStack. - if let surfaceView = zoomedSurface { - InspectableSurface(surfaceView: surfaceView) - } - } - } - } - - /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever - /// one of these in a split tree. - private struct TerminalSplitRoot: View { - /// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close. - @Binding var node: SplitNode? - - /// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own - /// is in the zoomed state, we clear our body since we expect a zoomed split to overlay - /// this one. - @Binding var zoomedSurface: SurfaceView? - - var body: some View { - let center = NotificationCenter.default - let pubZoom = center.publisher(for: Notification.didToggleSplitZoom) - - // If we're zoomed, we don't render anything, we are transparent. This - // ensures that the View stays around so we don't lose our state, but - // also that the zoomed view on top can see through if background transparency - // is enabled. - if (zoomedSurface == nil) { - ZStack { - switch (node) { - case nil: - Color(.clear) - - case .leaf(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: .empty, - node: $node - ) - - case .split(let container): - TerminalSplitContainer( - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - } - } - } else { - // On these events we want to reset the split state and call it. - let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!) - - ZStack {} - .onReceive(pubZoom) { onZoomReset(notification: $0) } - .onReceive(pubSplit) { onZoomReset(notification: $0) } - .onReceive(pubClose) { onZoomReset(notification: $0) } - .onReceive(pubFocus) { onZoomReset(notification: $0) } - } - } - - func onZoom(notification: SwiftUI.Notification) { - // Our node must be split to receive zooms. You can't zoom an unsplit terminal. - if case .leaf = node { - preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") - } - - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard node?.contains(view: surfaceView) ?? false else { return } - - // We are in the zoomed state. - zoomedSurface = surfaceView - - // See onZoomReset, same logic. - DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) } - } - - func onZoomReset(notification: SwiftUI.Notification) { - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard zoomedSurface == surfaceView else { return } - - // We are now unzoomed - zoomedSurface = nil - - // We need to stay focused on this view, but the view is going to change - // superviews. We need to do this async so it happens on the next event loop - // tick. - DispatchQueue.main.async { - Ghostty.moveFocus(to: surfaceView) - - // If the notification is not a toggle zoom notification, we want to re-publish - // it after a short delay so that the split tree has a chance to re-establish - // so the proper view gets this notification. - if (notification.name != Notification.didToggleSplitZoom) { - // We have to wait ANOTHER tick since we just established. - DispatchQueue.main.async { - NotificationCenter.default.post(notification) - } - } - } - } - } - - /// A noSplit leaf node of a split tree. - private struct TerminalSplitLeaf: View { - /// The leaf to draw the surface for. - let leaf: SplitNode.Leaf - - /// The neighbors, used for navigation. - let neighbors: SplitNode.Neighbors - - /// The SplitNode that the leaf belongs to. This will be set to nil when leaf is closed. - @Binding var node: SplitNode? - - var body: some View { - let center = NotificationCenter.default - let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface) - let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface) - - InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) - .onReceive(pub) { onNewSplit(notification: $0) } - .onReceive(pubClose) { onClose(notification: $0) } - .onReceive(pubFocus) { onMoveFocus(notification: $0) } - .onReceive(pubResize) { onResize(notification: $0) } - } - - private func onClose(notification: SwiftUI.Notification) { - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - - // If the child process is not alive, then we exit immediately - guard processAlive else { - node = nil - return - } - - // If we don't have a window to attach our modal to, we also exit immediately. - // This should NOT happen. - guard let window = leaf.surface.window else { - node = nil - return - } - - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog - // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that - // confirmationDialog allows the user to Cmd-W close the alert, but when doing - // so SwiftUI does not update any of the bindings to note that window is no longer - // being shown, and provides no callback to detect this. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - switch (response) { - case .alertFirstButtonReturn: - alert.window.orderOut(nil) - node = nil - - default: - break - } - }) - } - - private func onNewSplit(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? SurfaceConfiguration - - // Determine our desired direction - guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_action_split_direction_e else { return } - let splitDirection: SplitViewDirection - let swap: Bool - switch (direction) { - case GHOSTTY_SPLIT_DIRECTION_RIGHT: - splitDirection = .horizontal - swap = false - case GHOSTTY_SPLIT_DIRECTION_LEFT: - splitDirection = .horizontal - swap = true - case GHOSTTY_SPLIT_DIRECTION_DOWN: - splitDirection = .vertical - swap = false - case GHOSTTY_SPLIT_DIRECTION_UP: - splitDirection = .vertical - swap = true - - default: - return - } - - // Setup our new container since we are now split - let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config) - - // Change the parent node. This will trigger the parent to relayout our views. - node = .split(container) - - // See moveFocus comment, we have to run this whenever split changes. - Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus()) - - // If we are swapping, swap now. We do this after our focus event - // so that focus is in the right place. - if swap { - container.swap() - } - } - - /// This handles the event to move the split focus (i.e. previous/next) from a keyboard event. - private func onMoveFocus(notification: SwiftUI.Notification) { - // Determine our desired direction - guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } - guard let direction = directionAny as? SplitFocusDirection else { return } - - // Find the next surface to move to. In most cases this should be - // finding the neighbor in provided direction, and focus it. When - // the neighbor cannot be found based on next or previous direction, - // this would instead search for first or last leaf and focus it - // instead, giving the wrap around effect. - // When other directions are provided, this can be nil, and early - // returned. - guard let nextSurface = neighbors.get(direction: direction)?.preferredFocus(direction) - ?? node?.firstOrLast(direction)?.surface else { return } - - Ghostty.moveFocus( - to: nextSurface - ) - } - - /// Handle a resize event. - private func onResize(notification: SwiftUI.Notification) { - // If this leaf is not part of a split then there is nothing to do - guard let parent = leaf.parent else { return } - - guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } - guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } - - guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } - guard let amount = amountAny as? UInt16 else { return } - - parent.resize(direction: direction, amount: amount) - } - } - - /// This represents a split view that is in the horizontal or vertical split state. - private struct TerminalSplitContainer: View { - @EnvironmentObject var ghostty: Ghostty.App - - let neighbors: SplitNode.Neighbors - @Binding var node: SplitNode? - @ObservedObject var container: SplitNode.Container - - var body: some View { - SplitView( - container.direction, - $container.split, - dividerColor: ghostty.config.splitDividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: container.resizeEvent, - left: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.down - - TerminalSplitNested( - node: closeableTopLeft(), - neighbors: neighbors.update([ - neighborKey: container.bottomRight, - \.next: container.bottomRight, - ]) - ) - }, right: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.up - - TerminalSplitNested( - node: closeableBottomRight(), - neighbors: neighbors.update([ - neighborKey: container.topLeft, - \.previous: container.topLeft, - ]) - ) - }) - } - - private func closeableTopLeft() -> Binding { - return .init(get: { - container.topLeft - }, set: { newValue in - if let newValue { - container.topLeft = newValue - return - } - - // Closing - node = container.bottomRight - - switch (node) { - case .leaf(let l): - l.parent = container.parent - case .split(let c): - c.parent = container.parent - case .none: - break - } - - DispatchQueue.main.async { - Ghostty.moveFocus( - to: container.bottomRight.preferredFocus(), - from: container.topLeft.preferredFocus() - ) - } - }) - } - - private func closeableBottomRight() -> Binding { - return .init(get: { - container.bottomRight - }, set: { newValue in - if let newValue { - container.bottomRight = newValue - return - } - - // Closing - node = container.topLeft - - switch (node) { - case .leaf(let l): - l.parent = container.parent - case .split(let c): - c.parent = container.parent - case .none: - break - } - - DispatchQueue.main.async { - Ghostty.moveFocus( - to: container.topLeft.preferredFocus(), - from: container.bottomRight.preferredFocus() - ) - } - }) - } - } - - - /// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but - /// requires there be a binding to the parent node. - private struct TerminalSplitNested: View { - @Binding var node: SplitNode? - let neighbors: SplitNode.Neighbors - - var body: some View { - Group { - switch (node) { - case nil: - Color(.clear) - - case .leaf(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: neighbors, - node: $node - ) - - case .split(let container): - TerminalSplitContainer( - neighbors: neighbors, - node: $node, - container: container - ) - } - } - .id(node) - } - } - - /// When changing the split state, or going full screen (native or non), the terminal view - /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't - /// figure it out so we're going to do this hacky thing to bring focus back to the terminal - /// that should have it. - static func moveFocus( - to: SurfaceView, - from: SurfaceView? = nil, - delay: TimeInterval? = nil - ) { - // The whole delay machinery is a bit of a hack to work around a - // situation where the window is destroyed and the surface view - // will never be attached to a window. Realistically, we should - // handle this upstream but we also don't want this function to be - // a source of infinite loops. - - // Our max delay before we give up - let maxDelay: TimeInterval = 0.5 - guard (delay ?? 0) < maxDelay else { return } - - // We start at a 50 millisecond delay and do a doubling backoff - let nextDelay: TimeInterval = if let delay { - delay * 2 - } else { - // 100 milliseconds - 0.05 - } - - let work: DispatchWorkItem = .init { - // If the callback runs before the surface is attached to a view - // then the window will be nil. We just reschedule in that case. - guard let window = to.window else { - moveFocus(to: to, from: from, delay: nextDelay) - return - } - - // If we had a previously focused node and its not where we're sending - // focus, make sure that we explicitly tell it to lose focus. In theory - // we should NOT have to do this but the focus callback isn't getting - // called for some reason. - if let from = from { - _ = from.resignFirstResponder() - } - - window.makeFirstResponder(to) - } - - let queue = DispatchQueue.main - if let delay { - queue.asyncAfter(deadline: .now() + delay, execute: work) - } else { - queue.async(execute: work) - } - } -} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 513e5af46..a282c7a88 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -460,6 +460,60 @@ extension Ghostty { return config } } + + /// When changing the split state, or going full screen (native or non), the terminal view + /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't + /// figure it out so we're going to do this hacky thing to bring focus back to the terminal + /// that should have it. + static func moveFocus( + to: SurfaceView, + from: SurfaceView? = nil, + delay: TimeInterval? = nil + ) { + // The whole delay machinery is a bit of a hack to work around a + // situation where the window is destroyed and the surface view + // will never be attached to a window. Realistically, we should + // handle this upstream but we also don't want this function to be + // a source of infinite loops. + + // Our max delay before we give up + let maxDelay: TimeInterval = 0.5 + guard (delay ?? 0) < maxDelay else { return } + + // We start at a 50 millisecond delay and do a doubling backoff + let nextDelay: TimeInterval = if let delay { + delay * 2 + } else { + // 100 milliseconds + 0.05 + } + + let work: DispatchWorkItem = .init { + // If the callback runs before the surface is attached to a view + // then the window will be nil. We just reschedule in that case. + guard let window = to.window else { + moveFocus(to: to, from: from, delay: nextDelay) + return + } + + // If we had a previously focused node and its not where we're sending + // focus, make sure that we explicitly tell it to lose focus. In theory + // we should NOT have to do this but the focus callback isn't getting + // called for some reason. + if let from = from { + _ = from.resignFirstResponder() + } + + window.makeFirstResponder(to) + } + + let queue = DispatchQueue.main + if let delay { + queue.asyncAfter(deadline: .now() + delay, execute: work) + } else { + queue.async(execute: work) + } + } } // MARK: Surface Environment Keys From 01fa87f2aba0bfaa122b018ef08ca6baa8740d7c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 13:37:42 -0700 Subject: [PATCH 159/245] macos: fix iOS builds --- macos/Sources/Ghostty/SurfaceView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index a282c7a88..18a8d2f1c 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -461,6 +461,7 @@ extension Ghostty { } } + #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't /// figure it out so we're going to do this hacky thing to bring focus back to the terminal @@ -514,6 +515,7 @@ extension Ghostty { queue.async(execute: work) } } + #endif } // MARK: Surface Environment Keys From f8e3539b7db2d6bfe60bb744e67891ea22cc02dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 19:43:47 -0700 Subject: [PATCH 160/245] macos: remove the unused resizeEvent code from SplitView --- .../Splits/TerminalSplitTreeView.swift | 1 - .../Sources/Helpers/SplitView/SplitView.swift | 44 +------------------ 2 files changed, 2 insertions(+), 43 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 4a41afc42..b219e0b31 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -43,7 +43,6 @@ struct TerminalSplitSubtreeView: View { }), dividerColor: ghostty.config.splitDividerColor, resizeIncrements: .init(width: 1, height: 1), - resizePublisher: .init(), left: { TerminalSplitSubtreeView(node: split.left, onResize: onResize) }, diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift index 8ac2bc33f..9747ac99f 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.swift @@ -1,5 +1,4 @@ import SwiftUI -import Combine /// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing. /// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom". @@ -13,12 +12,10 @@ struct SplitView: View { /// Divider color let dividerColor: Color - /// If set, the split view supports programmatic resizing via events sent via the publisher. /// Minimum increment (in points) that this split can be resized by, in /// each direction. Both `height` and `width` should be whole numbers /// greater than or equal to 1.0 let resizeIncrements: NSSize - let resizePublisher: PassthroughSubject /// The left and right views to render. let left: L @@ -55,37 +52,15 @@ struct SplitView: View { .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } - .onReceive(resizePublisher) { value in - resize(for: geo.size, amount: value) - } } } - /// Initialize a split view. This view isn't programmatically resizable; it can only be resized - /// by manually dragging the divider. - init(_ direction: SplitViewDirection, - _ split: Binding, - dividerColor: Color, - @ViewBuilder left: (() -> L), - @ViewBuilder right: (() -> R)) { - self.init( - direction, - split, - dividerColor: dividerColor, - resizeIncrements: .init(width: 1, height: 1), - resizePublisher: .init(), - left: left, - right: right - ) - } - - /// Initialize a split view that supports programmatic resizing. + /// Initialize a split view that can be resized by manually dragging the divider. init( _ direction: SplitViewDirection, _ split: Binding, dividerColor: Color, - resizeIncrements: NSSize, - resizePublisher: PassthroughSubject, + resizeIncrements: NSSize = .init(width: 1, height: 1), @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R) ) { @@ -93,25 +68,10 @@ struct SplitView: View { self._split = split self.dividerColor = dividerColor self.resizeIncrements = resizeIncrements - self.resizePublisher = resizePublisher self.left = left() self.right = right() } - private func resize(for size: CGSize, amount: Double) { - let dim: CGFloat - switch (direction) { - case .horizontal: - dim = size.width - case .vertical: - dim = size.height - } - - let pos = split * dim - let new = min(max(minSize, pos + amount), dim - minSize) - split = new / dim - } - private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { return DragGesture() .onChanged { gesture in From 1966dfdef7eb8a8d08bc486a5419f409260715fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 4 Jun 2025 19:44:30 -0700 Subject: [PATCH 161/245] macos: moving some files around --- macos/Ghostty.xcodeproj/project.pbxproj | 34 +++++++------------ .../Splits}/SplitView.Divider.swift | 0 .../Splits}/SplitView.swift | 0 .../EventModifiers+Extension.swift | 0 .../KeyboardShortcut+Extension.swift | 0 .../NSAppearance+Extension.swift | 0 .../NSApplication+Extension.swift | 0 .../{ => Extensions}/NSImage+Extension.swift | 0 .../NSPasteboard+Extension.swift | 0 .../{ => Extensions}/NSScreen+Extension.swift | 0 .../{ => Extensions}/NSWindow+Extension.swift | 0 .../{ => Extensions}/OSColor+Extension.swift | 0 .../{ => Extensions}/String+Extension.swift | 0 .../{ => Extensions}/View+Extension.swift | 0 14 files changed, 13 insertions(+), 21 deletions(-) rename macos/Sources/{Helpers/SplitView => Features/Splits}/SplitView.Divider.swift (100%) rename macos/Sources/{Helpers/SplitView => Features/Splits}/SplitView.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/EventModifiers+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/KeyboardShortcut+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/NSAppearance+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/NSApplication+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/NSImage+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/NSPasteboard+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/NSScreen+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/NSWindow+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/OSColor+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/String+Extension.swift (100%) rename macos/Sources/Helpers/{ => Extensions}/View+Extension.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index bb9e860f3..62cb079bf 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -301,23 +301,11 @@ A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, - A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, - A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, - C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, - A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, - A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, - A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, - A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, - AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, - A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, - A5985CD62C320C4500C57AD3 /* String+Extension.swift */, - A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, - A5CEAFDA29B8005900646FDA /* SplitView */, ); path = Helpers; sourceTree = ""; @@ -434,6 +422,8 @@ children = ( A586365E2DEE6C2100E04A10 /* SplitTree.swift */, A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */, + A5CEAFDB29B8009000646FDA /* SplitView.swift */, + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, ); path = Splits; sourceTree = ""; @@ -442,7 +432,18 @@ isa = PBXGroup; children = ( A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, + A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, + C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, + A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, + A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, + A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, + AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, + A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A5CC36142C9CDA03004D6760 /* View+Extension.swift */, ); path = Extensions; sourceTree = ""; @@ -534,15 +535,6 @@ path = "Global Keybinds"; sourceTree = ""; }; - A5CEAFDA29B8005900646FDA /* SplitView */ = { - isa = PBXGroup; - children = ( - A5CEAFDB29B8009000646FDA /* SplitView.swift */, - A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, - ); - path = SplitView; - sourceTree = ""; - }; A5D495A3299BECBA00DD1313 /* Frameworks */ = { isa = PBXGroup; children = ( diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Features/Splits/SplitView.Divider.swift similarity index 100% rename from macos/Sources/Helpers/SplitView/SplitView.Divider.swift rename to macos/Sources/Features/Splits/SplitView.Divider.swift diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift similarity index 100% rename from macos/Sources/Helpers/SplitView/SplitView.swift rename to macos/Sources/Features/Splits/SplitView.swift diff --git a/macos/Sources/Helpers/EventModifiers+Extension.swift b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift similarity index 100% rename from macos/Sources/Helpers/EventModifiers+Extension.swift rename to macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift diff --git a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift similarity index 100% rename from macos/Sources/Helpers/KeyboardShortcut+Extension.swift rename to macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift diff --git a/macos/Sources/Helpers/NSAppearance+Extension.swift b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSAppearance+Extension.swift rename to macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSApplication+Extension.swift rename to macos/Sources/Helpers/Extensions/NSApplication+Extension.swift diff --git a/macos/Sources/Helpers/NSImage+Extension.swift b/macos/Sources/Helpers/Extensions/NSImage+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSImage+Extension.swift rename to macos/Sources/Helpers/Extensions/NSImage+Extension.swift diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSPasteboard+Extension.swift rename to macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift diff --git a/macos/Sources/Helpers/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSScreen+Extension.swift rename to macos/Sources/Helpers/Extensions/NSScreen+Extension.swift diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift similarity index 100% rename from macos/Sources/Helpers/NSWindow+Extension.swift rename to macos/Sources/Helpers/Extensions/NSWindow+Extension.swift diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift similarity index 100% rename from macos/Sources/Helpers/OSColor+Extension.swift rename to macos/Sources/Helpers/Extensions/OSColor+Extension.swift diff --git a/macos/Sources/Helpers/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift similarity index 100% rename from macos/Sources/Helpers/String+Extension.swift rename to macos/Sources/Helpers/Extensions/String+Extension.swift diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/Extensions/View+Extension.swift similarity index 100% rename from macos/Sources/Helpers/View+Extension.swift rename to macos/Sources/Helpers/Extensions/View+Extension.swift From c40ac6b785cc7482d7556943b7d26f7fd4897617 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 07:09:46 -0700 Subject: [PATCH 162/245] input: add focus split directional commands to command palette --- .../Terminal/BaseTerminalController.swift | 6 ++-- src/input/command.zig | 34 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5e2777195..ea849bb4a 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -447,7 +447,7 @@ class BaseTerminalController: NSWindowController, case .left: focusDirection = .spatial(.left) case .right: focusDirection = .spatial(.right) } - + // Find the node for the target surface guard let targetNode = surfaceTree.root?.node(view: target) else { return } @@ -462,7 +462,9 @@ class BaseTerminalController: NSWindowController, } // Move focus to the next surface - Ghostty.moveFocus(to: nextSurface, from: target) + DispatchQueue.main.async { + Ghostty.moveFocus(to: nextSurface, from: target) + } } @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { diff --git a/src/input/command.zig b/src/input/command.zig index 1ce6aa7cb..4a918cff3 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -274,6 +274,39 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_split => comptime &.{ + .{ + .action = .{ .goto_split = .previous }, + .title = "Focus Split: Previous", + .description = "Focus the previous split, if any.", + }, + .{ + .action = .{ .goto_split = .next }, + .title = "Focus Split: Next", + .description = "Focus the next split, if any.", + }, + .{ + .action = .{ .goto_split = .left }, + .title = "Focus Split: Left", + .description = "Focus the split to the left, if it exists.", + }, + .{ + .action = .{ .goto_split = .right }, + .title = "Focus Split: Right", + .description = "Focus the split to the right, if it exists.", + }, + .{ + .action = .{ .goto_split = .up }, + .title = "Focus Split: Up", + .description = "Focus the split above, if it exists.", + }, + .{ + .action = .{ .goto_split = .down }, + .title = "Focus Split: Down", + .description = "Focus the split below, if it exists.", + }, + }, + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", @@ -396,7 +429,6 @@ fn actionCommands(action: Action.Key) []const Command { .jump_to_prompt, .write_scrollback_file, .goto_tab, - .goto_split, .resize_split, .crash, => comptime &.{}, From 9008e21637f504fe606da37fb30d3ebedb50a3d4 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Sun, 1 Jun 2025 12:06:47 -0300 Subject: [PATCH 163/245] fix: exit non-native fullscreen on close --- macos/Sources/Helpers/Fullscreen.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6094bf844..6b10ceb40 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -150,6 +150,26 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { private var savedState: SavedState? + required init?(_ window: NSWindow) { + super.init(window) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowWillCloseNotification), + name: NSWindow.willCloseNotification, + object: window) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func windowWillCloseNotification(_ notification: Notification) { + // When the window closes we need to explicitly exit non-native fullscreen + // otherwise some state like the menu bar can remain hidden. + exit() + } + func enter() { // If we are in fullscreen we don't do it again. guard !isFullscreen else { return } From 045c84acb71e99e384b63ca658621bce891f8841 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 13:41:33 -0700 Subject: [PATCH 164/245] macos: split directional navigation should use distance to leaf Fixes regression from #7523 I messed two things up around spatial navigation in the split tree that this commit fixes: 1. The distance in the spatial tree only used a single dimension that we were navigating. This commit now uses 2D euclidean distance from the top-left corners of nodes. This handles the case where the nodes are directly above or below each other better. 2. The spatial slots include split containers because they are layout elements. But we should only navigate to leaf nodes. This was causing the wrong navigatin to happen in some scenarios. --- macos/Sources/Features/Splits/SplitTree.swift | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index ab4b387a4..cbd440124 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -190,17 +190,22 @@ extension SplitTree { return nil } - // Extract the view from the best candidate node - let bestNode = nodes[0].node - switch bestNode { + // Extract the view from the best candidate node. The best candidate + // node is the closest leaf node. If we have no leaves (impossible?) + // just use the first node. + let bestNode = nodes.first(where: { + if case .leaf = $0.node { return true } else { return false } + }) ?? nodes[0] + switch bestNode.node { case .leaf(let view): return view + case .split: // If the best candidate is a split node, use its the leaf/rightmost // depending on our spatial direction. return switch (spatialDirection) { - case .up, .left: bestNode.leftmostLeaf() - case .down, .right: bestNode.rightmostLeaf() + case .up, .left: bestNode.node.leftmostLeaf() + case .down, .right: bestNode.node.rightmostLeaf() } } } @@ -892,32 +897,47 @@ extension SplitTree.Spatial { /// - **Up**: Slots with bounds above the reference node (Y=0 is top) /// - **Down**: Slots with bounds below the reference node /// - /// Results are sorted by distance from the reference node, with closest slots first. - /// Distance is calculated as the gap between the reference node and the candidate slot - /// in the direction of movement. + /// Results are sorted by 2D euclidean distance from the reference node, with closest slots first. + /// Distance is calculated from the top-left corners of the bounds, prioritizing nodes that are + /// closer in both dimensions. + /// + /// **Important**: The returned array contains both split nodes and leaf nodes. When using this + /// for navigation or focus management, you typically want to filter for leaf nodes first, as they + /// represent the actual views that can receive focus. Split nodes are included in the results + /// because they have bounds and occupy space in the layout, but they are structural elements + /// that cannot themselves be focused. If no leaf nodes are found in the results, you may need + /// to traverse into a split node to find its appropriate leaf child. /// /// - Parameters: /// - direction: The direction to search for slots /// - referenceNode: The node to use as the reference point - /// - Returns: An array of slots in the specified direction, sorted by distance (closest first) + /// - Returns: An array of slots in the specified direction, sorted by 2D distance (closest first) func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] { guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] } + + // Helper function to calculate 2D euclidean distance between top-left corners of two rectangles + func distance(from rect1: CGRect, to rect2: CGRect) -> Double { + // Calculate distance between top-left corners + let dx = rect2.minX - rect1.minX + let dy = rect2.minY - rect1.minY + return sqrt(dx * dx + dy * dy) + } - return switch direction { + let result = switch direction { case .left: // Slots to the left: their right edge is at or left of reference's left edge slots.filter { $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX }.sorted { - (refSlot.bounds.minX - $0.bounds.maxX) < (refSlot.bounds.minX - $1.bounds.maxX) + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .right: // Slots to the right: their left edge is at or right of reference's right edge slots.filter { $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX }.sorted { - ($0.bounds.minX - refSlot.bounds.maxX) < ($1.bounds.minX - refSlot.bounds.maxX) + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } case .up: @@ -925,7 +945,7 @@ extension SplitTree.Spatial { slots.filter { $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY }.sorted { - (refSlot.bounds.minY - $0.bounds.maxY) < (refSlot.bounds.minY - $1.bounds.maxY) + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } case .down: @@ -933,9 +953,11 @@ extension SplitTree.Spatial { slots.filter { $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY }.sorted { - ($0.bounds.minY - refSlot.bounds.maxY) < ($1.bounds.minY - refSlot.bounds.maxY) + distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } } + + return result } /// Returns whether the given node borders the specified side of the spatial bounds. From c2c267439be55304ed07181dd35d5a90a5dd7cde Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 5 Jun 2025 13:14:58 -0700 Subject: [PATCH 165/245] macos: fix hasWindowButtons logic --- .../Features/Terminal/TerminalWindow.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 830a73e86..0b43582f3 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -45,15 +45,14 @@ class TerminalWindow: NSWindow { }, ] + // false if all three traffic lights are missing/hidden, otherwise true private var hasWindowButtons: Bool { get { - if let close = standardWindowButton(.closeButton), - let miniaturize = standardWindowButton(.miniaturizeButton), - let zoom = standardWindowButton(.zoomButton) { - return !(close.isHidden && miniaturize.isHidden && zoom.isHidden) - } else { - return false - } + // if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true + let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true + let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true + let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true + return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden) } } @@ -78,7 +77,7 @@ class TerminalWindow: NSWindow { if titlebarTabs { generateToolbar() } - + level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } From 70f030e3c2f09ad7785846b94c40988dcc97266c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 07:22:37 -0700 Subject: [PATCH 166/245] macos: dismiss notifications on focus, application exit I've only recently been using programs that use user notifications heavily and this commit addresses a number of annoyances I've encountered. 1. Notifications dispatched while the source terminal surface is focused are now only shown for a short time (3 seconds hardcoded) and then automatically dismiss. 2. Notifications are dismissed when the target surface becomes focused from an unfocused state. This dismissal happens immediately (no delay). 3. Notifications are dismissed when the application exits. 4. This fixes a bug where notification callbacks were modifying view state, but the notification center doesn't guarantee that the callback is called on the main thread. We now ensure that the callback is always called on the main thread. --- macos/Sources/App/macOS/AppDelegate.swift | 7 +++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 26 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c6816d50c..54454e6bf 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -316,6 +316,13 @@ class AppDelegate: NSObject, } } + func applicationWillTerminate(_ notification: Notification) { + // We have no notifications we want to persist after death, + // so remove them all now. In the future we may want to be + // more selective and only remove surface-targeted notifications. + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + /// This is called when the application is already open and someone double-clicks the icon /// or clicks the dock icon. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 0aecef6ad..682efa947 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -306,6 +306,14 @@ extension Ghostty { // We unset our bell state if we gained focus bell = false + + // Remove any notifications for this surface once we gain focus. + if !notificationIdentifiers.isEmpty { + UNUserNotificationCenter.current() + .removeDeliveredNotifications( + withIdentifiers: Array(notificationIdentifiers)) + self.notificationIdentifiers = [] + } } } @@ -1388,13 +1396,29 @@ extension Ghostty { trigger: nil ) - UNUserNotificationCenter.current().add(request) { error in + // Note the callback may be executed on a background thread as documented + // so we need @MainActor since we're reading/writing view state. + UNUserNotificationCenter.current().add(request) { @MainActor error in if let error = error { AppDelegate.logger.error("Error scheduling user notification: \(error)") return } + // We need to keep track of this notification so we can remove it + // under certain circumstances self.notificationIdentifiers.insert(uuid) + + // If we're focused then we schedule to remove the notification + // after a few seconds. If we gain focus we automatically remove it + // in focusDidChange. + if (self.focused) { + Task { @MainActor [weak self] in + try await Task.sleep(for: .seconds(3)) + self?.notificationIdentifiers.remove(uuid) + UNUserNotificationCenter.current() + .removeDeliveredNotifications(withIdentifiers: [uuid]) + } + } } } From 5f6a15abef20f510d79314c5107f58ff3636682a Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Fri, 6 Jun 2025 23:46:06 +0800 Subject: [PATCH 167/245] Add bell feature flags for audio, attention, and title actions on macOS Signed-off-by: Aaron Ruan --- macos/Sources/App/macOS/AppDelegate.swift | 10 ++++++---- macos/Sources/Ghostty/Ghostty.Config.swift | 3 +++ macos/Sources/Ghostty/SurfaceView.swift | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c6816d50c..bee5826ed 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -530,11 +530,13 @@ class AppDelegate: NSObject, } @objc private func ghosttyBellDidRing(_ notification: Notification) { - // Bounce the dock icon if we're not focused. - NSApp.requestUserAttention(.informationalRequest) + if (ghostty.config.bellFeatures.contains(.attention)) { + // Bounce the dock icon if we're not focused. + NSApp.requestUserAttention(.informationalRequest) - // Handle setting the dock badge based on permissions - ghosttyUpdateBadgeForBell() + // Handle setting the dock badge based on permissions + ghosttyUpdateBadgeForBell() + } } private func ghosttyUpdateBadgeForBell() { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index cce14ca0f..3acb93c25 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -566,6 +566,9 @@ extension Ghostty.Config { let rawValue: CUnsignedInt static let system = BellFeatures(rawValue: 1 << 0) + static let audio = BellFeatures(rawValue: 1 << 1) + static let attention = BellFeatures(rawValue: 1 << 2) + static let title = BellFeatures(rawValue: 1 << 3) } enum MacHidden : String { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 18a8d2f1c..46d379b9c 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -59,7 +59,7 @@ extension Ghostty { var title: String { var result = surfaceView.title - if (surfaceView.bell) { + if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) { result = "🔔 \(result)" } From aab00da24200d5eceb907d30339b889aec52a757 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 20:36:34 -0700 Subject: [PATCH 168/245] terminal: fix crash when reflowing grapheme with a spacer head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7536 When we're reflowing a row and we need to insert a spacer head, we must move to the next row to insert it. Previously, we were setting a spacer head and then copying data into that spacer head, which could lead to corrupt data and an eventual crash. In debug builds this triggers assertion failures but in release builds this would lead to silent corruption and a crash later on. The unit test shows the issue clearly but effectively you need a multi-codepoint grapheme such as `👨‍👨‍👦‍👦` to wrap across a row by changing the columns. --- src/terminal/PageList.zig | 149 +++++++++++++++++++++++++++++++++++--- src/terminal/page.zig | 7 +- 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index a0eb3edd1..9838bfb53 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -908,16 +908,6 @@ const ReflowCursor = struct { const cell = &cells[x]; x += 1; - // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{ - // src_y, - // x, - // self.y, - // self.x, - // self.page.size.cols, - // cell.content.codepoint, - // cell.wide, - // }); - // Copy cell contents. switch (cell.content_tag) { .codepoint, @@ -937,8 +927,15 @@ const ReflowCursor = struct { }; // Decrement the source position so that when we - // loop we'll process this source cell again. + // loop we'll process this source cell again, + // since we can't copy it into a spacer head. x -= 1; + + // Move to the next row (this sets pending wrap + // which will cause us to wrap on the next + // iteration). + self.cursorForward(); + continue; } else { self.page_cell.* = cell.*; } @@ -990,6 +987,17 @@ const ReflowCursor = struct { self.page_cell.hyperlink = false; self.page_cell.style_id = stylepkg.default_id; + // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={X} wide={} page_cell_wide={}", .{ + // src_y, + // x, + // self.y, + // self.x, + // self.page.size.cols, + // cell.content.codepoint, + // cell.wide, + // self.page_cell.wide, + // }); + // Copy grapheme data. if (cell.content_tag == .codepoint_grapheme) { // Copy the graphemes @@ -8375,6 +8383,125 @@ test "PageList resize reflow less cols to wrap a wide char" { } } +test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // We want to make the screen look like this: + // + // 👨‍👨‍👦‍👦👨‍👨‍👦‍👦 + + // First family emoji at (0, 0) + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme + .wide = .wide, + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, 0x1F468, + 0x200D, 0x1F466, + 0x200D, 0x1F466, + }); + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + } + // Second family emoji at (2, 0) + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme + .wide = .wide, + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, 0x1F468, + 0x200D, 0x1F466, + 0x200D, 0x1F466, + }); + } + { + const rac = page.getRowAndCell(3, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 6), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F468), cps[1]); + try testing.expectEqual(@as(u21, 0x200D), cps[2]); + try testing.expectEqual(@as(u21, 0x1F466), cps[3]); + try testing.expectEqual(@as(u21, 0x200D), cps[4]); + try testing.expectEqual(@as(u21, 0x1F466), cps[5]); + + // Row should be wrapped + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + } + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 6), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F468), cps[1]); + try testing.expectEqual(@as(u21, 0x200D), cps[2]); + try testing.expectEqual(@as(u21, 0x1F466), cps[3]); + try testing.expectEqual(@as(u21, 0x200D), cps[4]); + try testing.expectEqual(@as(u21, 0x1F466), cps[5]); + } + { + const rac = page.getRowAndCell(1, 1); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } +} + test "PageList resize reflow less cols copy kitty placeholder" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index d7f252af1..fea16c28b 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1316,7 +1316,12 @@ pub const Page = struct { /// Set the graphemes for the given cell. This asserts that the cell /// has no graphemes set, and only contains a single codepoint. - pub fn setGraphemes(self: *Page, row: *Row, cell: *Cell, cps: []u21) GraphemeError!void { + pub fn setGraphemes( + self: *Page, + row: *Row, + cell: *Cell, + cps: []const u21, + ) GraphemeError!void { defer self.assertIntegrity(); assert(cell.codepoint() > 0); From ea0766e62b4c63e7afacfb6d62cee554862061c6 Mon Sep 17 00:00:00 2001 From: Leorize Date: Sat, 7 Jun 2025 11:57:26 -0500 Subject: [PATCH 169/245] gtk/CommandPalette: prevent leaks on initialization * Deallocate the builder after use * Don't hold a reference to `Command` after appending to `GListStore` --- src/apprt/gtk/CommandPalette.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index fda2c5ca8..a99db78d7 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -43,6 +43,7 @@ pub fn init(self: *CommandPalette, window: *Window) !void { _ = Command.getGObjectType(); var builder = Builder.init("command-palette", 1, 5); + defer builder.deinit(); self.* = .{ .window = window, @@ -120,7 +121,9 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi command, config.keybind.set, ); - self.source.append(cmd.as(gobject.Object)); + const cmd_ref = cmd.as(gobject.Object); + self.source.append(cmd_ref); + cmd_ref.unref(); } } From 42bafe9d599799e47cd11a421282bfb4a40a6af6 Mon Sep 17 00:00:00 2001 From: Leorize Date: Sat, 7 Jun 2025 12:44:21 -0500 Subject: [PATCH 170/245] flatpak: detach process tracking thread after spawn This makes sure the underlying thread implementation know to free resources the moment the thread is no longer necessary, preventing leaks from not manually collecting the thread. --- src/os/flatpak.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index 7b92a8ba9..eaff529b0 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -112,6 +112,8 @@ pub const FlatpakHostCommand = struct { pub fn spawn(self: *FlatpakHostCommand, alloc: Allocator) !u32 { const thread = try std.Thread.spawn(.{}, threadMain, .{ self, alloc }); thread.setName("flatpak-host-command") catch {}; + // We don't track this thread, it will terminate on its own on command exit + thread.detach(); // Wait for the process to start or error. self.state_mutex.lock(); From 53c2874667f234e7dd054aeca81e8a469b050359 Mon Sep 17 00:00:00 2001 From: Leorize Date: Sat, 7 Jun 2025 13:26:21 -0500 Subject: [PATCH 171/245] flatpak: free GError after use --- src/os/flatpak.zig | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index eaff529b0..7bd84bc27 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -234,9 +234,10 @@ pub const FlatpakHostCommand = struct { }; // Get our bus connection. - var g_err: [*c]c.GError = null; + var g_err: ?*c.GError = null; + defer if (g_err) |ptr| c.g_error_free(ptr); const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { - log.warn("signal error getting bus: {s}", .{g_err.*.message}); + log.warn("signal error getting bus: {s}", .{g_err.?.*.message}); return Error.FlatpakSetupFail; }; defer c.g_object_unref(bus); @@ -260,7 +261,7 @@ pub const FlatpakHostCommand = struct { &g_err, ); if (g_err != null) { - log.warn("signal send error: {s}", .{g_err.*.message}); + log.warn("signal send error: {s}", .{g_err.?.*.message}); return; } defer c.g_variant_unref(reply); @@ -280,9 +281,10 @@ pub const FlatpakHostCommand = struct { // Get our bus connection. This has to remain active until we exit // the thread otherwise our signals won't be called. - var g_err: [*c]c.GError = null; + var g_err: ?*c.GError = null; + defer if (g_err) |ptr| c.g_error_free(ptr); const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse { - log.warn("spawn error getting bus: {s}", .{g_err.*.message}); + log.warn("spawn error getting bus: {s}", .{g_err.?.*.message}); self.updateState(.{ .err = {} }); return; }; @@ -310,7 +312,8 @@ pub const FlatpakHostCommand = struct { bus: *c.GDBusConnection, loop: *c.GMainLoop, ) !void { - var err: [*c]c.GError = null; + var err: ?*c.GError = null; + defer if (err) |ptr| c.g_error_free(ptr); var arena_allocator = std.heap.ArenaAllocator.init(alloc); defer arena_allocator.deinit(); const arena = arena_allocator.allocator(); @@ -319,15 +322,15 @@ pub const FlatpakHostCommand = struct { const fd_list = c.g_unix_fd_list_new(); defer c.g_object_unref(fd_list); if (c.g_unix_fd_list_append(fd_list, self.stdin, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } if (c.g_unix_fd_list_append(fd_list, self.stdout, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } if (c.g_unix_fd_list_append(fd_list, self.stderr, &err) < 0) { - log.warn("error adding fd: {s}", .{err.*.message}); + log.warn("error adding fd: {s}", .{err.?.*.message}); return Error.FlatpakSetupFail; } @@ -407,7 +410,7 @@ pub const FlatpakHostCommand = struct { null, &err, ) orelse { - log.warn("Flatpak.HostCommand failed: {s}", .{err.*.message}); + log.warn("Flatpak.HostCommand failed: {s}", .{err.?.*.message}); return Error.FlatpakRPCFail; }; defer c.g_variant_unref(reply); From 41ee578b7a0ed7226a06d5d77fa672b30b502a46 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 12:36:12 -0700 Subject: [PATCH 172/245] macos: quick terminal restores previous size when exiting final surface This fixes a regression from the new split work last week, but it was also probably an issue before that in a slightly different way. With the new split work, the quick terminal was becoming unusable when the final surface explicitly `exit`-ed, because AppKit/SwiftUI would resize the window to a very small size and you couldn't see the new terminal on the next toggle. Prior to this, I think the quick terminal would've reverted to its original size but I'm not sure (even if the user resized it manually). This commit saves the size of the quick terminal at the point all surfaces are exited and restores it when the quick terminal is shown the next time with a new initial surface. --- .../QuickTerminalController.swift | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 0dcfce204..8c86c2531 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -21,6 +21,14 @@ class QuickTerminalController: BaseTerminalController { // The active space when the quick terminal was last shown. private var previousActiveSpace: CGSSpace? = nil + /// The window frame saved when the quick terminal's surface tree becomes empty. + /// + /// This preserves the user's window size and position when all terminal surfaces + /// are closed (e.g., via the `exit` command). When a new surface is created, + /// the window will be restored to this frame, preventing SwiftUI from resetting + /// the window to its default minimum size. + private var lastClosedFrame: NSRect? = nil + /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -190,6 +198,12 @@ class QuickTerminalController: BaseTerminalController { // If our surface tree is nil then we animate the window out. if (to.isEmpty) { + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + lastClosedFrame = window?.frame + animateOut() } } @@ -230,9 +244,6 @@ class QuickTerminalController: BaseTerminalController { // Set previous active space self.previousActiveSpace = CGSSpace.active() - // Animate the window in - animateWindowIn(window: window, from: position) - // If our surface tree is empty then we initialize a new terminal. The surface // tree can be empty if for example we run "exit" in the terminal and force // animate out. @@ -241,7 +252,16 @@ class QuickTerminalController: BaseTerminalController { let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) surfaceTree = SplitTree(view: view) focusedSurface = view + + // Restore our previous frame if we have one + if let lastClosedFrame { + window.setFrame(lastClosedFrame, display: false) + self.lastClosedFrame = nil + } } + + // Animate the window in + animateWindowIn(window: window, from: position) } func animateOut() { From 493b1f53506263c11d763ae19a0ca83f1b8bd0e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 15:04:11 -0700 Subject: [PATCH 173/245] wip: undo --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++ macos/Sources/App/macOS/AppDelegate.swift | 10 +++ macos/Sources/App/macOS/MainMenu.xib | 15 ++++ .../Terminal/BaseTerminalController.swift | 74 ++++++++++++++++--- macos/Sources/Helpers/ExpiringTarget.swift | 53 +++++++++++++ .../Extensions/Duration+Extension.swift | 8 ++ 6 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Helpers/ExpiringTarget.swift create mode 100644 macos/Sources/Helpers/Extensions/Duration+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 62cb079bf..153ec8e6f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,6 +62,8 @@ A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; + A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */; }; + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -168,6 +170,8 @@ A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; + A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTarget.swift; sourceTree = ""; }; + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -290,6 +294,7 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */, A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, @@ -432,6 +437,7 @@ isa = PBXGroup; children = ( A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, @@ -686,6 +692,7 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, @@ -713,6 +720,7 @@ A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, + A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index fd25ef358..d12b2efd2 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -36,6 +36,8 @@ class AppDelegate: NSObject, @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? + @IBOutlet private var menuUndo: NSMenuItem? + @IBOutlet private var menuRedo: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @@ -88,6 +90,9 @@ class AppDelegate: NSObject, /// Manages our terminal windows. let terminalManager: TerminalManager + /// The global undo manager for app-level state such as window restoration. + lazy var undoManager = UndoManager() + /// Our quick terminal. This starts out uninitialized and only initializes if used. private var quickController: QuickTerminalController? = nil @@ -393,6 +398,11 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + // TODO: sync + menuUndo?.keyEquivalent = "z" + menuUndo?.keyEquivalentModifierMask = [.command] + menuRedo?.keyEquivalent = "z" + menuRedo?.keyEquivalentModifierMask = [.command, .shift] syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 828e82bd0..7130d544e 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -40,6 +40,7 @@ + @@ -57,6 +58,7 @@ + @@ -204,6 +206,19 @@ + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ea849bb4a..cd7ceffbb 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -75,6 +75,13 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The undo manager for this controller is the undo manager of the window, + /// which we set via the delegate method. + override var undoManager: UndoManager? { + // This should be set via the delegate method windowWillReturnUndoManager + window?.undoManager + } + struct SavedFrame { let window: NSRect let screen: NSRect @@ -261,6 +268,9 @@ class BaseTerminalController: NSWindowController, let oldFocused = focusedSurface let focused = node.contains { $0 == focusedSurface } + // Keep track of the old tree for undo management. + let oldTree = surfaceTree + // Remove the node from the tree surfaceTree = surfaceTree.remove(node) @@ -270,6 +280,32 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: nextTarget, from: oldFocused) } } + + // Setup our undo + if let undoManager { + undoManager.setActionName("Close Terminal") + undoManager.registerUndo(withTarget: ExpiringTarget( + with: .seconds(5), + in: undoManager, + )) { [weak self] v in + guard let self else { return } + self.surfaceTree = oldTree + if let oldFocused { + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldFocused, from: self.focusedSurface) + } + } + + undoManager.registerUndo(withTarget: NSObject()) { [weak self] _ in + self?.closeSurface( + node.leftmostLeaf(), + withConfirmation: node.contains { + $0.needsConfirmQuit + } + ) + } + } + } } // MARK: Notifications @@ -346,19 +382,25 @@ class BaseTerminalController: NSWindowController, } @objc private func ghosttyDidCloseSurface(_ notification: Notification) { - // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } + closeSurface( + target, + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, + ) + } + + /// Close a surface view, requesting confirmation if necessary. + /// + /// This will also insert the proper undo stack information in. + private func closeSurface( + _ target: Ghostty.SurfaceView, + withConfirmation: Bool = true, + ) { + // The target must be within our tree guard let node = surfaceTree.root?.node(view: target) else { return } - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - // If the child process is not alive, then we exit immediately - guard processAlive else { + guard withConfirmation else { removeSurfaceAndMoveFocus(node) return } @@ -405,7 +447,8 @@ class BaseTerminalController: NSWindowController, // Do the split do { - surfaceTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) + let newTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) + surfaceTree = newTree } catch { // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its @@ -414,6 +457,7 @@ class BaseTerminalController: NSWindowController, return } + // Once we've split, we need to move focus to the new split Ghostty.moveFocus(to: newView, from: oldView) } @@ -732,6 +776,11 @@ class BaseTerminalController: NSWindowController, // MARK: NSWindowController override func windowDidLoad() { + super.windowDidLoad() + + // Setup our undo manager. + + // Everything beyond here is setting up the window guard let window else { return } // If there is a hardcoded title in the configuration, we set that @@ -818,6 +867,11 @@ class BaseTerminalController: NSWindowController, windowFrameDidChange() } + func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } + return appDelegate.undoManager + } + // MARK: First Responder @IBAction func close(_ sender: Any) { diff --git a/macos/Sources/Helpers/ExpiringTarget.swift b/macos/Sources/Helpers/ExpiringTarget.swift new file mode 100644 index 000000000..d24021495 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringTarget.swift @@ -0,0 +1,53 @@ +import AppKit + +/// A target object for UndoManager that automatically expires after a specified duration. +/// +/// ExpiringTarget holds a reference to a target object and removes all undo actions +/// associated with itself from the UndoManager when the timer expires. This is useful +/// for creating temporary undo operations that should not persist beyond a certain time. +/// +/// The parameter T can be used to retain a reference to some target value +/// that can be used in the undo operation. The target is released when the timer expires. +/// +/// - Parameter T: The type of the target object, constrained to AnyObject +class ExpiringTarget { + private(set) var target: T? + private var timer: Timer? + private weak var undoManager: UndoManager? + + /// Creates an expiring target that will automatically remove undo actions after the specified duration. + /// + /// - Parameters: + /// - target: The target object to hold weakly. Defaults to nil. + /// - duration: The time after which the target should expire + /// - undoManager: The UndoManager from which to remove actions when expired + init(_ target: T? = nil, with duration: Duration, in undoManager: UndoManager) { + self.target = target + self.undoManager = undoManager + self.timer = Timer.scheduledTimer( + withTimeInterval: duration.timeInterval, + repeats: false) { _ in + self.expire() + } + } + + /// Manually expires the target, removing all associated undo actions and invalidating the timer. + /// + /// This method is called automatically when the timer fires, but can also be called manually + /// to expire the target before the timer duration has elapsed. + func expire() { + target = nil + undoManager?.removeAllActions(withTarget: self) + timer?.invalidate() + } + + deinit { + expire() + } +} + +extension ExpiringTarget where T == NSObject { + convenience init(with duration: Duration, in undoManager: UndoManager) { + self.init(nil, with: duration, in: undoManager) + } +} diff --git a/macos/Sources/Helpers/Extensions/Duration+Extension.swift b/macos/Sources/Helpers/Extensions/Duration+Extension.swift new file mode 100644 index 000000000..43eca6b79 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Duration+Extension.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Duration { + var timeInterval: TimeInterval { + return TimeInterval(self.components.seconds) + + TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000 + } +} From 6d32b01c6498a4fab34293cd673dc4437798ede8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 21:28:49 -0700 Subject: [PATCH 174/245] macos: implement a custom ExpiringUndoManager, setup undo for new/close --- macos/Ghostty.xcodeproj/project.pbxproj | 8 +- macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../Terminal/BaseTerminalController.swift | 79 ++++++++-- .../Sources/Ghostty/SurfaceView_AppKit.swift | 2 + macos/Sources/Helpers/ExpiringTarget.swift | 53 ------- .../Sources/Helpers/ExpiringUndoManager.swift | 137 ++++++++++++++++++ 6 files changed, 207 insertions(+), 74 deletions(-) delete mode 100644 macos/Sources/Helpers/ExpiringTarget.swift create mode 100644 macos/Sources/Helpers/ExpiringUndoManager.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 153ec8e6f..67f1784ac 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,8 +62,8 @@ A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; - A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */; }; A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -170,8 +170,8 @@ A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; - A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTarget.swift; sourceTree = ""; }; A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -294,7 +294,6 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( - A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */, A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, @@ -303,6 +302,7 @@ A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, @@ -708,6 +708,7 @@ A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, @@ -720,7 +721,6 @@ A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, - A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index d12b2efd2..eae8dd121 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -91,7 +91,7 @@ class AppDelegate: NSObject, let terminalManager: TerminalManager /// The global undo manager for app-level state such as window restoration. - lazy var undoManager = UndoManager() + lazy var undoManager = ExpiringUndoManager() /// Our quick terminal. This starts out uninitialized and only initializes if used. private var quickController: QuickTerminalController? = nil diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index cd7ceffbb..6cc6b2ec8 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -75,11 +75,25 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The time that undo/redo operations that contain running ptys are valid for. + private var undoExpiration: Duration { + .seconds(5) + } + /// The undo manager for this controller is the undo manager of the window, /// which we set via the delegate method. - override var undoManager: UndoManager? { + override var undoManager: ExpiringUndoManager? { // This should be set via the delegate method windowWillReturnUndoManager - window?.undoManager + if let result = window?.undoManager as? ExpiringUndoManager { + return result + } + + // If the window one isn't set, we fallback to our global one. + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + return appDelegate.undoManager + } + + return nil } struct SavedFrame { @@ -173,7 +187,7 @@ class BaseTerminalController: NSWindowController, deinit { NotificationCenter.default.removeObserver(self) - + undoManager?.removeAllActions(withTarget: self) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } @@ -284,20 +298,20 @@ class BaseTerminalController: NSWindowController, // Setup our undo if let undoManager { undoManager.setActionName("Close Terminal") - undoManager.registerUndo(withTarget: ExpiringTarget( - with: .seconds(5), - in: undoManager, - )) { [weak self] v in - guard let self else { return } - self.surfaceTree = oldTree + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration) { target in + target.surfaceTree = oldTree if let oldFocused { DispatchQueue.main.async { - Ghostty.moveFocus(to: oldFocused, from: self.focusedSurface) + Ghostty.moveFocus(to: oldFocused, from: target.focusedSurface) } } - undoManager.registerUndo(withTarget: NSObject()) { [weak self] _ in - self?.closeSurface( + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration) { target in + target.closeSurface( node.leftmostLeaf(), withConfirmation: node.contains { $0.needsConfirmQuit @@ -446,9 +460,12 @@ class BaseTerminalController: NSWindowController, let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) // Do the split + let newTree: SplitTree do { - let newTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) - surfaceTree = newTree + newTree = try surfaceTree.insert( + view: newView, + at: oldView, + direction: splitDirection) } catch { // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its @@ -457,9 +474,36 @@ class BaseTerminalController: NSWindowController, return } + // Keep track of the old tree for undo + let oldTree = surfaceTree - // Once we've split, we need to move focus to the new split - Ghostty.moveFocus(to: newView, from: oldView) + // Setup our new split tree + surfaceTree = newTree + DispatchQueue.main.async { + Ghostty.moveFocus(to: newView, from: oldView) + } + + // Setup our undo + if let undoManager { + undoManager.setActionName("New Split") + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration) { target in + target.surfaceTree = oldTree + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration) { target in + target.surfaceTree = newTree + DispatchQueue.main.async { + Ghostty.moveFocus(to: newView, from: target.focusedSurface) + } + } + } + } } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { @@ -836,6 +880,9 @@ class BaseTerminalController: NSWindowController, // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. window.contentView = nil + + // Make sure we clean up all our undos + window.undoManager?.removeAllActions(withTarget: self) } func windowDidBecomeKey(_ notification: Notification) { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 682efa947..6e35f40d1 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -287,6 +287,8 @@ extension Ghostty { if let surface = self.surface { ghostty_surface_free(surface) } + + Ghostty.logger.warning("WOW close") } func focusDidChange(_ focused: Bool) { diff --git a/macos/Sources/Helpers/ExpiringTarget.swift b/macos/Sources/Helpers/ExpiringTarget.swift deleted file mode 100644 index d24021495..000000000 --- a/macos/Sources/Helpers/ExpiringTarget.swift +++ /dev/null @@ -1,53 +0,0 @@ -import AppKit - -/// A target object for UndoManager that automatically expires after a specified duration. -/// -/// ExpiringTarget holds a reference to a target object and removes all undo actions -/// associated with itself from the UndoManager when the timer expires. This is useful -/// for creating temporary undo operations that should not persist beyond a certain time. -/// -/// The parameter T can be used to retain a reference to some target value -/// that can be used in the undo operation. The target is released when the timer expires. -/// -/// - Parameter T: The type of the target object, constrained to AnyObject -class ExpiringTarget { - private(set) var target: T? - private var timer: Timer? - private weak var undoManager: UndoManager? - - /// Creates an expiring target that will automatically remove undo actions after the specified duration. - /// - /// - Parameters: - /// - target: The target object to hold weakly. Defaults to nil. - /// - duration: The time after which the target should expire - /// - undoManager: The UndoManager from which to remove actions when expired - init(_ target: T? = nil, with duration: Duration, in undoManager: UndoManager) { - self.target = target - self.undoManager = undoManager - self.timer = Timer.scheduledTimer( - withTimeInterval: duration.timeInterval, - repeats: false) { _ in - self.expire() - } - } - - /// Manually expires the target, removing all associated undo actions and invalidating the timer. - /// - /// This method is called automatically when the timer fires, but can also be called manually - /// to expire the target before the timer duration has elapsed. - func expire() { - target = nil - undoManager?.removeAllActions(withTarget: self) - timer?.invalidate() - } - - deinit { - expire() - } -} - -extension ExpiringTarget where T == NSObject { - convenience init(with duration: Duration, in undoManager: UndoManager) { - self.init(nil, with: duration, in: undoManager) - } -} diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift new file mode 100644 index 000000000..3eda56182 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -0,0 +1,137 @@ +/// An UndoManager subclass that supports registering undo operations that automatically expire after a specified duration. +/// +/// This class extends the standard UndoManager to add time-based expiration for undo operations. +/// When an undo operation expires, it is automatically removed from the undo stack and cannot be invoked. +/// +/// Example usage: +/// ```swift +/// let undoManager = ExpiringUndoManager() +/// undoManager.registerUndo(withTarget: myObject, expiresAfter: .seconds(30)) { target in +/// // Undo operation that expires after 30 seconds +/// target.restorePreviousState() +/// } +/// ``` +class ExpiringUndoManager: UndoManager { + /// The set of expiring targets so we can properly clean them up when removeAllActions + /// is called with the real target. + private lazy var expiringTargets: Set = [] + + /// Registers an undo operation that automatically expires after the specified duration. + /// + /// - Parameters: + /// - target: The target object for the undo operation. The undo operation will be removed + /// if this object is deallocated before the operation is invoked. + /// - duration: The duration after which the undo operation should expire and be removed from the undo stack. + /// - handler: The closure to execute when the undo operation is invoked. The closure receives + /// the target object as its parameter. + func registerUndo( + withTarget target: TargetType, + expiresAfter duration: Duration, + handler: @escaping (TargetType) -> Void + ) { + let expiringTarget = ExpiringTarget( + target, + expiresAfter: duration, + in: self) + expiringTargets.insert(expiringTarget) + + super.registerUndo(withTarget: expiringTarget) { [weak self] expiringTarget in + self?.expiringTargets.remove(expiringTarget) + guard let target = expiringTarget.target as? TargetType else { return } + handler(target) + } + } + + /// Removes all undo and redo operations from the undo manager. + /// + /// This override ensures that all expiring targets are also cleared when + /// the undo manager is reset. + override func removeAllActions() { + super.removeAllActions() + expiringTargets = [] + } + + /// Removes all undo and redo operations involving the specified target. + /// + /// This override ensures that when actions are removed for a target, any associated + /// expiring targets are also properly cleaned up. + /// + /// - Parameter target: The target object whose actions should be removed. + override func removeAllActions(withTarget target: Any) { + // Call super to handle standard removal + super.removeAllActions(withTarget: target) + + if !(target is ExpiringTarget) { + // Find and remove any ExpiringTarget instances that wrap this target. + expiringTargets + .filter { $0.target == nil || $0.target === (target as AnyObject) } + .forEach { + // Technically they'll always expire when they get deinitialized + // but we want to make sure it happens right now. + $0.expire() + expiringTargets.remove($0) + } + } + } +} + +/// A target object for ExpiringUndoManager that removes itself from the +/// undo manager after it expires. +/// +/// This class acts as a proxy for the real target object in undo operations. +/// It holds a weak reference to the actual target and automatically removes +/// all associated undo operations when either: +/// - The specified duration expires +/// - The ExpiringTarget instance is deallocated +/// - The expire() method is called manually +private class ExpiringTarget { + /// The actual target object for the undo operation, held weakly to avoid retain cycles. + private(set) weak var target: AnyObject? + + /// Timer that triggers expiration after the specified duration. + private var timer: Timer? + + /// The undo manager from which to remove actions when this target expires. + private weak var undoManager: UndoManager? + + /// Creates an expiring target that will automatically remove undo actions after the specified duration. + /// + /// - Parameters: + /// - target: The target object to hold weakly. + /// - duration: The time after which the target should expire. + /// - undoManager: The UndoManager from which to remove actions when expired. + init(_ target: AnyObject? = nil, expiresAfter duration: Duration, in undoManager: UndoManager) { + self.target = target + self.undoManager = undoManager + self.timer = Timer.scheduledTimer( + withTimeInterval: duration.timeInterval, + repeats: false) { [weak self] _ in + self?.expire() + } + } + + /// Manually expires the target, removing all associated undo actions and invalidating the timer. + /// + /// This method is called automatically when the timer fires, but can also be called manually + /// to expire the target before the timer duration has elapsed. + func expire() { + target = nil + undoManager?.removeAllActions(withTarget: self) + timer?.invalidate() + timer = nil + } + + deinit { + expire() + } +} + +extension ExpiringTarget: Hashable, Equatable { + static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool { + return lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} From f571519157bc2eaf3dcf7995989c7d8266a8ddc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 21:43:41 -0700 Subject: [PATCH 175/245] macos: setup undo responders at the AppDelegate level --- macos/Sources/App/macOS/AppDelegate.swift | 24 +++++++++++++++++++ .../Features/Terminal/TerminalManager.swift | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index eae8dd121..1fce7d665 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -892,6 +892,14 @@ class AppDelegate: NSObject, NSApplication.shared.arrangeInFront(sender) } + @IBAction func undo(_ sender: Any?) { + undoManager.undo() + } + + @IBAction func redo(_ sender: Any?) { + undoManager.redo() + } + private struct DerivedConfig { let initialWindow: Bool let shouldQuitAfterLastWindowClosed: Bool @@ -981,6 +989,22 @@ extension AppDelegate: NSMenuItemValidation { // terminal window (not quick terminal). return NSApp.keyWindow is TerminalWindow + case #selector(undo(_:)): + if undoManager.canUndo { + item.title = "Undo \(undoManager.undoActionName)" + } else { + item.title = "Undo" + } + return undoManager.canUndo + + case #selector(redo(_:)): + if undoManager.canRedo { + item.title = "Redo \(undoManager.redoActionName)" + } else { + item.title = "Redo" + } + return undoManager.canRedo + default: return true } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 805ae6e93..050bc5563 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -228,7 +228,7 @@ class TerminalManager { // Ensure any publishers we have are cancelled w.closePublisher.cancel() - + // If we remove a window, we reset the cascade point to the key window so that // the next window cascade's from that one. if let focusedWindow = NSApplication.shared.keyWindow { From 104cc2adfee94062c735611b0dbacf7332dff58d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 07:52:31 -0700 Subject: [PATCH 176/245] macos: basic undo close window, not very robust yet --- macos/Sources/Features/Splits/SplitTree.swift | 38 ++++++++ .../Terminal/BaseTerminalController.swift | 31 +++--- .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- 3 files changed, 147 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index cbd440124..394cd1089 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -107,6 +107,18 @@ extension SplitTree { self.init(root: .leaf(view: view), zoomed: nil) } + /// Checks if the tree contains the specified node. + /// + /// Note that SplitTree implements Sequence on views so there's already a `contains` + /// for views too. + /// + /// - Parameter node: The node to search for in the tree + /// - Returns: True if the node exists in the tree, false otherwise + func contains(_ node: Node) -> Bool { + guard let root else { return false } + return root.path(to: node) != nil + } + /// Insert a new view at the given view point by creating a split in the given direction. /// This will always reset the zoomed state of the tree. func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { @@ -1078,3 +1090,29 @@ extension SplitTree.Node: Sequence { return leaves().makeIterator() } } + +// MARK: SplitTree Collection + +extension SplitTree: Collection { + typealias Index = Int + typealias Element = ViewType + + var startIndex: Int { + return 0 + } + + var endIndex: Int { + return root?.leaves().count ?? 0 + } + + subscript(position: Int) -> ViewType { + precondition(position >= 0 && position < endIndex, "Index out of bounds") + let leaves = root?.leaves() ?? [] + return leaves[position] + } + + func index(after i: Int) -> Int { + precondition(i < endIndex, "Cannot increment index beyond endIndex") + return i + 1 + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6cc6b2ec8..e34a44941 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -76,7 +76,7 @@ class BaseTerminalController: NSWindowController, private var focusedSurfaceCancellables: Set = [] /// The time that undo/redo operations that contain running ptys are valid for. - private var undoExpiration: Duration { + var undoExpiration: Duration { .seconds(5) } @@ -277,7 +277,11 @@ class BaseTerminalController: NSWindowController, } /// Remove a node from the surface tree and move focus appropriately. - private func removeSurfaceAndMoveFocus(_ node: SplitTree.Node) { + /// + /// This also updates the undo manager to support restoring this node. + /// + /// This does no confirmation and assumes confirmation is already done. + private func removeSurfaceNode(_ node: SplitTree.Node) { let nextTarget = findNextFocusTargetAfterClosing(node: node) let oldFocused = focusedSurface let focused = node.contains { $0 == focusedSurface } @@ -311,8 +315,8 @@ class BaseTerminalController: NSWindowController, undoManager.registerUndo( withTarget: target, expiresAfter: target.undoExpiration) { target in - target.closeSurface( - node.leftmostLeaf(), + target.closeSurfaceNode( + node, withConfirmation: node.contains { $0.needsConfirmQuit } @@ -397,25 +401,26 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidCloseSurface(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - closeSurface( - target, + guard let node = surfaceTree.root?.node(view: target) else { return } + closeSurfaceNode( + node, withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, ) } - /// Close a surface view, requesting confirmation if necessary. + /// Close a surface node (which may contain splits), requesting confirmation if necessary. /// /// This will also insert the proper undo stack information in. - private func closeSurface( - _ target: Ghostty.SurfaceView, + func closeSurfaceNode( + _ node: SplitTree.Node, withConfirmation: Bool = true, ) { - // The target must be within our tree - guard let node = surfaceTree.root?.node(view: target) else { return } + // This node must be part of our tree + guard surfaceTree.contains(node) else { return } // If the child process is not alive, then we exit immediately guard withConfirmation else { - removeSurfaceAndMoveFocus(node) + removeSurfaceNode(node) return } @@ -429,7 +434,7 @@ class BaseTerminalController: NSWindowController, informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." ) { [weak self] in if let self { - self.removeSurfaceAndMoveFocus(node) + self.removeSurfaceNode(node) } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index baae90068..554f7699b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -386,6 +386,93 @@ class TerminalController: BaseTerminalController { return frame } + /// This is called anytime a node in the surface tree is being removed. + override func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // More than 1 window means we have tabs and we're closing a tab + if window?.tabGroup?.windows.count ?? 0 > 1 { + closeTab(nil) + return + } + + // 1 window, closing the window + closeWindow(nil) + } + + /// Closes the current window (including any other tabs) immediately and without + /// confirmation. This will setup proper undo state so the action can be undone. + private func closeWindowImmediately(_ sender: Any?) { + guard let window = window else { return } + + // Regardless of tabs vs no tabs, what we want to do here is keep + // track of the window frame to restore, the surface tree, and the + // the focused surface. We want to restore that with undo even + // if we end up closing. + if let undoManager { + // Capture current state for undo + let currentFrame = window.frame + let currentSurfaceTree = surfaceTree + let currentFocusedSurface = focusedSurface + + // Register undo action to restore the window + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + + // Create a new window controller with the saved state + let newController = TerminalController( + ghostty, + withSurfaceTree: currentSurfaceTree + ) + + // Show the window and restore its frame + newController.showWindow(nil) + if let newWindow = newController.window { + newWindow.setFrame(currentFrame, display: true) + + // Restore focus to the previously focused surface + if let focusTarget = currentFocusedSurface { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusTarget, from: nil) + } + } + } + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + // For redo, we close the window again + target.closeWindowImmediately(sender) + } + } + } + + guard let tabGroup = window.tabGroup else { + // No tabs, no tab group, just perform a normal close. + window.close() + return + } + + // If have one window then we just do a normal close + if tabGroup.windows.count == 1 { + window.close() + return + } + + + tabGroup.windows.forEach { $0.close() } + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -635,13 +722,13 @@ class TerminalController: BaseTerminalController { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + closeWindowImmediately(sender) return } // If have one window then we just do a normal close if tabGroup.windows.count == 1 { - window.performClose(sender) + closeWindowImmediately(sender) return } @@ -655,7 +742,7 @@ class TerminalController: BaseTerminalController { // If none need confirmation then we can just close all the windows. if !needsConfirm { - tabGroup.windows.forEach { $0.close() } + closeWindowImmediately(sender) return } @@ -663,7 +750,7 @@ class TerminalController: BaseTerminalController { messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - tabGroup.windows.forEach { $0.close() } + self.closeWindowImmediately(sender) } } From 5f74445b141a14d5a0d8705a38161f242edf1ec6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 11:05:06 -0700 Subject: [PATCH 177/245] macos: basic undo tab, not quite working --- .../Terminal/TerminalController.swift | 163 ++++++++++++------ 1 file changed, 115 insertions(+), 48 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 554f7699b..b7b2fcd89 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -407,52 +407,82 @@ class TerminalController: BaseTerminalController { closeWindow(nil) } - /// Closes the current window (including any other tabs) immediately and without - /// confirmation. This will setup proper undo state so the action can be undone. - private func closeWindowImmediately(_ sender: Any?) { + private func closeTabImmediately() { guard let window = window else { return } - - // Regardless of tabs vs no tabs, what we want to do here is keep - // track of the window frame to restore, the surface tree, and the - // the focused surface. We want to restore that with undo even - // if we end up closing. - if let undoManager { - // Capture current state for undo - let currentFrame = window.frame - let currentSurfaceTree = surfaceTree - let currentFocusedSurface = focusedSurface - - // Register undo action to restore the window - undoManager.setActionName("Close Window") + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + closeWindowImmediately() + return + } + + // Undo + if let undoManager, let undoState { + // Get the current tab index before closing + let tabIndex = tabGroup.windows.firstIndex(of: window) ?? 0 + + // Register undo action to restore the tab + undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration) { ghostty in - - // Create a new window controller with the saved state - let newController = TerminalController( - ghostty, - withSurfaceTree: currentSurfaceTree - ) - // Show the window and restore its frame - newController.showWindow(nil) + // Create a new window controller with the saved state + let newController = TerminalController(ghostty, with: undoState) + if let newWindow = newController.window { - newWindow.setFrame(currentFrame, display: true) - - // Restore focus to the previously focused surface - if let focusTarget = currentFocusedSurface { - DispatchQueue.main.async { - Ghostty.moveFocus(to: focusTarget, from: nil) - } + // Add the window back to the tab group at the correct position + if let targetWindow = tabGroup.windows.dropFirst(tabIndex).first { + // Insert after the target window + targetWindow.addTabbedWindow(newWindow, ordered: .above) + } else if let targetWindow = tabGroup.windows.last { + // Add at the end if the original position is beyond current tabs + targetWindow.addTabbedWindow(newWindow, ordered: .above) + } else if let firstWindow = tabGroup.windows.first { + // Fallback: add to the beginning if needed + firstWindow.addTabbedWindow(newWindow, ordered: .below) } + + // Make it the key window + newWindow.makeKeyAndOrderFront(nil) } + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + // For redo, we close the tab again + target.closeTabImmediately() + } + } + } + + window.close() + } + + /// Closes the current window (including any other tabs) immediately and without + /// confirmation. This will setup proper undo state so the action can be undone. + private func closeWindowImmediately() { + guard let window = window else { return } + + // Regardless of tabs vs no tabs, what we want to do here is keep + // track of the window frame to restore, the surface tree, and the + // the focused surface. We want to restore that with undo even + // if we end up closing. + if let undoManager, let undoState { + // Register undo action to restore the window + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + // Restore the undo state + let newController = TerminalController(ghostty, with: undoState) + // Register redo action undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in // For redo, we close the window again - target.closeWindowImmediately(sender) + target.closeWindowImmediately() } } } @@ -473,6 +503,44 @@ class TerminalController: BaseTerminalController { tabGroup.windows.forEach { $0.close() } } + // MARK: Undo/Redo + + /// The state that we require to recreate a TerminalController from an undo. + struct UndoState { + let frame: NSRect + let surfaceTree: SplitTree + let focusedSurface: UUID? + } + + convenience init(_ ghostty: Ghostty.App, + with undoState: UndoState + ) { + self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + + // Show the window and restore its frame + showWindow(nil) + if let window { + window.setFrame(undoState.frame, display: true) + + // Restore focus to the previously focused surface + if let focusedUUID = undoState.focusedSurface, + let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusTarget, from: nil) + } + } + } + } + + /// The current undo state for this controller + var undoState: UndoState? { + guard let window else { return nil } + return .init( + frame: window.frame, + surfaceTree: surfaceTree, + focusedSurface: focusedSurface?.uuid) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -694,23 +762,22 @@ class TerminalController: BaseTerminalController { @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } - guard window.tabGroup != nil else { - // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + guard window.tabGroup?.windows.count ?? 0 > 1 else { + closeWindow(sender) return } - if surfaceTree.contains(where: { $0.needsConfirmQuit }) { - confirmClose( - messageText: "Close Tab?", - informativeText: "The terminal still has a running process. If you close the tab the process will be killed." - ) { - window.close() - } + guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else { + closeTabImmediately() return } - window.close() + confirmClose( + messageText: "Close Tab?", + informativeText: "The terminal still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabImmediately() + } } @IBAction func returnToDefaultSize(_ sender: Any?) { @@ -722,13 +789,13 @@ class TerminalController: BaseTerminalController { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. - closeWindowImmediately(sender) + closeWindowImmediately() return } // If have one window then we just do a normal close if tabGroup.windows.count == 1 { - closeWindowImmediately(sender) + closeWindowImmediately() return } @@ -742,7 +809,7 @@ class TerminalController: BaseTerminalController { // If none need confirmation then we can just close all the windows. if !needsConfirm { - closeWindowImmediately(sender) + closeWindowImmediately() return } @@ -750,7 +817,7 @@ class TerminalController: BaseTerminalController { messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - self.closeWindowImmediately(sender) + self.closeWindowImmediately() } } @@ -948,7 +1015,6 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons @@ -971,6 +1037,7 @@ class TerminalController: BaseTerminalController { } } +// MARK: NSMenuItemValidation extension TerminalController: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { From e1847da1391b3bc49e3c79a0afcec338a7441c0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 11:13:47 -0700 Subject: [PATCH 178/245] macos: more robust undo tab that goes back to the same position --- .../Terminal/TerminalController.swift | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index b7b2fcd89..162141d11 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -417,35 +417,13 @@ class TerminalController: BaseTerminalController { // Undo if let undoManager, let undoState { - // Get the current tab index before closing - let tabIndex = tabGroup.windows.firstIndex(of: window) ?? 0 - // Register undo action to restore the tab undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration) { ghostty in - - // Create a new window controller with the saved state let newController = TerminalController(ghostty, with: undoState) - if let newWindow = newController.window { - // Add the window back to the tab group at the correct position - if let targetWindow = tabGroup.windows.dropFirst(tabIndex).first { - // Insert after the target window - targetWindow.addTabbedWindow(newWindow, ordered: .above) - } else if let targetWindow = tabGroup.windows.last { - // Add at the end if the original position is beyond current tabs - targetWindow.addTabbedWindow(newWindow, ordered: .above) - } else if let firstWindow = tabGroup.windows.first { - // Fallback: add to the beginning if needed - firstWindow.addTabbedWindow(newWindow, ordered: .below) - } - - // Make it the key window - newWindow.makeKeyAndOrderFront(nil) - } - // Register redo action undoManager.registerUndo( withTarget: newController, @@ -510,6 +488,8 @@ class TerminalController: BaseTerminalController { let frame: NSRect let surfaceTree: SplitTree let focusedSurface: UUID? + let tabIndex: Int? + private(set) weak var tabGroup: NSWindowTabGroup? } convenience init(_ ghostty: Ghostty.App, @@ -522,6 +502,21 @@ class TerminalController: BaseTerminalController { if let window { window.setFrame(undoState.frame, display: true) + // If we have a tab group and index, restore the tab to its original position + if let tabGroup = undoState.tabGroup, + let tabIndex = undoState.tabIndex { + if tabIndex < tabGroup.windows.count { + // Find the window that is currently at that index + let currentWindow = tabGroup.windows[tabIndex] + currentWindow.addTabbedWindow(window, ordered: .below) + } else { + tabGroup.windows.last?.addTabbedWindow(window, ordered: .above) + } + + // Make it the key window + window.makeKeyAndOrderFront(nil) + } + // Restore focus to the previously focused surface if let focusedUUID = undoState.focusedSurface, let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { @@ -538,7 +533,9 @@ class TerminalController: BaseTerminalController { return .init( frame: window.frame, surfaceTree: surfaceTree, - focusedSurface: focusedSurface?.uuid) + focusedSurface: focusedSurface?.uuid, + tabIndex: window.tabGroup?.windows.firstIndex(of: window), + tabGroup: window.tabGroup) } //MARK: - NSWindowController From b044f4864ae4ad8ab06e3e631a23eb2e5748c0ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 11:34:33 -0700 Subject: [PATCH 179/245] add undo/redo keybindings, default them on macOS --- include/ghostty.h | 2 + macos/Sources/App/macOS/AppDelegate.swift | 7 +-- .../Terminal/TerminalController.swift | 6 +-- macos/Sources/Ghostty/Ghostty.App.swift | 48 +++++++++++++++++++ src/Surface.zig | 12 +++++ src/apprt/action.zig | 9 ++++ src/build/mdgen/mdgen.zig | 3 +- src/config/Config.zig | 12 +++++ src/input/Binding.zig | 31 ++++++++++++ src/input/command.zig | 12 +++++ 10 files changed, 132 insertions(+), 10 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6b1625a30..95bd58cd7 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -673,6 +673,8 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES } ghostty_action_tag_e; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1fce7d665..db332813f 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -398,11 +398,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) - // TODO: sync - menuUndo?.keyEquivalent = "z" - menuUndo?.keyEquivalentModifierMask = [.command] - menuRedo?.keyEquivalent = "z" - menuRedo?.keyEquivalentModifierMask = [.command, .shift] + syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) + syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 162141d11..ddeb3dada 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -428,8 +428,7 @@ class TerminalController: BaseTerminalController { undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in - // For redo, we close the tab again - target.closeTabImmediately() + target.closeTab(nil) } } } @@ -459,8 +458,7 @@ class TerminalController: BaseTerminalController { undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in - // For redo, we close the window again - target.closeWindowImmediately() + target.closeWindow(nil) } } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4a9dc0ea6..ba0b95212 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -553,6 +553,12 @@ extension Ghostty { case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) + case GHOSTTY_ACTION_UNDO: + return undo(app, target: target) + + case GHOSTTY_ACTION_REDO: + return redo(app, target: target) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -599,6 +605,48 @@ extension Ghostty { } } + private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canUndo else { return false } + undoManager.undo() + return true + } + + private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canRedo else { return false } + undoManager.redo() + return true + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/src/Surface.zig b/src/Surface.zig index 62a0ce549..e53613ac0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4337,6 +4337,18 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 7866db182..b4c5164c2 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -258,6 +258,13 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Undo the last action. See the "undo" keybinding for more + /// details on what can and cannot be undone. + undo, + + /// Redo the last undone action. + redo, + check_for_updates, /// Sync with: ghostty_action_tag_e @@ -307,6 +314,8 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + undo, + redo, check_for_updates, }; diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index aca230aa5..e7d966323 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void { \\ ); - @setEvalBranchQuota(3000); + @setEvalBranchQuota(5000); inline for (@typeInfo(Config).@"struct".fields) |field| { if (field.name[0] == '_') continue; @@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void { const info = @typeInfo(KeybindAction); std.debug.assert(info == .@"union"); + @setEvalBranchQuota(5000); inline for (info.@"union".fields) |field| { if (field.name[0] == '_') continue; diff --git a/src/config/Config.zig b/src/config/Config.zig index 14f394559..fdbde692d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4898,6 +4898,18 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .quit = {} }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } }, + .{ .redo = {} }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 7818fac1e..52d36c004 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -655,6 +655,35 @@ pub const Action = union(enum) { /// Only implemented on macOS. check_for_updates, + /// Undo the last undoable action for the focused surface or terminal, + /// if possible. This can undo actions such as closing tabs or + /// windows. + /// + /// Not every action in Ghostty can be undone or redone. The list + /// of actions support undo/redo is currently limited to: + /// + /// - New window, close window + /// - New tab, close tab + /// - New split, close split + /// + /// All actions are only undoable/redoable for a limited time. + /// For example, restoring a closed split can only be done for + /// some number of seconds since the split was closed. The exact + /// amount is configured with `TODO`. + /// + /// The undo/redo actions being limited ensures that there is + /// bounded memory usage over time, closed surfaces don't continue running + /// in the background indefinitely, and the keybinds become available + /// for terminal applications to use. + /// + /// Only implemented on macOS. + undo, + + /// Redo the last undoable action for the focused surface or terminal, + /// if possible. See "undo" for more details on what can and cannot + /// be undone or redone. + redo, + /// Quit Ghostty. quit, @@ -991,6 +1020,8 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_command_palette, .reset_window_size, + .undo, + .redo, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 4a918cff3..94fbf56a5 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -409,6 +409,18 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Check for updates to the application.", }}, + .undo => comptime &.{.{ + .action = .undo, + .title = "Undo", + .description = "Undo the last action.", + }}, + + .redo => comptime &.{.{ + .action = .redo, + .title = "Redo", + .description = "Redo the last undone action.", + }}, + .quit => comptime &.{.{ .action = .quit, .title = "Quit", From 3e02c0cbd5edc1fbbf842a5a603ecf907ad5a187 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:12:14 -0700 Subject: [PATCH 180/245] macos: fix an incorrect bindable write during view update --- macos/Sources/Ghostty/SurfaceView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 46d379b9c..f830da4ef 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -301,8 +301,12 @@ extension Ghostty { if let instant = focusInstant { let d = instant.duration(to: ContinuousClock.now) if (d < .milliseconds(500)) { - // Avoid this size completely. - lastSize = geoSize + // Avoid this size completely. We can't set values during + // view updates so we have to defer this to another tick. + DispatchQueue.main.async { + lastSize = geoSize + } + return true; } } From 49cc88f0d335e4f52c69bbf38e9099c000d26c7d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:21:05 -0700 Subject: [PATCH 181/245] macos: configurable undo timeout --- .../Terminal/BaseTerminalController.swift | 2 +- macos/Sources/Ghostty/Ghostty.Config.swift | 8 +++ .../Sources/Helpers/ExpiringUndoManager.swift | 3 + src/config/Config.zig | 66 ++++++++++++++++++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e34a44941..6ea56f693 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -77,7 +77,7 @@ class BaseTerminalController: NSWindowController, /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { - .seconds(5) + ghostty.config.undoTimeout } /// The undo manager for this controller is the undo manager of the window, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 3acb93c25..fcbea2a12 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -506,6 +506,14 @@ extension Ghostty { return v; } + var undoTimeout: Duration { + guard let config = self.config else { return .seconds(5) } + var v: UInt = 0 + let key = "undo-timeout" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return .milliseconds(v) + } + var autoUpdate: AutoUpdate? { guard let config = self.config else { return nil } var v: UnsafePointer? = nil diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift index 3eda56182..9a9349cf3 100644 --- a/macos/Sources/Helpers/ExpiringUndoManager.swift +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -29,6 +29,9 @@ class ExpiringUndoManager: UndoManager { expiresAfter duration: Duration, handler: @escaping (TargetType) -> Void ) { + // Ignore instantly expiring undos + guard duration.timeInterval > 0 else { return } + let expiringTarget = ExpiringTarget( target, expiresAfter: duration, diff --git a/src/config/Config.zig b/src/config/Config.zig index fdbde692d..e1d5b548e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1705,6 +1705,52 @@ keybind: Keybinds = .{}, /// window is ever created. Only implemented on Linux and macOS. @"initial-window": bool = true, +/// The duration that undo operations remain available. After this +/// time, the operation will be removed from the undo stack and +/// cannot be undone. +/// +/// The default value is 5 seconds. +/// +/// This timeout applies per operation, meaning that if you perform +/// multiple operations, each operation will have its own timeout. +/// New operations do not reset the timeout of previous operations. +/// +/// A timeout of zero will effectively disable undo operations. It is +/// not possible to set an infinite timeout, but you can set a very +/// large timeout to effectively disable the timeout (on the order of years). +/// This is highly discouraged, as it will cause the undo stack to grow +/// indefinitely, memory usage to grow unbounded, and terminal sessions +/// to never actually quit. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// This configuration is only supported on macOS. Linux doesn't +/// support undo operations at all so this configuration has no +/// effect. +@"undo-timeout": Duration = .{ .duration = 5 * std.time.ns_per_s }, + /// The position of the "quick" terminal window. To learn more about the /// quick terminal, see the documentation for the `toggle_quick_terminal` /// binding action. @@ -6583,7 +6629,7 @@ pub const Duration = struct { if (remaining.len == 0) break; // Find the longest number - const number = number: { + const number: u64 = number: { var prev_number: ?u64 = null; var prev_remaining: ?[]const u8 = null; for (1..remaining.len + 1) |index| { @@ -6597,8 +6643,17 @@ pub const Duration = struct { break :number prev_number; } orelse return error.InvalidValue; - // A number without a unit is invalid - if (remaining.len == 0) return error.InvalidValue; + // A number without a unit is invalid unless the number is + // exactly zero. In that case, the unit is unambiguous since + // its all the same. + if (remaining.len == 0) { + if (number == 0) { + value = 0; + break; + } + + return error.InvalidValue; + } // Find the longest matching unit. Needs to be the longest matching // to distinguish 'm' from 'ms'. @@ -6808,6 +6863,11 @@ test "parse duration" { try std.testing.expectEqual(unit.factor, d.duration); } + { + const d = try Duration.parseCLI("0"); + try std.testing.expectEqual(@as(u64, 0), d.duration); + } + { const d = try Duration.parseCLI("100ns"); try std.testing.expectEqual(@as(u64, 100), d.duration); From d2d38520261c1ba9dc51ec7a787b3518077f4890 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:28:48 -0700 Subject: [PATCH 182/245] macos: remove debug log --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 6e35f40d1..682efa947 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -287,8 +287,6 @@ extension Ghostty { if let surface = self.surface { ghostty_surface_free(surface) } - - Ghostty.logger.warning("WOW close") } func focusDidChange(_ focused: Bool) { From 966c4f98c7da9bb286342c385d8e28796f376d4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:39:02 -0700 Subject: [PATCH 183/245] apprt/glfw,gtk: noop undo/redo actions --- src/apprt/glfw.zig | 2 ++ src/apprt/gtk/App.zig | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index d67567aee..924737074 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -250,6 +250,8 @@ pub const App = struct { .reset_window_size, .ring_bell, .check_for_updates, + .undo, + .redo, .show_gtk_inspector, => { log.info("unimplemented action={}", .{action}); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index d69102bda..099a051a4 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -515,6 +515,8 @@ pub fn performAction( .color_change, .reset_window_size, .check_for_updates, + .undo, + .redo, => { log.warn("unimplemented action={}", .{action}); return false; From 5507ec0fc0199d3442065a54e8c737759eb5d084 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:48:23 -0700 Subject: [PATCH 184/245] macos: compile errors in CI --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 5 ++--- .../Sources/Helpers/Extensions/NSApplication+Extension.swift | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6ea56f693..129aeb1e2 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -404,8 +404,7 @@ class BaseTerminalController: NSWindowController, guard let node = surfaceTree.root?.node(view: target) else { return } closeSurfaceNode( node, - withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, - ) + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) } /// Close a surface node (which may contain splits), requesting confirmation if necessary. @@ -413,7 +412,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true, + withConfirmation: Bool = true ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } diff --git a/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift index d8e41523a..0bc79fb6a 100644 --- a/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift @@ -1,3 +1,4 @@ +import AppKit import Cocoa // MARK: Presentation Options From 3b77a16b63448eb1a1764ba82c0e945969c2d819 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 13:35:31 -0700 Subject: [PATCH 185/245] Make undo/redo app-targeted so it works with no windows --- src/App.zig | 3 +++ src/Surface.zig | 27 +++++++++++++++------------ src/input/Binding.zig | 4 ++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/App.zig b/src/App.zig index 39db2e2f9..3bbeff2c8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -446,6 +446,9 @@ pub fn performAction( .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}), .check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}), .show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}), + .undo => _ = try rt_app.performAction(.app, .undo, {}), + + .redo => _ = try rt_app.performAction(.app, .redo, {}), } } diff --git a/src/Surface.zig b/src/Surface.zig index e53613ac0..9ab7234d6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3923,6 +3923,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .parent = self }, ), + // Undo and redo both support both surface and app targeting. + // If we are triggering on a surface then we perform the + // action with the surface target. + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + else => try self.app.performAction( self.rt_app, action.scoped(.app).?, @@ -4337,18 +4352,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .undo => return try self.rt_app.performAction( - .{ .surface = self }, - .undo, - {}, - ), - - .redo => return try self.rt_app.performAction( - .{ .surface = self }, - .redo, - {}, - ), - .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 52d36c004..ca3fd9790 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -980,6 +980,8 @@ pub const Action = union(enum) { // These are app but can be special-cased in a surface context. .new_window, + .undo, + .redo, => .app, // Obviously surface actions. @@ -1020,8 +1022,6 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_command_palette, .reset_window_size, - .undo, - .redo, .crash, => .surface, From 33d128bcff2ee529359a844bde50c3aa9dfed460 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:19:05 -0700 Subject: [PATCH 186/245] macos: remove TerminalManager All logic related to TerminalController is now in TerminalController. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 - macos/Sources/App/macOS/AppDelegate.swift | 61 ++- .../Features/Services/ServiceProvider.swift | 5 +- .../Terminal/BaseTerminalController.swift | 2 +- .../Terminal/TerminalController.swift | 223 ++++++++++- .../Features/Terminal/TerminalManager.swift | 372 ------------------ .../Terminal/TerminalRestorable.swift | 6 +- 7 files changed, 268 insertions(+), 405 deletions(-) delete mode 100644 macos/Sources/Features/Terminal/TerminalManager.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 67f1784ac..7da727fbb 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -71,7 +71,6 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; }; @@ -179,7 +178,6 @@ A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = ""; }; @@ -467,7 +465,6 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, @@ -710,7 +707,6 @@ C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index db332813f..aacf8f651 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -87,9 +87,6 @@ class AppDelegate: NSObject, /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() - /// Manages our terminal windows. - let terminalManager: TerminalManager - /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() @@ -119,7 +116,6 @@ class AppDelegate: NSObject, } override init() { - terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( // Important: we must not start the updater here because we need to read our configuration // first to determine whether we're automatically checking, downloading, etc. The updater @@ -202,6 +198,16 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewWindow(_:)), + name: Ghostty.Notification.ghosttyNewWindow, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewTab(_:)), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) // Configure user notifications let actions = [ @@ -253,8 +259,8 @@ class AppDelegate: NSObject, // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + if TerminalController.all.isEmpty && derivedConfig.initialWindow { + _ = TerminalController.newWindow(ghostty) } } } @@ -339,10 +345,10 @@ class AppDelegate: NSObject, // This is possible with flag set to false if there a race where the // window is still initializing and is not visible but the user clicked // the dock icon. - guard terminalManager.windows.count == 0 else { return true } + guard TerminalController.all.isEmpty else { return true } // No visible windows, open a new one. - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) return false } @@ -358,16 +364,17 @@ class AppDelegate: NSObject, var config = Ghostty.SurfaceConfiguration() if (isDirectory.boolValue) { - // When opening a directory, create a new tab in the main window with that as the working directory. + // When opening a directory, create a new tab in the main + // window with that as the working directory. // If no windows exist, a new one will be created. config.workingDirectory = filename - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(ghostty, withBaseConfig: config) } else { // When opening a file, open a new window with that file as the command, // and its parent directory as the working directory. config.command = filename config.workingDirectory = (filename as NSString).deletingLastPathComponent - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } return true @@ -456,10 +463,6 @@ class AppDelegate: NSObject, menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) } - private func focusedSurface() -> ghostty_surface_t? { - return terminalManager.focusedSurface?.surface - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -592,6 +595,22 @@ class AppDelegate: NSObject, } } + @objc private func ghosttyNewWindow(_ notification: Notification) { + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) + } + + @objc private func ghosttyNewTab(_ notification: Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -627,7 +646,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) - terminalManager.relabelAllTabs() + TerminalController.all.forEach { $0.relabelTabs() } // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal @@ -756,8 +775,8 @@ class AppDelegate: NSObject, //MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { - for c in terminalManager.windows { - for view in c.controller.surfaceTree { + for c in TerminalController.all { + for view in c.surfaceTree { if view.uuid == uuid { return view } @@ -811,7 +830,7 @@ class AppDelegate: NSObject, } @IBAction func newWindow(_ sender: Any?) { - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -819,7 +838,7 @@ class AppDelegate: NSObject, } @IBAction func newTab(_ sender: Any?) { - terminalManager.newTab() + _ = TerminalController.newTab(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -827,7 +846,7 @@ class AppDelegate: NSObject, } @IBAction func closeAllWindows(_ sender: Any?) { - terminalManager.closeAllWindows() + TerminalController.closeAllWindows() AboutController.shared.hide() } diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index 043f5d704..f60f94211 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -32,7 +32,6 @@ class ServiceProvider: NSObject { error: AutoreleasingUnsafeMutablePointer ) { guard let delegate = NSApp.delegate as? AppDelegate else { return } - let terminalManager = delegate.terminalManager guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { error.pointee = Self.errorNoString @@ -53,10 +52,10 @@ class ServiceProvider: NSObject { switch (target) { case .window: - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) case .tab: - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 129aeb1e2..e4b42c3a1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -412,7 +412,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true + withConfirmation: Bool = true, ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index ddeb3dada..3210eda0c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: SplitTree? = nil + withSurfaceTree tree: SplitTree? = nil, + parent: NSWindow? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -137,6 +138,159 @@ class TerminalController: BaseTerminalController { syncAppearance(focusedSurface.derivedConfig) } + // MARK: Terminal Creation + + /// Returns all the available terminal controllers present in the app currently. + static var all: [TerminalController] { + return NSApplication.shared.windows.compactMap { + $0.windowController as? TerminalController + } + } + + // Keep track of the last point that our window was launched at so that new + // windows "cascade" over each other and don't just launch directly on top + // of each other. + private static var lastCascadePoint = NSPoint(x: 0, y: 0) + + // The preferred parent terminal controller. + private static var preferredParent: TerminalController? { + all.first { + $0.window?.isMainWindow ?? false + } ?? all.last + } + + /// The "new window" action. + static func newWindow( + _ ghostty: Ghostty.App, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil, + withParent explicitParent: NSWindow? = nil + ) -> TerminalController { + let c = TerminalController.init(ghostty, withBaseConfig: baseConfig) + + // Get our parent. Our parent is the one explicitly given to us, + // otherwise the focused terminal, otherwise an arbitrary one. + let parent: NSWindow? = explicitParent ?? preferredParent?.window + + if let parent { + if parent.styleMask.contains(.fullScreen) { + parent.toggleFullScreen(nil) + } else if ghostty.config.windowFullscreen { + switch (ghostty.config.windowFullscreenMode) { + case .native: + // Native has to be done immediately so that our stylemask contains + // fullscreen for the logic later in this method. + c.toggleFullscreen(mode: .native) + + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + // If we're non-native then we have to do it on a later loop + // so that the content view is setup. + DispatchQueue.main.async { + c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode) + } + } + } + } + + // We're dispatching this async because otherwise the lastCascadePoint doesn't + // take effect. Our best theory is there is some next-event-loop-tick logic + // that Cocoa is doing that we need to be after. + DispatchQueue.main.async { + // Only cascade if we aren't fullscreen. + if let window = c.window { + if (!window.styleMask.contains(.fullScreen)) { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + } + + c.showWindow(self) + } + + return c + } + + static func newTab( + _ ghostty: Ghostty.App, + from parent: NSWindow? = nil, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil + ) -> TerminalController? { + // Making sure that we're dealing with a TerminalController. If not, + // then we just create a new window. + guard let parent, + let parentController = parent.windowController as? TerminalController else { + return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) + } + + // If our parent is in non-native fullscreen, then new tabs do not work. + // See: https://github.com/mitchellh/ghostty/issues/392 + if let fullscreenStyle = parentController.fullscreenStyle, + fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: parent) + return nil + } + + // Create a new window and add it to the parent + let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) + guard let window = controller.window else { return controller } + + // If the parent is miniaturized, then macOS exhibits really strange behaviors + // so we have to bring it back out. + if (parent.isMiniaturized) { parent.deminiaturize(self) } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + // + // At the time of writing this code, the only known case this happens + // is when the "+" button is clicked in the tab bar. + if let tg = parent.tabGroup, + tg.windows.firstIndex(of: window) != nil { + tg.removeWindow(window) + } + + // Our windows start out invisible. We need to make it visible. If we + // don't do this then various features such as window blur won't work because + // the macOS APIs only work on a visible window. + controller.showWindow(self) + + // If we have the "hidden" titlebar style we want to create new + // tabs as windows instead, so just skip adding it to the parent. + if (ghostty.config.macosTitlebarStyle != "hidden") { + // Add the window to the tab group and show it. + switch ghostty.config.windowNewTabPosition { + case "end": + // If we already have a tab group and we want the new tab to open at the end, + // then we use the last window in the tab group as the parent. + if let last = parent.tabGroup?.windows.last { + last.addTabbedWindow(window, ordered: .above) + } else { + fallthrough + } + + case "current": fallthrough + default: + parent.addTabbedWindow(window, ordered: .above) + } + } + + window.makeKeyAndOrderFront(self) + + // It takes an event loop cycle until the macOS tabGroup state becomes + // consistent which causes our tab labeling to be off when the "+" button + // is used in the tab bar. This fixes that. If we can find a more robust + // solution we should do that. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + controller.relabelTabs() + } + + return controller + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -479,6 +633,44 @@ class TerminalController: BaseTerminalController { tabGroup.windows.forEach { $0.close() } } + /// Close all windows, asking for confirmation if necessary. + static func closeAllWindows() { + let needsConfirm: Bool = all.contains { + $0.surfaceTree.contains { $0.needsConfirmQuit } + } + + if (!needsConfirm) { + closeAllWindowsImmediately() + return + } + + // If we don't have a main window, we just close all windows because + // we have no window to show the modal on top of. I'm sure there's a way + // to do an app-level alert but I don't know how and this case should never + // really happen. + guard let alertWindow = preferredParent?.window else { + closeAllWindowsImmediately() + return + } + + // If we need confirmation by any, show one confirmation for all windows + let alert = NSAlert() + alert.messageText = "Close All Windows?" + alert.informativeText = "All terminal sessions will be terminated." + alert.addButton(withTitle: "Close All Windows") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: alertWindow, completionHandler: { response in + if (response == .alertFirstButtonReturn) { + closeAllWindowsImmediately() + } + }) + } + + static private func closeAllWindowsImmediately() { + all.forEach { $0.close() } + } + // MARK: Undo/Redo /// The state that we require to recreate a TerminalController from an undo. @@ -709,6 +901,35 @@ class TerminalController: BaseTerminalController { override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() + + // If we remove a window, we reset the cascade point to the key window so that + // the next window cascade's from that one. + if let focusedWindow = NSApplication.shared.keyWindow { + // If we are NOT the focused window, then we are a tabbed window. If we + // are closing a tabbed window, we want to set the cascade point to be + // the next cascade point from this window. + if focusedWindow != window { + // The cascadeTopLeft call below should NOT move the window. Starting with + // macOS 15, we found that specifically when used with the new window snapping + // features of macOS 15, this WOULD move the frame. So we keep track of the + // old frame and restore it if necessary. Issue: + // https://github.com/ghostty-org/ghostty/issues/2565 + let oldFrame = focusedWindow.frame + + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + + if focusedWindow.frame != oldFrame { + focusedWindow.setFrame(oldFrame, display: true) + } + + return + } + + // If we are the focused window, then we set the last cascade point to + // our own frame so that it shows up in the same spot. + let frame = focusedWindow.frame + Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + } } override func windowDidBecomeKey(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift deleted file mode 100644 index 050bc5563..000000000 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ /dev/null @@ -1,372 +0,0 @@ -import Cocoa -import SwiftUI -import GhosttyKit -import Combine - -/// Manages a set of terminal windows. This is effectively an array of TerminalControllers. -/// This abstraction helps manage tabs and multi-window scenarios. -class TerminalManager { - struct Window { - let controller: TerminalController - let closePublisher: AnyCancellable - } - - let ghostty: Ghostty.App - - /// The currently focused surface of the main window. - var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface } - - /// The set of windows we currently have. - var windows: [Window] = [] - - // Keep track of the last point that our window was launched at so that new - // windows "cascade" over each other and don't just launch directly on top - // of each other. - private static var lastCascadePoint = NSPoint(x: 0, y: 0) - - /// Returns the main window of the managed window stack. If there is no window - /// then an arbitrary window will be chosen. - private var mainWindow: Window? { - for window in windows { - if (window.controller.window?.isMainWindow ?? false) { - return window - } - } - - // If we have no main window, just use the last window. - return windows.last - } - - /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig - - init(_ ghostty: Ghostty.App) { - self.ghostty = ghostty - self.derivedConfig = DerivedConfig(ghostty.config) - - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onNewTab), - name: Ghostty.Notification.ghosttyNewTab, - object: nil) - center.addObserver( - self, - selector: #selector(onNewWindow), - name: Ghostty.Notification.ghosttyNewWindow, - object: nil) - center.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil) - } - - deinit { - let center = NotificationCenter.default - center.removeObserver(self) - } - - // MARK: - Window Management - - /// Create a new terminal window. - func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - let c = createWindow(withBaseConfig: base) - let window = c.window! - - // If the previous focused window was native fullscreen, the new window also - // becomes native fullscreen. - if let parent = focusedSurface?.window, - parent.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } else if derivedConfig.windowFullscreen { - switch (derivedConfig.windowFullscreenMode) { - case .native: - // Native has to be done immediately so that our stylemask contains - // fullscreen for the logic later in this method. - c.toggleFullscreen(mode: .native) - - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: - // If we're non-native then we have to do it on a later loop - // so that the content view is setup. - DispatchQueue.main.async { - c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode) - } - } - } - - // All new_window actions force our app to be active. - NSApp.activate(ignoringOtherApps: true) - - // We're dispatching this async because otherwise the lastCascadePoint doesn't - // take effect. Our best theory is there is some next-event-loop-tick logic - // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { - // Only cascade if we aren't fullscreen. - if (!window.styleMask.contains(.fullScreen)) { - Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) - } - - c.showWindow(self) - } - } - - /// Creates a new tab in the current main window. If there are no windows, a window - /// is created. - func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - // If there is no main window, just create a new window - guard let parent = mainWindow?.controller.window else { - newWindow(withBaseConfig: base) - return - } - - // Create a new window and add it to the parent - newTab(to: parent, withBaseConfig: base) - } - - private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) { - // Making sure that we're dealing with a TerminalController - guard parent.windowController is TerminalController else { return } - - // If our parent is in non-native fullscreen, then new tabs do not work. - // See: https://github.com/mitchellh/ghostty/issues/392 - if let controller = parent.windowController as? TerminalController, - let fullscreenStyle = controller.fullscreenStyle, - fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: parent) - return - } - - // Create a new window and add it to the parent - let controller = createWindow(withBaseConfig: base) - let window = controller.window! - - // If the parent is miniaturized, then macOS exhibits really strange behaviors - // so we have to bring it back out. - if (parent.isMiniaturized) { parent.deminiaturize(self) } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - // - // At the time of writing this code, the only known case this happens - // is when the "+" button is clicked in the tab bar. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil { - tg.removeWindow(window) - } - - // Our windows start out invisible. We need to make it visible. If we - // don't do this then various features such as window blur won't work because - // the macOS APIs only work on a visible window. - controller.showWindow(self) - - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (derivedConfig.macosTitlebarStyle != "hidden") { - // Add the window to the tab group and show it. - switch derivedConfig.windowNewTabPosition { - case "end": - // If we already have a tab group and we want the new tab to open at the end, - // then we use the last window in the tab group as the parent. - if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) - } else { - fallthrough - } - case "current": fallthrough - default: - parent.addTabbedWindow(window, ordered: .above) - - } - } - - window.makeKeyAndOrderFront(self) - - // It takes an event loop cycle until the macOS tabGroup state becomes - // consistent which causes our tab labeling to be off when the "+" button - // is used in the tab bar. This fixes that. If we can find a more robust - // solution we should do that. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() } - } - - /// Creates a window controller, adds it to our managed list, and returns it. - func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: SplitTree? = nil) -> TerminalController { - // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) - - // Create a listener for when the window is closed so we can remove it. - let pubClose = NotificationCenter.default.publisher( - for: NSWindow.willCloseNotification, - object: c.window! - ).sink { notification in - guard let window = notification.object as? NSWindow else { return } - guard let c = window.windowController as? TerminalController else { return } - self.removeWindow(c) - } - - // Keep track of every window we manage - windows.append(Window( - controller: c, - closePublisher: pubClose - )) - - return c - } - - func removeWindow(_ controller: TerminalController) { - // Remove it from our managed set - guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return } - let w = self.windows[idx] - self.windows.remove(at: idx) - - // Ensure any publishers we have are cancelled - w.closePublisher.cancel() - - // If we remove a window, we reset the cascade point to the key window so that - // the next window cascade's from that one. - if let focusedWindow = NSApplication.shared.keyWindow { - // If we are NOT the focused window, then we are a tabbed window. If we - // are closing a tabbed window, we want to set the cascade point to be - // the next cascade point from this window. - if focusedWindow != controller.window { - // The cascadeTopLeft call below should NOT move the window. Starting with - // macOS 15, we found that specifically when used with the new window snapping - // features of macOS 15, this WOULD move the frame. So we keep track of the - // old frame and restore it if necessary. Issue: - // https://github.com/ghostty-org/ghostty/issues/2565 - let oldFrame = focusedWindow.frame - - Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) - - if focusedWindow.frame != oldFrame { - focusedWindow.setFrame(oldFrame, display: true) - } - - return - } - - // If we are the focused window, then we set the last cascade point to - // our own frame so that it shows up in the same spot. - let frame = focusedWindow.frame - Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - } - - // I don't think we strictly have to do this but if a window is - // closed I want to make sure that the app state is invalided so - // we don't reopen closed windows. - NSApplication.shared.invalidateRestorableState() - } - - /// Close all windows, asking for confirmation if necessary. - func closeAllWindows() { - var needsConfirm: Bool = false - for w in self.windows { - if w.controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) { - needsConfirm = true - break - } - } - - if (!needsConfirm) { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = mainWindow?.controller.window else { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we need confirmation by any, show one confirmation for all windows - let alert = NSAlert() - alert.messageText = "Close All Windows?" - alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close All Windows") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for w in self.windows { - w.controller.close() - } - } - }) - } - - /// Relabels all the tabs with the proper keyboard shortcut. - func relabelAllTabs() { - for w in windows { - w.controller.relabelTabs() - } - } - - // MARK: - Notifications - - @objc private func onNewWindow(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - self.newWindow(withBaseConfig: config) - } - - @objc private func onNewTab(notification: SwiftUI.Notification) { - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard let window = surfaceView.window else { return } - - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - - self.newTab(to: window, withBaseConfig: config) - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - // We only care if the configuration is a global configuration, not a - // surface-specific one. - guard notification.object == nil else { return } - - // Get our managed configuration object out - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - - // Update our derived config - self.derivedConfig = DerivedConfig(config) - } - - private struct DerivedConfig { - let windowFullscreen: Bool - let windowFullscreenMode: FullscreenMode - let macosTitlebarStyle: String - let windowNewTabPosition: String - - init() { - self.windowFullscreen = false - self.windowFullscreenMode = .native - self.macosTitlebarStyle = "transparent" - self.windowNewTabPosition = "" - } - - init(_ config: Ghostty.Config) { - self.windowFullscreen = config.windowFullscreen - self.windowFullscreenMode = config.windowFullscreenMode - self.macosTitlebarStyle = config.macosTitlebarStyle - self.windowNewTabPosition = config.windowNewTabPosition - } - } -} diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 5229dc46e..9d9b7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -83,9 +83,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow( - withSurfaceTree: state.surfaceTree - ) + let c = TerminalController.init( + appDelegate.ghostty, + withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return From 797c10af37aa71c0e36c569d6d47faa761f1614b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:50:30 -0700 Subject: [PATCH 187/245] macos: undo new window --- .../Terminal/TerminalController.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 3210eda0c..f90490c3f 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -205,6 +205,27 @@ class TerminalController: BaseTerminalController { c.showWindow(self) } + // Setup our undo + if let undoManager = c.undoManager { + undoManager.setActionName("New Window") + undoManager.registerUndo( + withTarget: c, + expiresAfter: c.undoExpiration) { target in + // Close the window when undoing + target.closeWindow(nil) + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration) { ghostty in + _ = TerminalController.newWindow( + ghostty, + withBaseConfig: baseConfig, + withParent: explicitParent) + } + } + } + return c } From 636b1fff8a4d0ca43ef1794791ff582dc6a6e111 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:56:17 -0700 Subject: [PATCH 188/245] macos: initial window shouldn't support undo --- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index aacf8f651..013e89f58 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -260,7 +260,9 @@ class AppDelegate: NSObject, // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state if TerminalController.all.isEmpty && derivedConfig.initialWindow { + undoManager.disableUndoRegistration() _ = TerminalController.newWindow(ghostty) + undoManager.enableUndoRegistration() } } } From d92db73f25110bd5de145ce50cb99a9925cbc8f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:59:22 -0700 Subject: [PATCH 189/245] macos: undo new tab --- .../Terminal/TerminalController.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f90490c3f..7aa8d5285 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -309,6 +309,27 @@ class TerminalController: BaseTerminalController { controller.relabelTabs() } + // Setup our undo + if let undoManager = parentController.undoManager { + undoManager.setActionName("New Tab") + undoManager.registerUndo( + withTarget: controller, + expiresAfter: controller.undoExpiration) { target in + // Close the tab when undoing + target.closeTab(nil) + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration) { ghostty in + _ = TerminalController.newTab( + ghostty, + from: parent, + withBaseConfig: baseConfig) + } + } + } + return controller } From aeede903f50ce6224c94efe1cdc338b6b8ac9f56 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 16:03:20 -0700 Subject: [PATCH 190/245] macos: undo close all windows --- macos/Sources/Features/Terminal/TerminalController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7aa8d5285..907109e1c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -710,7 +710,11 @@ class TerminalController: BaseTerminalController { } static private func closeAllWindowsImmediately() { - all.forEach { $0.close() } + let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + undoManager?.beginUndoGrouping() + all.forEach { $0.closeWindowImmediately() } + undoManager?.setActionName("Close All Windows") + undoManager?.endUndoGrouping() } // MARK: Undo/Redo From 396e53244d998a7b6097f256a0af1daccf9e62d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 06:57:11 -0700 Subject: [PATCH 191/245] config: add super+shift+t as a default undo too to mimic browsers --- src/config/Config.zig | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index e1d5b548e..2df66ba45 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4944,6 +4944,25 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .quit = {} }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, + .{ .clear_screen = {} }, + .{ .performable = true }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, + .{ .select_all = {} }, + ); + + // Undo/redo + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true, .shift = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, @@ -4956,17 +4975,6 @@ pub const Keybinds = struct { .{ .redo = {} }, .{ .performable = true }, ); - try self.set.putFlags( - alloc, - .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, - .{ .clear_screen = {} }, - .{ .performable = true }, - ); - try self.set.put( - alloc, - .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, - .{ .select_all = {} }, - ); // Viewport scrolling try self.set.put( From b234cb20140fc2287799496fe7b4ad13d5deb94a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 07:01:08 -0700 Subject: [PATCH 192/245] macos: only process reopen if already activated --- macos/Sources/App/macOS/AppDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 013e89f58..e5b35037e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -349,6 +349,11 @@ class AppDelegate: NSObject, // the dock icon. guard TerminalController.all.isEmpty else { return true } + // If the application isn't active yet then we don't want to process + // this because we're not ready. This happens sometimes in Xcode runs + // but I haven't seen it happen in releases. I'm unsure why. + guard applicationHasBecomeActive else { return true } + // No visible windows, open a new one. _ = TerminalController.newWindow(ghostty) return false From 973a2afdde103c302c690f2e41e16ab53a4a86fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 07:11:30 -0700 Subject: [PATCH 193/245] macos: make sure we're not registering unnecessary undos --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../Terminal/BaseTerminalController.swift | 103 ++++++++---------- .../Terminal/TerminalController.swift | 32 ++++-- .../Sources/Helpers/ExpiringUndoManager.swift | 10 +- .../Extensions/UndoManager+Extension.swift | 20 ++++ 5 files changed, 102 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/UndoManager+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 7da727fbb..9686dcbd1 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -171,6 +172,7 @@ A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = ""; }; + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -447,6 +449,7 @@ C1F26EA62B738B9900404083 /* NSView+Extension.swift */, A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, ); path = Extensions; @@ -683,6 +686,7 @@ A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e4b42c3a1..06cecf651 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -260,8 +260,8 @@ class BaseTerminalController: NSWindowController, self.alert = alert } - // MARK: Focus Management - + // MARK: Split Tree Management + /// Find the next surface to focus when a node is being closed. /// Goes to previous split unless we're the leftmost leaf, then goes to next. private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { @@ -282,45 +282,63 @@ class BaseTerminalController: NSWindowController, /// /// This does no confirmation and assumes confirmation is already done. private func removeSurfaceNode(_ node: SplitTree.Node) { - let nextTarget = findNextFocusTargetAfterClosing(node: node) - let oldFocused = focusedSurface - let focused = node.contains { $0 == focusedSurface } - - // Keep track of the old tree for undo management. - let oldTree = surfaceTree - - // Remove the node from the tree - surfaceTree = surfaceTree.remove(node) - // Move focus if the closed surface was focused and we have a next target - if let nextTarget, focused { + let nextFocus: Ghostty.SurfaceView? = if node.contains( + where: { $0 == focusedSurface } + ) { + findNextFocusTargetAfterClosing(node: node) + } else { + nil + } + + replaceSurfaceTree( + surfaceTree.remove(node), + moveFocusTo: nextFocus, + moveFocusFrom: focusedSurface, + undoAction: "Close Terminal" + ) + } + + private func replaceSurfaceTree( + _ newTree: SplitTree, + moveFocusTo newView: Ghostty.SurfaceView? = nil, + moveFocusFrom oldView: Ghostty.SurfaceView? = nil, + undoAction: String? = nil + ) { + // Setup our new split tree + let oldTree = surfaceTree + surfaceTree = newTree + if let newView { DispatchQueue.main.async { - Ghostty.moveFocus(to: nextTarget, from: oldFocused) + Ghostty.moveFocus(to: newView, from: oldView) } } // Setup our undo if let undoManager { - undoManager.setActionName("Close Terminal") + if let undoAction { + undoManager.setActionName(undoAction) + } undoManager.registerUndo( withTarget: self, - expiresAfter: undoExpiration) { target in + expiresAfter: undoExpiration + ) { target in target.surfaceTree = oldTree - if let oldFocused { + if let oldView { DispatchQueue.main.async { - Ghostty.moveFocus(to: oldFocused, from: target.focusedSurface) + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) } } undoManager.registerUndo( withTarget: target, - expiresAfter: target.undoExpiration) { target in - target.closeSurfaceNode( - node, - withConfirmation: node.contains { - $0.needsConfirmQuit - } - ) + expiresAfter: target.undoExpiration + ) { target in + target.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: target.focusedSurface, + undoAction: undoAction) } } } @@ -478,36 +496,11 @@ class BaseTerminalController: NSWindowController, return } - // Keep track of the old tree for undo - let oldTree = surfaceTree - - // Setup our new split tree - surfaceTree = newTree - DispatchQueue.main.async { - Ghostty.moveFocus(to: newView, from: oldView) - } - - // Setup our undo - if let undoManager { - undoManager.setActionName("New Split") - undoManager.registerUndo( - withTarget: self, - expiresAfter: undoExpiration) { target in - target.surfaceTree = oldTree - DispatchQueue.main.async { - Ghostty.moveFocus(to: oldView, from: target.focusedSurface) - } - - undoManager.registerUndo( - withTarget: target, - expiresAfter: target.undoExpiration) { target in - target.surfaceTree = newTree - DispatchQueue.main.async { - Ghostty.moveFocus(to: newView, from: target.focusedSurface) - } - } - } - } + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Split") } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 907109e1c..244f8720d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -210,14 +210,18 @@ class TerminalController: BaseTerminalController { undoManager.setActionName("New Window") undoManager.registerUndo( withTarget: c, - expiresAfter: c.undoExpiration) { target in + expiresAfter: c.undoExpiration + ) { target in // Close the window when undoing - target.closeWindow(nil) + undoManager.disableUndoRegistration { + target.closeWindow(nil) + } // Register redo action undoManager.registerUndo( withTarget: ghostty, - expiresAfter: target.undoExpiration) { ghostty in + expiresAfter: target.undoExpiration + ) { ghostty in _ = TerminalController.newWindow( ghostty, withBaseConfig: baseConfig, @@ -314,14 +318,18 @@ class TerminalController: BaseTerminalController { undoManager.setActionName("New Tab") undoManager.registerUndo( withTarget: controller, - expiresAfter: controller.undoExpiration) { target in + expiresAfter: controller.undoExpiration + ) { target in // Close the tab when undoing - target.closeTab(nil) - + undoManager.disableUndoRegistration { + target.closeTab(nil) + } + // Register redo action undoManager.registerUndo( withTarget: ghostty, - expiresAfter: target.undoExpiration) { ghostty in + expiresAfter: target.undoExpiration + ) { ghostty in _ = TerminalController.newTab( ghostty, from: parent, @@ -617,14 +625,16 @@ class TerminalController: BaseTerminalController { undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, - expiresAfter: undoExpiration) { ghostty in + expiresAfter: undoExpiration + ) { ghostty in let newController = TerminalController(ghostty, with: undoState) // Register redo action undoManager.registerUndo( withTarget: newController, - expiresAfter: newController.undoExpiration) { target in - target.closeTab(nil) + expiresAfter: newController.undoExpiration + ) { target in + target.closeTabImmediately() } } } @@ -654,7 +664,7 @@ class TerminalController: BaseTerminalController { undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in - target.closeWindow(nil) + target.closeWindowImmediately() } } } diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift index 9a9349cf3..5fde0e870 100644 --- a/macos/Sources/Helpers/ExpiringUndoManager.swift +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -32,6 +32,11 @@ class ExpiringUndoManager: UndoManager { // Ignore instantly expiring undos guard duration.timeInterval > 0 else { return } + // Ignore when undo registration is disabled. UndoManager still lets + // registration happen then cancels later but I was seeing some + // weird behavior with this so let's just guard on it. + guard self.isUndoRegistrationEnabled else { return } + let expiringTarget = ExpiringTarget( target, expiresAfter: duration, @@ -64,7 +69,10 @@ class ExpiringUndoManager: UndoManager { // Call super to handle standard removal super.removeAllActions(withTarget: target) - if !(target is ExpiringTarget) { + // If the target is an expiring target, remove it. + if let expiring = target as? ExpiringTarget { + expiringTargets.remove(expiring) + } else { // Find and remove any ExpiringTarget instances that wrap this target. expiringTargets .filter { $0.target == nil || $0.target === (target as AnyObject) } diff --git a/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift new file mode 100644 index 000000000..6c7c1e9f1 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift @@ -0,0 +1,20 @@ +import Foundation + +extension UndoManager { + /// A Boolean value that indicates whether the undo manager is currently performing + /// either an undo or redo operation. + var isUndoingOrRedoing: Bool { + isUndoing || isRedoing + } + + /// Temporarily disables undo registration while executing the provided handler. + /// + /// This method provides a convenient way to perform operations without recording them + /// in the undo stack. It ensures that undo registration is properly re-enabled even + /// if the handler throws an error. + func disableUndoRegistration(handler: () -> Void) { + disableUndoRegistration() + handler() + enableUndoRegistration() + } +} From 20744f0482e1369039b3b565bd782a4bad786e8d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 12:22:37 -0700 Subject: [PATCH 194/245] macos: fix some CI build issues --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 06cecf651..594a58056 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -430,7 +430,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true, + withConfirmation: Bool = true ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } From 6e77a5a6ca05c1416a1c19c5c61b76566574ea71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 13:07:05 -0700 Subject: [PATCH 195/245] macos: address quick terminal basic functionality with new API --- include/ghostty.h | 1 + macos/Sources/App/macOS/AppDelegate.swift | 4 ++ .../QuickTerminalController.swift | 58 +++++++++++++++---- .../Sources/Ghostty/SurfaceView_AppKit.swift | 6 ++ src/apprt/embedded.zig | 5 ++ 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 95bd58cd7..9f17d0b97 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -786,6 +786,7 @@ ghostty_app_t ghostty_surface_app(ghostty_surface_t); ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); +bool ghostty_surface_process_exited(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_draw(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e5b35037e..7fb52a025 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -612,6 +612,10 @@ class AppDelegate: NSObject, guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard let window = surfaceView.window else { return } + // We only want to listen to new tabs if the focused parent is + // a regular terminal controller. + guard window.windowController is TerminalController else { return } + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] let config = configAny as? Ghostty.SurfaceConfiguration diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8c86c2531..ce5f07616 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -61,6 +61,12 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(closeWindow(_:)), + name: .ghosttyCloseWindow, + object: nil + ) center.addObserver( self, selector: #selector(onNewTab), @@ -198,16 +204,38 @@ class QuickTerminalController: BaseTerminalController { // If our surface tree is nil then we animate the window out. if (to.isEmpty) { - // Save the current window frame before animating out. This preserves - // the user's preferred window size and position for when the quick - // terminal is reactivated with a new surface. Without this, SwiftUI - // would reset the window to its minimum content size. - lastClosedFrame = window?.frame - animateOut() } } + override func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // If this isn't a final leaf then we're dealing with a split closure + guard case .leaf(let surface) = node else { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // If its the root, we check if the process exited. If it did, + // then we do empty the tree. + if surface.processExited { + surfaceTree = .init() + return + } + + // If its the root then we just animate out. We never actually allow + // the surface to fully close. + animateOut() + } + // MARK: Methods func toggle() { @@ -252,12 +280,6 @@ class QuickTerminalController: BaseTerminalController { let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) surfaceTree = SplitTree(view: view) focusedSurface = view - - // Restore our previous frame if we have one - if let lastClosedFrame { - window.setFrame(lastClosedFrame, display: false) - self.lastClosedFrame = nil - } } // Animate the window in @@ -283,6 +305,12 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } + // Restore our previous frame if we have one + if let lastClosedFrame { + window.setFrame(lastClosedFrame, display: false) + self.lastClosedFrame = nil + } + // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -393,6 +421,12 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + lastClosedFrame = window.frame + // If we hid the dock then we unhide it. hiddenDock = nil diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 682efa947..ea9a8c61b 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -92,6 +92,12 @@ extension Ghostty { return ghostty_surface_needs_confirm_quit(surface) } + // Retruns true if the process in this surface has exited. + var processExited: Bool { + guard let surface = self.surface else { return true } + return ghostty_surface_process_exited(surface) + } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 67aeeaf7c..5334c8ecd 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1359,6 +1359,11 @@ pub const CAPI = struct { return surface.core_surface.needsConfirmQuit(); } + /// Returns true if the surface process has exited. + export fn ghostty_surface_process_exited(surface: *Surface) bool { + return surface.core_surface.child_exited; + } + /// Returns true if the surface has a selection. export fn ghostty_surface_has_selection(surface: *Surface) bool { return surface.core_surface.hasSelection(); From 6f6d493763f9fb77c9105578e6a748d014390b73 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 13:13:57 -0700 Subject: [PATCH 196/245] macos: show quick terminal on undo/redo --- .../QuickTerminal/QuickTerminalController.swift | 14 ++++++++++++-- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index ce5f07616..28dea9579 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -202,9 +202,19 @@ class QuickTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we animate the window out. - if (to.isEmpty) { + // If our surface tree is nil then we animate the window out. We + // defer reinitializing the tree to save some memory here. + if to.isEmpty { animateOut() + return + } + + // If we're not empty (e.g. this isn't the first set) and we're + // not visible, then we animate in. This allows us to show the quick + // terminal when things such as undo/redo are done. + if !from.isEmpty && !visible { + animateIn() + return } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index ea9a8c61b..e4f6f507c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -92,7 +92,7 @@ extension Ghostty { return ghostty_surface_needs_confirm_quit(surface) } - // Retruns true if the process in this surface has exited. + // Returns true if the process in this surface has exited. var processExited: Bool { guard let surface = self.surface else { return true } return ghostty_surface_process_exited(surface) From ba15da47229ff5859b1be939b40b28f4cd218ace Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 8 Jun 2025 01:03:12 +0200 Subject: [PATCH 197/245] input: parse binds containing equal signs correctly Since the W3C rewrite we're able to specify codepoints like `+` directly in the config format who otherwise have special meanings. Turns out we forgot to do the same for `=`. --- src/input/Binding.zig | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e5d434265..757c4ff24 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -63,9 +63,11 @@ pub const Parser = struct { const flags, const start_idx = try parseFlags(raw_input); const input = raw_input[start_idx..]; - // Find the first = which splits are mapping into the trigger + // Find the last = which splits are mapping into the trigger // and action, respectively. - const eql_idx = std.mem.indexOf(u8, input, "=") orelse return Error.InvalidFormat; + // We use the last = because the keybind itself could contain + // raw equal signs (for the = codepoint) + const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat; // Sequence iterator goes up to the equal, action is after. We can // parse the action now. @@ -2050,6 +2052,32 @@ test "parse: plus sign" { try testing.expectError(Error.InvalidFormat, parseSingle("++=ignore")); } +test "parse: equals sign" { + const testing = std.testing; + + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .{ .unicode = '=' } }, + .action = .ignore, + }, + try parseSingle("==ignore"), + ); + + // Modifier + try testing.expectEqual( + Binding{ + .trigger = .{ + .key = .{ .unicode = '=' }, + .mods = .{ .ctrl = true }, + }, + .action = .ignore, + }, + try parseSingle("ctrl+==ignore"), + ); + + try testing.expectError(Error.InvalidFormat, parseSingle("=ignore")); +} + // For Ghostty 1.2+ we changed our key names to match the W3C and removed // `physical:`. This tests the backwards compatibility with the old format. // Note that our backwards compatibility isn't 100% perfect since triggers From 3b33813071650a4a0ce0a7c9e5e3275d1767bfc5 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 8 Jun 2025 00:14:39 +0000 Subject: [PATCH 198/245] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 3c6ed95ed..fa071dbfe 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz", - .hash = "N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz", + .hash = "N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index b1d919f3a..ee2f14508 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj": { + "N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz", - "hash": "sha256-2AsOCV9RymfDbhFFRdNVE+GYCAmE713tM27TBPKxAW0=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz", + "hash": "sha256-TcrTZUCVetvAFGX0fBqg3zlG90flljScNr/OYR/MJ5Y=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index ce4a656c7..e28a2a0dd 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj"; + name = "N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz"; - hash = "sha256-2AsOCV9RymfDbhFFRdNVE+GYCAmE713tM27TBPKxAW0="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz"; + hash = "sha256-TcrTZUCVetvAFGX0fBqg3zlG90flljScNr/OYR/MJ5Y="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index cb8195752..3335b9574 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index d56e6d121..fb032fe82 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/273a780bcd7e8a514d49ff42612b6856601cc052.tar.gz", - "dest": "vendor/p/N-V-__8AAIgLXgT76kRaZJzBE-1ZTXqaSx2jjIvPpIsnL2Gj", - "sha256": "d80b0e095f51ca67c36e114545d35513e198080984ef5ded336ed304f2b1016d" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/10f216f54a32cee6f1f2e3aa01812650a209c16b.tar.gz", + "dest": "vendor/p/N-V-__8AAMJWXQSVe0xzU_4UVRTDVERqnNpQU4wfS0FRGRRj", + "sha256": "4dcad36540957adbc01465f47c1aa0df3946f747e596349c36bfce611fcc2796" }, { "type": "archive", From ec043e13866cef34d1835930a0512e1a42de5736 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 07:00:49 -0700 Subject: [PATCH 199/245] macos: red traffic light should be undoable --- macos/Sources/Features/Terminal/TerminalController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 244f8720d..7a241d866 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -681,7 +681,6 @@ class TerminalController: BaseTerminalController { return } - tabGroup.windows.forEach { $0.close() } } @@ -954,6 +953,13 @@ class TerminalController: BaseTerminalController { //MARK: - NSWindowDelegate + override func windowShouldClose(_ sender: NSWindow) -> Bool { + closeWindow(sender) + + // We will always explicitly close the window using the above + return false + } + override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() From 3de3f48faf830fe1326f44b08fb9f27fa65cefcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 07:29:19 -0700 Subject: [PATCH 200/245] macos: fix undo/redo for closing windows with multiple tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When closing a window that contains multiple tabs, the undo operation now properly restores all tabs as a single tabbed window rather than just restoring the active tab. The implementation: - Collects undo states from all windows in the tab group before closing - Sorts them by their original tab index to preserve order - Clears tab group references to avoid referencing garbage collected objects - Restores all windows and re-adds them as tabs to the first window - Tracks and restores which tab was focused (or focuses the last tab if none were) AI prompts that generated this commit are below. Each separate prompt is separated by a blank line, so this session was made up with many prompts in a back-and-forth conversation. > We need to update the undo/redo implementation in > @macos/Sources/Features/Terminal/TerminalController.swift `closeWindowImmediately` > to handle the case that multiple windows in a tab group are closed all at once, > and to restore them as a tabbed window. To do this, I think we should collect > all the `undoStates`, sort them by `tabIndex` (null at the end), and then on j > restore, restore them one at a time but add them back to the same tabGroup. We > can't use the tab group in the `undoState` because it will be garbage collected > by then. To be sure, we should just set it to nil. I should note at this point that the feature already worked, but the code quality and organization wasn't up to my standards. If someone using AI were just trying to make something work, they might be done at this point. I do think this is the biggest gap I worry about with AI-assisted development: bridging between the "it works" stage at a junior quality and the "it works and is maintainable" stage at a senior quality. I suspect this will be a balance of LLMs getting better but also senior code reviewers remaining highly involved in the process. > Let's extract all the work you just did into a dedicated private method > called `registerUndoForCloseWindow` Manual: made some tweaks to comments, moved some lines around, didn’t change any logic. > I think we can pull the tabIndex directly from the undoState instead of > storing it in a tuple. > Instead of `var undoStates`, I think we can create a `let undoStates` and > build and filter and sort them all in a chain of functional mappings. > Okay, looking at your logic for restoration, the `var firstController` and > conditionals are littly messy. Can you make your own pass at cleaning those > up and I'll review and provide more specific guidance after. > Excellent. Perfect. The last thing we're missing is restoring the proper > focused window of the tab group. We should store that and make sure the > proper window is made key. If no windows were key, then we should make the > last one key. > Excellent. Any more cleanups or comments you'd recommend in the places you > changed? Notes on the last one: it gave me a bunch of suggestions, I rejected most but did accept some. > Can you write me a commit message summarizing the changes? It wrote me a part of the commit message you're reading now, but I always manually tweak the commit message and add my own flair. --- .../Terminal/TerminalController.swift | 140 ++++++++++++++---- 1 file changed, 112 insertions(+), 28 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7a241d866..fc262686b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -647,41 +647,125 @@ class TerminalController: BaseTerminalController { private func closeWindowImmediately() { guard let window = window else { return } - // Regardless of tabs vs no tabs, what we want to do here is keep - // track of the window frame to restore, the surface tree, and the - // the focused surface. We want to restore that with undo even - // if we end up closing. - if let undoManager, let undoState { - // Register undo action to restore the window - undoManager.setActionName("Close Window") - undoManager.registerUndo( - withTarget: ghostty, - expiresAfter: undoExpiration) { ghostty in - // Restore the undo state - let newController = TerminalController(ghostty, with: undoState) + // Register undo for this close operation + registerUndoForCloseWindow() - // Register redo action + // Close the window(s) + if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { + tabGroup.windows.forEach { $0.close() } + } else { + window.close() + } + } + + /// Registers undo for closing window(s), handling both single windows and tab groups. + private func registerUndoForCloseWindow() { + guard let undoManager else { return } + guard let window else { return } + + // If we don't have a tab group or we don't have multiple tabs, then + // do a normal single window close. + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + // No tabs, just save this window's state + if let undoState { + // Register undo action to restore the window + undoManager.setActionName("Close Window") undoManager.registerUndo( - withTarget: newController, - expiresAfter: newController.undoExpiration) { target in - target.closeWindowImmediately() + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + // Restore the undo state + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + target.closeWindowImmediately() + } + } + } + + return + } + + // Multiple windows in tab group - collect all undo states in sorted order + // by tab ordering. Also track which window was key. + let undoStates = tabGroup.windows + .compactMap { tabWindow -> UndoState? in + guard let controller = tabWindow.windowController as? TerminalController, + var undoState = controller.undoState else { return nil } + // Clear the tab group reference since it is unneeded. It should be + // garbage collected but we want to be extra sure we don't try to + // restore into it because we're going to recreate it. + undoState.tabGroup = nil + return undoState + } + .sorted { (lhs, rhs) in + switch (lhs.tabIndex, rhs.tabIndex) { + case let (l?, r?): return l < r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return true } } + + // Find the index of the key window in our sorted states. This is a bit verbose + // but we only need this for this style of undo so we don't want to add it to + // UndoState. + let keyWindowIndex: Int? + if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }), + let keyController = keyWindow.windowController as? TerminalController, + let keyUndoState = keyController.undoState { + keyWindowIndex = undoStates.firstIndex { + $0.tabIndex == keyUndoState.tabIndex } + } else { + keyWindowIndex = nil } - guard let tabGroup = window.tabGroup else { - // No tabs, no tab group, just perform a normal close. - window.close() - return - } + // Register undo action to restore all windows + guard !undoStates.isEmpty else { return } - // If have one window then we just do a normal close - if tabGroup.windows.count == 1 { - window.close() - return - } + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + // Restore all windows in the tab group + let controllers = undoStates.map { undoState in + TerminalController(ghostty, with: undoState) + } + + // The first controller becomes the parent window for all tabs. + // If we don't have a first controller (shouldn't be possible?) + // then we can't restore tabs. + guard let firstController = controllers.first else { return } + + // Add all subsequent controllers as tabs to the first window + for controller in controllers.dropFirst() { + controller.showWindow(nil) + if let firstWindow = firstController.window, + let newWindow = controller.window { + firstWindow.addTabbedWindow(newWindow, ordered: .above) + } + } + + // Make the appropriate window key. If we had a key window, restore it. + // Otherwise, make the last window key. + if let keyWindowIndex, keyWindowIndex < controllers.count { + controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil) + } else { + controllers.last?.window?.makeKeyAndOrderFront(nil) + } - tabGroup.windows.forEach { $0.close() } + // Register redo action on the first controller + undoManager.registerUndo( + withTarget: firstController, + expiresAfter: firstController.undoExpiration + ) { target in + target.closeWindowImmediately() + } + } } /// Close all windows, asking for confirmation if necessary. @@ -734,7 +818,7 @@ class TerminalController: BaseTerminalController { let surfaceTree: SplitTree let focusedSurface: UUID? let tabIndex: Int? - private(set) weak var tabGroup: NSWindowTabGroup? + weak var tabGroup: NSWindowTabGroup? } convenience init(_ ghostty: Ghostty.App, From 26e1dd8f8e876bfc0b797c5968becf7fd565c319 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 12:23:08 -0700 Subject: [PATCH 201/245] macos: clear out the surface trees to prevent repeat undo see the comment --- .../Features/Terminal/TerminalController.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index fc262686b..c9f8ef216 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -647,12 +647,19 @@ class TerminalController: BaseTerminalController { private func closeWindowImmediately() { guard let window = window else { return } - // Register undo for this close operation registerUndoForCloseWindow() - // Close the window(s) if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { - tabGroup.windows.forEach { $0.close() } + tabGroup.windows.forEach { window in + // Clear out the surfacetree to ensure there is no undo state. + // This prevents unnecessary undos registered since AppKit may + // process them on later ticks so we can't just disable undo registration. + if let controller = window.windowController as? TerminalController { + controller.surfaceTree = .init() + } + + window.close() + } } else { window.close() } @@ -660,7 +667,7 @@ class TerminalController: BaseTerminalController { /// Registers undo for closing window(s), handling both single windows and tab groups. private func registerUndoForCloseWindow() { - guard let undoManager else { return } + guard let undoManager, undoManager.isUndoRegistrationEnabled else { return } guard let window else { return } // If we don't have a tab group or we don't have multiple tabs, then @@ -859,6 +866,7 @@ class TerminalController: BaseTerminalController { /// The current undo state for this controller var undoState: UndoState? { guard let window else { return nil } + guard !surfaceTree.isEmpty else { return nil } return .init( frame: window.frame, surfaceTree: surfaceTree, From e4cd90b8a0cee2b704a8466e8f9b915c2ef30514 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 19:57:38 -0700 Subject: [PATCH 202/245] macos: set explicit identity for split tree view based on structure Fixes #7546 SwiftUI uses type and structure to identify views, which can lead to issues with tree like structures where the shape and type is the same but the content changes. This was causing #7546. To fix this, we need to add explicit identity to the split tree view so that SwiftUI can differentiate when it needs to redraw the view. We don't want to blindly add Hashable to SplitTree because we don't want to take into account all the fields. Instead, we add an explicit "structural identity" to the SplitTreeView that can be used by SwiftUI. --- macos/Sources/Features/Splits/SplitTree.swift | 145 ++++++++++++++++++ .../Splits/TerminalSplitTreeView.swift | 5 + 2 files changed, 150 insertions(+) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 394cd1089..1c4be7dd6 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1116,3 +1116,148 @@ extension SplitTree: Collection { return i + 1 } } + +// MARK: Structural Identity + +extension SplitTree.Node { + /// Returns a hashable representation that captures this node's structural identity. + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + /// A hashable representation of a node that captures its structural identity. + /// + /// This type provides a way to track changes to a node's structure in SwiftUI + /// by implementing `Hashable` based on: + /// - The node's hierarchical structure (splits and their directions) + /// - The identity of view instances in leaf nodes (using object identity) + /// - The split directions (but not ratios, as those may change slightly) + /// + /// This is useful for SwiftUI's `id()` modifier to detect when a node's structure + /// has changed, triggering appropriate view updates while preserving view identity + /// for unchanged portions of the tree. + struct StructuralIdentity: Hashable { + private let node: SplitTree.Node + + init(_ node: SplitTree.Node) { + self.node = node + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.node.isStructurallyEqual(to: rhs.node) + } + + func hash(into hasher: inout Hasher) { + node.hashStructure(into: &hasher) + } + } + + /// Checks if this node is structurally equal to another node. + /// Two nodes are structurally equal if they have the same tree structure + /// and the same views (by identity) in the same positions. + fileprivate func isStructurallyEqual(to other: Node) -> Bool { + switch (self, other) { + case let (.leaf(view1), .leaf(view2)): + // Views must be the same instance + return view1 === view2 + + case let (.split(split1), .split(split2)): + // Splits must have same direction and structurally equal children + // Note: We intentionally don't compare ratios as they may change slightly + return split1.direction == split2.direction && + split1.left.isStructurallyEqual(to: split2.left) && + split1.right.isStructurallyEqual(to: split2.right) + + default: + // Different node types + return false + } + } + + /// Hash keys for structural identity + private enum HashKey: UInt8 { + case leaf = 0 + case split = 1 + } + + /// Hashes the structural identity of this node. + /// Includes the tree structure and view identities in the hash. + fileprivate func hashStructure(into hasher: inout Hasher) { + switch self { + case .leaf(let view): + hasher.combine(HashKey.leaf) + hasher.combine(ObjectIdentifier(view)) + + case .split(let split): + hasher.combine(HashKey.split) + hasher.combine(split.direction) + // Note: We intentionally don't hash the ratio + split.left.hashStructure(into: &hasher) + split.right.hashStructure(into: &hasher) + } + } +} + +extension SplitTree { + /// Returns a hashable representation that captures this tree's structural identity. + var structuralIdentity: StructuralIdentity { + StructuralIdentity(self) + } + + /// A hashable representation of a SplitTree that captures its structural identity. + /// + /// This type provides a way to track changes to a SplitTree's structure in SwiftUI + /// by implementing `Hashable` based on: + /// - The tree's hierarchical structure (splits and their directions) + /// - The identity of view instances in leaf nodes (using object identity) + /// - The zoomed node state (if any) + /// + /// This is useful for SwiftUI's `id()` modifier to detect when a tree's structure + /// has changed, triggering appropriate view updates while preserving view identity + /// for unchanged portions of the tree. + /// + /// Example usage: + /// ```swift + /// var body: some View { + /// SplitTreeView(tree: splitTree) + /// .id(splitTree.structuralIdentity) + /// } + /// ``` + struct StructuralIdentity: Hashable { + private let root: Node? + private let zoomed: Node? + + init(_ tree: SplitTree) { + self.root = tree.root + self.zoomed = tree.zoomed + } + + static func == (lhs: Self, rhs: Self) -> Bool { + areNodesStructurallyEqual(lhs.root, rhs.root) && + areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(0) // Tree marker + if let root = root { + root.hashStructure(into: &hasher) + } + hasher.combine(1) // Zoomed marker + if let zoomed = zoomed { + zoomed.hashStructure(into: &hasher) + } + } + + /// Helper to compare optional nodes for structural equality + private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (node1?, node2?): + return node1.isStructurallyEqual(to: node2) + default: + return false + } + } + } +} diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index b219e0b31..2810fc2b4 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -10,6 +10,11 @@ struct TerminalSplitTreeView: View { node: node, isRoot: node == tree.root, onResize: onResize) + // This is necessary because we can't rely on SwiftUI's implicit + // structural identity to detect changes to this view. Due to + // the tree structure of splits it could result in bad beaviors. + // See: https://github.com/ghostty-org/ghostty/issues/7546 + .id(node.structuralIdentity) } } } From a87c68d49aa1f3a08c8173dbd7744e68f8af4d30 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 06:51:14 -0700 Subject: [PATCH 203/245] termio: unconditionally show "process exited" message We previously only showed this message if the user had `wait-after-command` set to true, since if its false the surface would close anyways. With the latest undo feature on macOS, this is no longer the case; a exited process can be undone and reopened. I considered disallowing undoing an exited surface, but I think there is value in being able to go back and recapture output in scrollback if you wanted to. --- src/termio/Exec.zig | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 23c626879..317ad13b4 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -418,25 +418,27 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void { return; } + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. We show this unconditionally + // on close even if `wait_after_command` is false and the surface closes + // immediately because if a user does an `undo` to restore a closed + // surface then they will see this message and know the process has + // completed. + terminal: { + td.renderer_state.mutex.lock(); + defer td.renderer_state.mutex.unlock(); + const t = td.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + // If we're purposely waiting then we just return since the process // exited flag is set to true. This allows the terminal window to remain // open. - if (execdata.wait_after_command) { - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. - terminal: { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); - const t = td.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - return; - } + if (execdata.wait_after_command) return; // Notify our surface we want to close _ = td.surface_mailbox.push(.{ From 59bc980250e7b448a8e8693484a9fb5d625fc198 Mon Sep 17 00:00:00 2001 From: Alex Straight Date: Sun, 8 Jun 2025 23:22:04 -0700 Subject: [PATCH 204/245] feat: implement mode 1048 for saving/restoring cursor position --- src/terminal/modes.zig | 1 + src/termio/stream_handler.zig | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index b36266b32..9a74db73c 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -223,6 +223,7 @@ const entries: []const ModeEntry = &.{ .{ .name = "alt_sends_escape", .value = 1039 }, .{ .name = "reverse_wrap_extended", .value = 1045 }, .{ .name = "alt_screen", .value = 1047 }, + .{ .name = "save_cursor", .value = 1048 }, .{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 }, .{ .name = "bracketed_paste", .value = 2004 }, .{ .name = "synchronized_output", .value = 2026 }, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b3aa82d20..2069a8ff2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -597,6 +597,18 @@ pub const StreamHandler = struct { try self.queueRender(); }, + // Mode 1048 is xterm's conditional save cursor depending + // on if alt screen is enabled or not (at the terminal emulator + // level). Alt screen is always enabled for us so this just + // does a save/restore cursor. + .save_cursor => { + if (enabled) { + self.terminal.saveCursor(); + } else { + try self.terminal.restoreCursor(); + } + }, + // Force resize back to the window size .enable_mode_3 => { const grid_size = self.size.grid(); From b0e0aadaf3583d59574e69a3f8199d48ad967591 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 9 Jun 2025 20:44:23 -0700 Subject: [PATCH 205/245] build: Xcode 26, macOS Tahoe support (build tooling only) This updates our build script and CI to support Xcode 26 and macOS Tahoe. **This doesn't update the Ghostty app to resolve any Tahoe issues.** For CI, we've added a new build job that runs on macOS Tahoe with Xcode 26. I've stopped short of updating our tip release job, since I think I want to wait until I verify a bit more about Tahoe before we flip that bit. Also, ideally, we'd run Xcode 26 on Sequoia (macOS 15) for stability reasons and Namespace doesn't have Xcode 26 on 15 yet. For builds, this updates our build script to find Metal binaries using `xcodebuild -find-executable` instead of `xcrun`. The latter doesn't work with Xcode 26, but the former does and also still works with older Xcodes. I'm not sure if this is a bug but I did report it: FB17874042. --- .github/workflows/test.yml | 60 +++++++++++++++++++++++++++++++++++--- src/build/MetallibStep.zig | 35 +++++++++++++++++----- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0b0ded6b..8a98584a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - build-nix - build-snap - build-macos + - build-macos-tahoe - build-macos-matrix - build-windows - build-windows-cross @@ -284,7 +285,7 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select + - name: Xcode Select run: sudo xcode-select -s /Applications/Xcode_16.0.app - name: get the Zig deps @@ -296,7 +297,58 @@ jobs: - name: Build GhosttyKit run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} - # The native app is built with native XCode tooling. This also does + # The native app is built with native Xcode tooling. This also does + # codesigning. IMPORTANT: this must NOT run in a Nix environment. + # Nix breaks xcodebuild so this has to be run outside. + - name: Build Ghostty.app + run: cd macos && xcodebuild -target Ghostty + + # Build the iOS target without code signing just to verify it works. + - name: Build Ghostty iOS + run: | + cd macos + xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" + + build-macos-tahoe: + runs-on: namespace-profile-ghostty-macos-tahoe + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_26.0.app + + # TODO(tahoe): + # https://developer.apple.com/documentation/xcode-release-notes/xcode-26-release-notes#Interface-Builder + # We allow this step to fail because if our image already has + # the workaround in place this will fail. + - name: Xcode 26 Beta 17A5241e Metal Workaround + continue-on-error: true + run: | + xcodebuild -downloadComponent metalToolchain -exportPath /tmp/MyMetalExport/ + sed -i '' -e 's/17A5241c/17A5241e/g' /tmp/MyMetalExport/MetalToolchain-17A5241c.exportedBundle/ExportMetadata.plist + xcodebuild -importComponent metalToolchain -importPath /tmp/MyMetalExport/MetalToolchain-17A5241c.exportedBundle + + - name: get the Zig deps + id: deps + run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT + + # GhosttyKit is the framework that is built from Zig for our native + # Mac app to access. + - name: Build GhosttyKit + run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} + + # The native app is built with native Xcode tooling. This also does # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app @@ -324,7 +376,7 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select + - name: Xcode Select run: sudo xcode-select -s /Applications/Xcode_16.0.app - name: get the Zig deps @@ -642,7 +694,7 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: XCode Select + - name: Xcode Select run: sudo xcode-select -s /Applications/Xcode_16.0.app - name: get the Zig deps diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index 12adf3edb..bac3a72c5 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -22,13 +22,12 @@ step: *Step, output: LazyPath, pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { - const self = b.allocator.create(MetallibStep) catch @panic("OOM"); + switch (opts.target.result.os.tag) { + .macos, .ios => {}, + else => return null, // Only macOS and iOS are supported. + } - const sdk = switch (opts.target.result.os.tag) { - .macos => "macosx", - .ios => "iphoneos", - else => return null, - }; + const self = b.allocator.create(MetallibStep) catch @panic("OOM"); const min_version = if (opts.target.query.os_version_min) |v| b.fmt("{}", .{v.semver}) @@ -38,11 +37,31 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { else => unreachable, }; + // Find the metal and metallib executables. The Apple docs + // at the time of writing (June 2025) say to use + // `xcrun --sdk metal` but this doesn't work with Xcode 26. + // + // I don't know if this is a bug but the xcodebuild approach also + // works with Xcode 15 so it seems safe to use this instead. + // + // Reported bug: FB17874042. + var code: u8 = undefined; + const metal_exe = std.mem.trim(u8, b.runAllowFail( + &.{ "xcodebuild", "-find-executable", "metal" }, + &code, + .Ignore, + ) catch return null, "\r\n "); + const metallib_exe = std.mem.trim(u8, b.runAllowFail( + &.{ "xcodebuild", "-find-executable", "metallib" }, + &code, + .Ignore, + ) catch return null, "\r\n "); + const run_ir = RunStep.create( b, b.fmt("metal {s}", .{opts.name}), ); - run_ir.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metal", "-o" }); + run_ir.addArgs(&.{ metal_exe, "-o" }); const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name})); run_ir.addArgs(&.{"-c"}); for (opts.sources) |source| run_ir.addFileArg(source); @@ -62,7 +81,7 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { b, b.fmt("metallib {s}", .{opts.name}), ); - run_lib.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metallib", "-o" }); + run_lib.addArgs(&.{ metallib_exe, "-o" }); const output_lib = run_lib.addOutputFileArg(b.fmt("{s}.metallib", .{opts.name})); run_lib.addFileArg(output_ir); run_lib.step.dependOn(&run_ir.step); From 3d692e46f435ec5488e9570d1fc65b8778437480 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 10 Jun 2025 10:20:26 -0600 Subject: [PATCH 206/245] license: update copyright notices to include contributors Updates all copyright notices to include "Ghostty contributors" to reflect the fact that Mitchell is not the sole copyright owner. Also adds "Ghostty contributors" to the author section in the manpages, linking https://github.com/ghostty-org/ghostty/graphs/contributors for proper credit. --- LICENSE | 2 +- pkg/README.md | 2 +- pkg/glfw/LICENSE | 2 +- po/ca_ES.UTF-8.po | 2 +- po/com.mitchellh.ghostty.pot | 2 +- po/de_DE.UTF-8.po | 2 +- po/es_BO.UTF-8.po | 2 +- po/fr_FR.UTF-8.po | 2 +- po/id_ID.UTF-8.po | 2 +- po/ja_JP.UTF-8.po | 2 +- po/mk_MK.UTF-8.po | 2 +- po/nb_NO.UTF-8.po | 2 +- po/nl_NL.UTF-8.po | 2 +- po/pl_PL.UTF-8.po | 2 +- po/pt_BR.UTF-8.po | 2 +- po/ru_RU.UTF-8.po | 2 +- po/tr_TR.UTF-8.po | 2 +- po/uk_UA.UTF-8.po | 2 +- po/zh_CN.UTF-8.po | 2 +- src/build/GhosttyI18n.zig | 2 +- src/build/mdgen/ghostty_1_footer.md | 1 + src/build/mdgen/ghostty_5_footer.md | 1 + 22 files changed, 22 insertions(+), 20 deletions(-) diff --git a/LICENSE b/LICENSE index 14e132f55..0a07a66cd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Mitchell Hashimoto +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/pkg/README.md b/pkg/README.md index 1d6f9f6eb..fddc4b3db 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -12,7 +12,7 @@ paste them into your project. the Ghostty project. This license does not apply to the rest of the Ghostty project.** -Copyright © 2024 Mitchell Hashimoto +Copyright © 2024 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in diff --git a/pkg/glfw/LICENSE b/pkg/glfw/LICENSE index eeeb852fe..8c422bd23 100644 --- a/pkg/glfw/LICENSE +++ b/pkg/glfw/LICENSE @@ -1,5 +1,5 @@ Copyright (c) 2021 Hexops Contributors (given via the Git commit history). -Copyright (c) 2025 Mitchell Hashimoto +Copyright (c) 2025 Mitchell Hashimoto, Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated diff --git a/po/ca_ES.UTF-8.po b/po/ca_ES.UTF-8.po index 712f0d5af..653439fa2 100644 --- a/po/ca_ES.UTF-8.po +++ b/po/ca_ES.UTF-8.po @@ -1,5 +1,5 @@ # Catalan translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Francesc Arpi , 2025. # diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index d6a99d01d..da0efbbee 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR Mitchell Hashimoto +# Copyright (C) YEAR Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # FIRST AUTHOR , YEAR. # diff --git a/po/de_DE.UTF-8.po b/po/de_DE.UTF-8.po index 44f3bae39..2d3b96d81 100644 --- a/po/de_DE.UTF-8.po +++ b/po/de_DE.UTF-8.po @@ -1,6 +1,6 @@ # German translations for com.mitchellh.ghostty package # German translation for com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Robin Pfäffle , 2025. # diff --git a/po/es_BO.UTF-8.po b/po/es_BO.UTF-8.po index f3a62748a..077b7dfa1 100644 --- a/po/es_BO.UTF-8.po +++ b/po/es_BO.UTF-8.po @@ -1,5 +1,5 @@ # Spanish translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Miguel Peredo , 2025. # diff --git a/po/fr_FR.UTF-8.po b/po/fr_FR.UTF-8.po index 4db72a23e..aef0d96ac 100644 --- a/po/fr_FR.UTF-8.po +++ b/po/fr_FR.UTF-8.po @@ -1,5 +1,5 @@ # French translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Kirwiisp , 2025. # diff --git a/po/id_ID.UTF-8.po b/po/id_ID.UTF-8.po index d5204d420..f82ec6197 100644 --- a/po/id_ID.UTF-8.po +++ b/po/id_ID.UTF-8.po @@ -1,5 +1,5 @@ # Indonesian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Satrio Bayu Aji , 2025. # diff --git a/po/ja_JP.UTF-8.po b/po/ja_JP.UTF-8.po index e6e015f8a..73ddd9f5a 100644 --- a/po/ja_JP.UTF-8.po +++ b/po/ja_JP.UTF-8.po @@ -1,6 +1,6 @@ # Japanese translations for com.mitchellh.ghostty package # com.mitchellh.ghostty パッケージに対する和訳. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Lon Sagisawa , 2025. # diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po index 39bb72b91..20a43572e 100644 --- a/po/mk_MK.UTF-8.po +++ b/po/mk_MK.UTF-8.po @@ -1,5 +1,5 @@ # Macedonian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Andrej Daskalov , 2025. # diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index 2685d67bb..045d47a80 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -1,5 +1,5 @@ # Norwegian Bokmal translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Hanna Rose , 2025. # Uzair Aftab , 2025. diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 466116352..355bc4a57 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -1,5 +1,5 @@ # Dutch translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Nico Geesink , 2025. # diff --git a/po/pl_PL.UTF-8.po b/po/pl_PL.UTF-8.po index 22d2cd975..a68d56818 100644 --- a/po/pl_PL.UTF-8.po +++ b/po/pl_PL.UTF-8.po @@ -1,6 +1,6 @@ # Polish translations for com.mitchellh.ghostty package # Polskie tłumaczenia dla pakietu com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Bartosz Sokorski , 2025. # diff --git a/po/pt_BR.UTF-8.po b/po/pt_BR.UTF-8.po index f6d2f26a2..d2ba0e693 100644 --- a/po/pt_BR.UTF-8.po +++ b/po/pt_BR.UTF-8.po @@ -1,6 +1,6 @@ # Portuguese translations for com.mitchellh.ghostty package # Traduções em português brasileiro para o pacote com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Gustavo Peres , 2025. # diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index 9e9cf8077..0cb533de7 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -1,6 +1,6 @@ # Russian translations for com.mitchellh.ghostty package # Русские переводы для пакета com.mitchellh.ghostty. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # blackzeshi , 2025. # diff --git a/po/tr_TR.UTF-8.po b/po/tr_TR.UTF-8.po index 3de70d61c..5d761f6a4 100644 --- a/po/tr_TR.UTF-8.po +++ b/po/tr_TR.UTF-8.po @@ -1,5 +1,5 @@ # Turkish translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Emir SARI , 2025. # diff --git a/po/uk_UA.UTF-8.po b/po/uk_UA.UTF-8.po index 5a264b537..bde975fc4 100644 --- a/po/uk_UA.UTF-8.po +++ b/po/uk_UA.UTF-8.po @@ -1,5 +1,5 @@ # Ukrainian translations for com.mitchellh.ghostty package. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Danylo Zalizchuk , 2025. # diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index ee2c51362..77be8a351 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -1,6 +1,6 @@ # Chinese translations for com.mitchellh.ghostty package # com.mitchellh.ghostty 软件包的简体中文翻译. -# Copyright (C) 2025 Mitchell Hashimoto +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Leah , 2025. # diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index daf523938..a1852bb96 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -54,7 +54,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { "--keyword=C_:1c,2", "--package-name=" ++ domain, "--msgid-bugs-address=m@mitchellh.com", - "--copyright-holder=Mitchell Hashimoto", + "--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"", "-o", "-", }); diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 7ace64cd8..f8e502b45 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -44,6 +44,7 @@ See GitHub issues: # AUTHOR Mitchell Hashimoto +Ghostty contributors # SEE ALSO diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index c5077ab97..380d83a53 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -36,6 +36,7 @@ See GitHub issues: # AUTHOR Mitchell Hashimoto +Ghostty contributors # SEE ALSO From 12ad0fa4b68c0748fbbbbf3ba89ceecad3567d0c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 10 Jun 2025 12:11:59 -0600 Subject: [PATCH 207/245] font/sprite: add corner pieces from Geometric Shapes block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ◢ ◣ ◤ ◥ ◸ ◹ ◺ ◿ --- src/font/sprite/Box.zig | 123 +++++++++++++++++++++++++++++++ src/font/sprite/Face.zig | 5 ++ src/font/sprite/testdata/Box.ppm | Bin 1048593 -> 1048593 bytes 3 files changed, 128 insertions(+) diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig index dd02f701b..f5140091d 100644 --- a/src/font/sprite/Box.zig +++ b/src/font/sprite/Box.zig @@ -581,6 +581,120 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void // '▟' 0x259f => self.draw_quadrant(canvas, .{ .tr = true, .bl = true, .br = true }), + // '◢' + 0x25e2 => self.draw_corner_triangle_shade(canvas, .br, .on), + // '◣' + 0x25e3 => self.draw_corner_triangle_shade(canvas, .bl, .on), + // '◤' + 0x25e4 => self.draw_corner_triangle_shade(canvas, .tl, .on), + // '◥' + 0x25e5 => self.draw_corner_triangle_shade(canvas, .tr, .on), + + // '◸' + 0x25f8 => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // top edge + self.rect( + canvas, + 0, + 0, + self.metrics.cell_width, + thickness_px, + ); + // left edge + self.rect( + canvas, + 0, + 0, + thickness_px, + self.metrics.cell_height -| 1, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .lower_left, + .upper_right, + ); + }, + // '◹' + 0x25f9 => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // top edge + self.rect( + canvas, + 0, + 0, + self.metrics.cell_width, + thickness_px, + ); + // right edge + self.rect( + canvas, + self.metrics.cell_width -| thickness_px, + 0, + self.metrics.cell_width, + self.metrics.cell_height -| 1, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .upper_left, + .lower_right, + ); + }, + // '◺' + 0x25fa => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // bottom edge + self.rect( + canvas, + 0, + self.metrics.cell_height -| thickness_px, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // left edge + self.rect( + canvas, + 0, + 1, + thickness_px, + self.metrics.cell_height, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .upper_left, + .lower_right, + ); + }, + // '◿' + 0x25ff => { + const thickness_px = Thickness.light.height(self.metrics.box_thickness); + // bottom edge + self.rect( + canvas, + 0, + self.metrics.cell_height -| thickness_px, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // right edge + self.rect( + canvas, + self.metrics.cell_width -| thickness_px, + 1, + self.metrics.cell_width, + self.metrics.cell_height, + ); + // diagonal + self.draw_cell_diagonal( + canvas, + .lower_left, + .upper_right, + ); + }, + 0x2800...0x28ff => self.draw_braille(canvas, cp), 0x1fb00...0x1fb3b => self.draw_sextant(canvas, cp), @@ -3197,6 +3311,15 @@ fn testRenderAll(self: Box, alloc: Allocator, atlas: *font.Atlas) !void { else => {}, } } + + // Geometric Shapes: filled and outlined corners + for ([_]u21{ '◢', '◣', '◤', '◥', '◸', '◹', '◺', '◿' }) |char| { + _ = try self.renderGlyph( + alloc, + atlas, + char, + ); + } } test "render all sprites" { diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index f15423ada..af0c0af6a 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -190,6 +190,11 @@ const Kind = enum { // ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟ 0x2580...0x259F, + // "Geometric Shapes" block + 0x25e2...0x25e5, // ◢◣◤◥ + 0x25f8...0x25fa, // ◸◹◺ + 0x25ff, // ◿ + // "Braille" block 0x2800...0x28FF, diff --git a/src/font/sprite/testdata/Box.ppm b/src/font/sprite/testdata/Box.ppm index d5a6cc72906318b235e33bc3ea53be8f88b67193..6082475af7d3e2264cf04061e9f63d7c6e6fdc9e 100644 GIT binary patch delta 12462 zcmeHNdw3L8mhYQ%Rdv@rmCmCRG>~*95f~&uf#h}|%Z@=j23f`By7^#LIuDBvSe z;w;W0rfnw22QV2w-y)$AEl`{UaRFVGpo=CR8R?+AF0>xl*ZgR64h*%XcLDY&IO+Pj+L= zD&`D%$2eFWhO6YXhmPdqwI9lTvFv7$&^w%^SC8i^Rzh;+ zD`s&B6(5IAkAnKd>hYXo*+;65hhI@SR%G!}beDluAE}(~)??W$M_o{NqRyPtLUlz> zteNKUpuZ<~Nj6&@DI5@|5CbO^313<#yB<0+02j`eQ&C@_4E%}o`68rmaJxPQi(4fL zU#yZ-@U=xs3jW|{=?Ret9{6XeH@+T_v+##;9DV{<)(NRYJ4kIKsq11=M_W>NlhhJ9 z8zbX5ZpE|#A`XWNZ@OBL{xl3VnxOtJ{!L~6hBlN=83jX^NDFyxS$jSKqQ{ckD z32=1`a8MuC`N5>~(^h9{8__{qo1`$GrEP+x?SmJkl%Sr3HJc?n4%rKR+8y*iGcA(P zFUj@;Ei*Kghi6HrtdA3IM;=f(tHUjel%Du~3A-pJ`U#ecshK>x zbC_ds&!EL*l30#{R`9)Bc{(0$Qgi5|3TIB`DOf*H%EZHyxzng{JM$P08pSjWWXXyY zi9{lG_{l1EQOG;Sjxbl@_l_rsl6F29kUvM1(35iTH5wZ7h@AqT!}h zibi5IJQ-jY$2_y1<>Kzgl}?^HgWQX*z~|&NaMW2kapZ8tjpes7AfIVCeYotz9kkY+ zoEsqA{ z+?aipEElgUQ+kK>zpzt)jgYz15*Mk%Sq;oh^3q6N6!FeD#0m>BsAq~D$h(#6=xs7f zoE}*JimGDqR-R*2w5j76mPJ&+`mxSM!irGE7w^-O(~;mn}bU8YRIO5j+VIA0|Di z3>F-#<2aUH0FFA71J!`SarDiQO%Zcgep?eqR(9TGa-uI1Wut9l^b+t;loX4tDvoUd z%f;5g{5+y0C)P#ToMhX`p^ArWi?XeYW4n>%hVK6ya7Hiag;npX_hC&Eq#G6Kd^|&~ z+uW0wFBC4yq&3PxYr9UC$d)A!$6?Pkv5kdmwL*qcCf|elc&_2$;ZkXbY?fFZAv)@; zM8>)yget{Q*T1U-NdI;ipc`kntlh?^T zjQwlX(F|{yCp%5PEwUTmp*n{u9>MoS9AC+W;QIw*zEEvZHdSRZ?%gCO;T2>dm2Ot- zo{8hU_5yL%nVgI`pOKSrKUqojsKvP{jB7Hy7zB4l8?~kX`5>DZ9m#IPfCv z?`A1(@lRm6p^razV4o&c|Iu`sh=sYlDi*#`jMbSmbbQ-eLo~mX-C~}9vP8-dr;*r_ zIOZH#Mlx~S3cXg8O7_@s?|L~I{gY)ktzLBQV$Gq&kEbCW#qb!D?+)t^F>yM_z(cG& z6#3$9+*&24nh33g@Gv1vk=?Y$(jk-0G!gu#MxZ0#Lr2{B#yZ&@)M@qK^|;ap>j|&O z?#RH>MpX%UC%DYR!lYb2T}-~I{do>fji|dZ@EDV@hK~L-r_xJ+Rw+6w=ksf@VV^w> ze;COx!LlT$@HFke#i=a=GCFwnE(*dWQ2kT}8cI8`5R;b^I?3H|*Tqvm z3RG|wKr4A$;?ub+xm|o1xhwg%WUFFSJit#f-0~}KH)d|&c1KwMGtDSFe)TK0XkI{d&wQ5W zpITZ>Y`aF`v@}!LA>P8m`VmEZ)9d*!7{R|vn*sUDj?=dBEa4zy&^A7ZN6pD(mYePH zsxh;bpK#I)=H{AUF0I!lc6s#o6v9H|!2^6NLoEYb-E}QntO1Qwuq<2KgUJK5e1ctp zfiIu{{qL(17B{N0(cpr?32_u>I1U?JFub#`KPF!%uah0PWwy5OM>$6%pm&7gzCayG zO;MNVwa1Eg%+PYOHwViu)3#v!Vr@mJ_;@nvl6D1dDF7842Ep90ep0Na=iMvMbdxd7 z4t9pR9sYw-t^b8naJPtF4hAM@ZP-vq($DQt0Ul20Nm!q)tr71(gL*bT7?fQ2yO$*i zkM)8woG}?*6~9CTbu=xAvUWT8w^9;*uET9OV~RB{tREF_6)7%qF(bxxeKl}gy;VxW z7w&;6K|PJt;atGo`NSYhdKCJqneF(EQSr}yj22o(-O7b9n0WiqiBA&C{Fx( z9`wQTy^4k>{4mc#Sq2-h|7fKH%3?yf8Mcc+NVq?pXP`HvmE#MeVGB+f4L>KntLbf| zjunlnldQ?ah9rlRe(!J_3rgX!PJ=6O^BAbb>l)P|ILs>pZnz8Xh)1{yGL51oT}bc2 z+6l1P92dL?{vm$IJQ{Lamm&A!mSWh7(;C%5xP7ezxZysN%5CT?;qy+yR$JIV8>S+zqsuCMM8PMmDnVh413O{u;hwMtT6oFqCG23m5zrwqU~& za8P#%*G>T$Z=4JTLA@Vdp9M)q@HeoW8R>IrMDb>j5S9VQ(%*xEOCNz}!k$)!4R0x` z;kgm6V5qEsWGsCWWW03)Jd2Ty&@-$bigsB*UF2nuLIAZv4@b`*z-jcZhCfBSl!-1Y zsLPX3i{2HCqvv(%^k{phrPN7U2C2B{S=fv<25_U-MmQD)SuWaFQ2Pt88C^R8jI77u zXte!KYM&8?UDb~PH?p2;Z>bP1FTpN!y(n`d>$&!(N@}`)2V~%_JK%YY>|`7ll!9vX z-31*)3@T}2uD}2omTn?v^y$1XYf3piRaOp01KIq{__RAt9RWOd> z%zuJ<@)UlC8&!FYyi-h>Ri?M--&x_kZrUos>8VbSmoHM|4Q2{##@*Wp{LqY|*! zMsOH?_rP%$*3|@j@DWKd`o0CnX?N#TKa^6vRN8S{^KRu6samt@m+wA=eDqC`ov2lV zV)T9YEQfy#`RJdkq=)q{&x*FYakt>Z4X_7W8lV(gwm^UU?lTh|@QxV2$p?>Cs?*-3#t zzLPrbq)y*-?zEj5Y4sGu3xCRoFEc=mqxb6Ts0wMaxBR9`qOA zZfR3FB|%O!CB}91aX#K~0P;x}$AKpygFZM8JOtb*Ite>jSAdBGS0dmV=JEo{r+joh z#Y^Ze1NS?%u|XaFb`fQ59}DX&p?eEX*Sb?|*P>+p1S4O-4OB9ZGV7u;7i3dOJDEyk z2c5tHg$nppf2tkEP-# zpUmLsU!rlUbxX`jE>_)GuL`m>nYwJGR{_K56xOF(_3}?UD#xfFtBqr(&g6O89I8Nb!un^Xx-)OBe?XfN zTSrPet|QYeuh2{~lPZgHVu{w9@O>8hSs-+CMwuny9^}bNhp6LHe0BFr`FGco{$ifa&J|(saQBLk=s24z?*pcYSl!Ac*SdT5aP-s+* z)&9&-{~lJKUvsGDuswEkpJ$PhV;vQgNjdo@A2~a58k||<+ zX@@JI-(V&BnnI_2N2bqaD~kPJ^^c&S8_SeQ z-M8SwR%$U4^=XK_pY_qkymM+ubkw`2I z;_oYQxRnZT|oBOgs?Q7bgBUQ{9$h delta 10213 zcmeHNYj9Lm60VbE9=Y8o84{e32g6N3C2JP5g1lE^cnJp0it?6>;DUk`25@a zDvyjKv_zEDs>lL{2_R<`7XqvZDC{Vd%7U1!S`h?=#Z^EQw))P^%p?$=5sNDSOij+| z{<{0??sNOxiO-wo^X7R=MQKcFY-wDnrPNw#E47!#uhOQxMMoCJYE(7~G;;6OG=J`w zK!xStq~O0aIP))`eh1Z*!!;CiK|c!i0x$$AcuLD8?;33Z>07Wp1zgacbQd^z4XErv zOeW8R_H}GcQrWS%fz<6St<$f@qiFZ(i+)~eeR@Z7w}2#CH^MsX=QVZ3N~kS)M*xs_ zIK&a;1Jo9=UET7Lwq20tE^Or=ce|Bj3P`G+B`x%#6K)`$O||`4+a<`G2CtFFB^~qb7FL#vSGha2o}zBlVY>gkMoTbT#Rc7|;)F%x zHY)rab@J}!zS*snQQ(xOaW6TIzTwU@dMyi^Q^i4Bi;x*_>-d&}Zp;^Hp-kR+tLPQ_ z_A?qO+Mrz#BaxbHT#~Mc|5qdvbsD!3NKy;i#k?6)zrN1C>Q5}Ra3{n^PJIg90_sl&YIp?UcJiv(Vm$p+D(c;*x29cW9-O3*o<_F4P689T!#lFhTC# zFoyA(l6xX*Cg>nIE|jAT8rmNgrCnrdPs3|W?Q`$m561-cTdUb9w;X`<1&~V%Uqu^x zat#&r250!gL34(Ko!o<=GZij?VhYX@b`I7;g(uKPEs7w+WW@tFh5QI~mUZ8P zEDX$h8B#d4(ZY@$aMaFGwdNXdQrYWJM%AASpuF8$3o3gWJBDH|1Ucv|fd@DvahwJ_ z`*lW^f{V>8rL*&$3{!)YUd=vv$3s?qrNZ@)d3H}`VK0VQtg%0XD0dO$tHSlL#u2X8 zs2Wv;3t(M+X1H+MGRB?pCN)_YqGke@a}qD+)RGjb|0q~S*jA-~XKRoXr5}eQXD`^` zA;=Qy$it8>s`M|cMi|V*3=s-Aw=+iAO!s?=7>5rF5FGsZalV$lu^kcryhGWZ#fxa2jU$^?Ty@)Xlgc*r9MOy}b=J+A$ob z(&-oR!$t(&G6JVl_w~>wRKmgIFze#cx%BvQD2>2s1MX%9JQ;In>+RsA1EX*xeYOQ{ zbns!=+Sq0$VjEgo1WoC{7#vBv9zqPYs!wlYnmU2klcR~8%B<&5%^*3Iwr5}(KeUYJ zU_HkjpCQtoaoCcU6+to`D8`Z0vNNuTG^kH6y&UMVcr8^HK@&PKfj#y#hR2dtO~M!G z-D#LprDvOmK?9Me(7J(;O}S|>o)$cUfs56eKrcOp%at`~Umar(E+ujXz5E1L)?0j< z7Cwn9=nwsIHD8o6`Kg?&db@Frpi}8!_gB=!g`N2mRXl?$DD`>lYNAfVW_0>VOrXsJ zv0DUy#(iyxSB3ZE4pF7wY{Y7%)Uvv2PoSjthGJesP4#{N_k=ZUlr|H)gbWovE>q`~ zAkd~Fyfs?kA>1O!yUC`hgn4+2pnd%@k%o@MWnrNt)ysohYlX^L=*{uioN`Msji!&r zQkp#(ogBBF#!SXk)%RiCR4e{+MBK~bV_AG+SbQR;(t@9~M4z5OC4OtX@=U?!!o*1h zf5BS?O?ltel-#2+o^l^$kZ}w$E(BszxsT#U(JG5r<*!BHqA?TkC7L}pEaj z18@kxUV{M3_lS>d- zc)4m8z(c~Pr-tS!y&5uk$jkBR`Qhm%hfY*7_UkOZ>H@@%FTn&Fz7#7}v%g)4cqNN3 zXYqHU#RD#I(Np{IT`E|K>*>dZ2&z>z)(8sb$!5y&6?PR=pm^&Q)KtrV;J*cRd=ry> zxZ$Sm`<3^e@-(pi)pW&6rRn~_y5C-sZm^3)TKhfDrZ@IrGoNmbSH|=gyis=G962~|R<(Vm6oMQ( zwI;kRqsGnf&{%Io2~I<2)a>H!iFR6Xl$||^nVe`)F1y@+`Jnn?20h)lpeu}Y#>$30 z4N&!c*qV;Ck@wQdeOS(IsGD&$mH&$#Z^~|yDm-Cl`t;FU?D^c3PVUhhpKkjF6kYo{ z1-jfXeEQ+2Suxm6cCXTXvFBatLWM2l8Y&zqH-s*H?u^j*RIk^;7{Yz}M|BY|PQ6O+ zWKvp@ft|vqM-$_MS?B5(b^54kkIWb3IDqAb50?#6=lb@+1$dF0i%_>&%%-T*WDu^7 z(EY2nmFnNi&Rmafu|R>$Hh}!Dd#rqI{nf!M$>8sdlagNkMC(f4$K>z%2h4oszD-sN zzCUE1H^=oVqqk4LDbg{@F<07!+Fl^@MEL3 z#cY{vJoju3o}0?;k&lR|nZq!3!HVWgWxP7@x}?Tq*y8BpYJ@aRT)1SEGZh&qqtWid z`bV&5iOeR)ZLpG#EEL)EZV^d!URq5xX=>p8)A`ohg^oG*K^s+HFCD+SV!Xw>L{>sS z3YEysb#rp(89h5omisIE*!eJXh7X=I53S`PoZ1>O{#~PX1gOos`MN98%adwfvs{hH z#(Sz!VM;Q6y5Ho|jPqGO;R4FNKz0*#O4kw|5T;%|>a=zs{Dxo6MTK0Y_lyl!(zHk{ R7rV|sIb7THYWhq{{6F2@nBD*Y From 2b9a6a482017b7bd3a1856e976ff8c2a3b13cecf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Jun 2025 12:11:17 -0700 Subject: [PATCH 208/245] macos: unsplit window shouldn't allow split zooming This was always the case, and is a recent regression from the SplitTree rework. This brings it back to the previous behavior. --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 594a58056..e91199358 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -556,12 +556,15 @@ class BaseTerminalController: NSWindowController, // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return } - + // Toggle the zoomed state if surfaceTree.zoomed == targetNode { // Already zoomed, unzoom it surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) } else { + // We require that the split tree have splits + guard surfaceTree.isSplit else { return } + // Not zoomed or different node zoomed, zoom this node surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) } From 8b5cceed3ecb916f6a5be4d384dcd964402abc3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Jun 2025 12:30:15 -0700 Subject: [PATCH 209/245] ci: pin gh-action-release to 2.2.2 to workaround issue https://github.com/softprops/action-gh-release/issues/628 --- .github/workflows/release-tip.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 42626288c..b6a6c5f6c 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -132,7 +132,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.2.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -299,7 +299,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.2.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -507,7 +507,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.2.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -682,7 +682,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.2.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From 1f340b4b2dc8e2f9752bee8702a555e8bd367b51 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Jun 2025 12:39:09 -0700 Subject: [PATCH 210/245] macos: for windowShouldClose, only close the tab if we have multiple Fixes a regression from our undo/redo rework. We were accidentally closing the entire window when the "X" button in the tab bar was clicked. --- macos/Sources/Features/Terminal/TerminalController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c9f8ef216..a984952f9 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1046,7 +1046,12 @@ class TerminalController: BaseTerminalController { //MARK: - NSWindowDelegate override func windowShouldClose(_ sender: NSWindow) -> Bool { - closeWindow(sender) + // If we have tabs, then this should only close the tab. + if window?.tabGroup?.windows.count ?? 0 > 1 { + closeTab(sender) + } else { + closeWindow(sender) + } // We will always explicitly close the window using the above return false From 3db5b3da752b07d3877ab9682f660c76320e82a6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Jun 2025 14:31:41 -0700 Subject: [PATCH 211/245] macos: hidden titlebar windows should cascade on new tab Windows with `macos-titlebar-style = hidden` create new windows when the new tab binding is pressed. This behavior has existed for a long time. However, these windows did not cascade, meaning they'd appear overlapped directly on top of the previous window, which is kind of nasty. This commit changes it so that new windows created via new tab from a hidden titlebar window will cascade. --- .../Terminal/TerminalController.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a984952f9..5916c5921 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -278,11 +278,6 @@ class TerminalController: BaseTerminalController { tg.removeWindow(window) } - // Our windows start out invisible. We need to make it visible. If we - // don't do this then various features such as window blur won't work because - // the macOS APIs only work on a visible window. - controller.showWindow(self) - // If we have the "hidden" titlebar style we want to create new // tabs as windows instead, so just skip adding it to the parent. if (ghostty.config.macosTitlebarStyle != "hidden") { @@ -303,7 +298,19 @@ class TerminalController: BaseTerminalController { } } - window.makeKeyAndOrderFront(self) + // We're dispatching this async because otherwise the lastCascadePoint doesn't + // take effect. Our best theory is there is some next-event-loop-tick logic + // that Cocoa is doing that we need to be after. + DispatchQueue.main.async { + // Only cascade if we aren't fullscreen and are alone in the tab group. + if !window.styleMask.contains(.fullScreen) && + window.tabGroup?.windows.count ?? 1 == 1 { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + + controller.showWindow(self) + window.makeKeyAndOrderFront(self) + } // It takes an event loop cycle until the macOS tabGroup state becomes // consistent which causes our tab labeling to be off when the "+" button From 990b6a6b0808f3eef1549bdeaf6b5413384141db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 00:31:07 +0000 Subject: [PATCH 212/245] build(deps): bump softprops/action-gh-release from 2.2.2 to 2.3.2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.2.2 to 2.3.2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2.2.2...v2.3.2) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.3.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tip.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b6a6c5f6c..73a1ddeeb 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -132,7 +132,7 @@ jobs: nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@v2.2.2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -299,7 +299,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2.2.2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -507,7 +507,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2.2.2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -682,7 +682,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@v2.2.2 + uses: softprops/action-gh-release@v2.3.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From 4d33a73fc4640b19cb6160942c5bdcd2a8102fb9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 10 Jun 2025 13:03:30 -0700 Subject: [PATCH 213/245] wip: redo terminal window styling --- macos/Ghostty.xcodeproj/project.pbxproj | 42 ++++- .../Terminal/TerminalController.swift | 170 +++++------------- .../HiddenTitlebarTerminalWindow.swift | 78 ++++++++ .../LegacyTerminalWindow.swift} | 44 +---- .../Terminal/{ => Window Styles}/Terminal.xib | 8 +- .../Window Styles/TerminalHiddenTitlebar.xib | 31 ++++ .../Terminal/Window Styles/TerminalLegacy.xib | 31 ++++ .../TerminalTransparentTitlebar.xib | 31 ++++ .../Window Styles/TerminalWindow.swift | 75 ++++++++ .../TransparentTitlebarTerminalWindow.swift | 72 ++++++++ .../Helpers/Extensions/NSView+Extension.swift | 27 +++ macos/Sources/Helpers/Fullscreen.swift | 11 +- 12 files changed, 448 insertions(+), 172 deletions(-) create mode 100644 macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift rename macos/Sources/Features/Terminal/{TerminalWindow.swift => Window Styles/LegacyTerminalWindow.swift} (95%) rename macos/Sources/Features/Terminal/{ => Window Styles}/Terminal.xib (86%) create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift create mode 100644 macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 9686dcbd1..594579744 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -15,7 +15,7 @@ A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; }; + A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; }; @@ -51,6 +51,12 @@ A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; + A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */; }; + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; }; + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; }; @@ -129,7 +135,7 @@ 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -159,6 +165,12 @@ A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; + A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalLegacy.xib; sourceTree = ""; }; + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = ""; }; + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; @@ -384,6 +396,21 @@ path = Sources; sourceTree = ""; }; + A5593FDD2DF8D56000B47B10 /* Window Styles */ = { + isa = PBXGroup; + children = ( + A59630992AEE1C6400D64628 /* Terminal.xib */, + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, + A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, + A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, + A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, + A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, + A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, + ); + path = "Window Styles"; + sourceTree = ""; + }; A55B7BB429B6F4410055DE60 /* Ghostty */ = { isa = PBXGroup; children = ( @@ -467,11 +494,10 @@ A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( - A59630992AEE1C6400D64628 /* Terminal.xib */, + A5593FDD2DF8D56000B47B10 /* Window Styles */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, - A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, @@ -647,9 +673,11 @@ buildActionMask = 2147483647; files = ( FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */, + A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, + A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, 9351BE8E3D22937F003B3499 /* nvim in Resources */, @@ -658,6 +686,7 @@ FC5218FA2D10FFCE004C93E0 /* zsh in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */, + A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */, A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */, A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, @@ -702,6 +731,7 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, @@ -709,6 +739,7 @@ A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, @@ -734,9 +765,10 @@ A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, + A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5916c5921..7fb8f9a07 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -6,7 +6,22 @@ import GhosttyKit /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController { - override var windowNibName: NSNib.Name? { "Terminal" } + override var windowNibName: NSNib.Name? { + //NOTE(mitchellh): switch to this when we've transitioned all legacy logic out + //let defaultValue = "Terminal" + let defaultValue = "TerminalLegacy" + + guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } + let config = appDelegate.ghostty.config + let nib = switch config.macosTitlebarStyle { + case "tabs": defaultValue + case "hidden": "TerminalHiddenTitlebar" + case "transparent": "TerminalTransparentTitlebar" + default: defaultValue + } + + return nib + } /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail @@ -114,7 +129,7 @@ class TerminalController: BaseTerminalController { invalidateRestorableState() // Update our zoom state - if let window = window as? TerminalWindow { + if let window = window as? LegacyTerminalWindow { window.surfaceIsZoomed = to.zoomed != nil } @@ -129,11 +144,6 @@ 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) } @@ -278,9 +288,8 @@ class TerminalController: BaseTerminalController { tg.removeWindow(window) } - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (ghostty.config.macosTitlebarStyle != "hidden") { + // If we don't allow tabs then we create a new window instead. + if (window.tabbingMode != .disallowed) { // Add the window to the tab group and show it. switch ghostty.config.windowNewTabPosition { case "end": @@ -389,7 +398,7 @@ class TerminalController: BaseTerminalController { // Reset this to false. It'll be set back to true later. tabListenForFrame = false - guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return } + guard let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] else { return } // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. @@ -440,7 +449,11 @@ class TerminalController: BaseTerminalController { } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { - guard let window = self.window as? TerminalWindow else { return } + if let window = window as? TerminalWindow { + window.syncAppearance(surfaceConfig) + } + + guard let window = self.window as? LegacyTerminalWindow else { return } // Set our explicit appearance if we need to based on the configuration. window.appearance = surfaceConfig.windowAppearance @@ -523,31 +536,6 @@ class TerminalController: BaseTerminalController { } } - private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { - guard let window else { return } - - // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { - if (!LastWindowPosition.shared.restore(window)) { - 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))) - } - /// Returns the default size of the window. This is contextual based on the focused surface because /// the focused surface may specify a different default size than others. private var defaultSize: NSRect? { @@ -889,52 +877,9 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } - fileprivate func hideWindowButtons() { - guard let window else { return } - - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - } - - 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) - hideWindowButtons() - - // 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 } + guard let window else { return } // Store our initial frame so we can know our default later. initialFrame = window.frame @@ -952,9 +897,6 @@ class TerminalController: BaseTerminalController { window.identifier = .init(String(describing: TerminalWindowRestoration.self)) } - // If window decorations are disabled, remove our title - if (!config.windowDecorations) { window.styleMask.remove(.titled) } - // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. if case let .leaf(view) = surfaceTree.root { @@ -967,42 +909,29 @@ class TerminalController: BaseTerminalController { } } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY, - windowDecorations: config.windowDecorations) - - if config.macosWindowButtons == .hidden { - hideWindowButtons() - } - - // Make sure our theme is set on the window so styling is correct. - if let windowTheme = config.windowTheme { - window.windowTheme = .init(rawValue: windowTheme) - } - - // Handle titlebar tabs config option. Something about what we do while setting up the - // titlebar tabs interferes with the window restore process unless window.tabbingMode - // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (config.macosTitlebarStyle == "tabs") { - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic + // TODO: remove + if let window = window as? LegacyTerminalWindow { + // Handle titlebar tabs config option. Something about what we do while setting up the + // titlebar tabs interferes with the window restore process unless window.tabbingMode + // is set to .preferred, so we set it, and switch back to automatic as soon as we can. + if (config.macosTitlebarStyle == "tabs") { + window.tabbingMode = .preferred + window.titlebarTabs = true + DispatchQueue.main.async { + window.tabbingMode = .automatic + } + } else if (config.macosTitlebarStyle == "transparent") { + window.transparentTabs = true } - } else if (config.macosTitlebarStyle == "transparent") { - window.transparentTabs = true - } - if window.hasStyledTabs { - // Set the background color of the window - let backgroundColor = NSColor(config.backgroundColor) - window.backgroundColor = backgroundColor + if window.hasStyledTabs { + // Set the background color of the window + let backgroundColor = NSColor(config.backgroundColor) + window.backgroundColor = backgroundColor - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) + // This makes sure our titlebar renders correctly when there is a transparent background + window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) + } } // Initialize our content view to the SwiftUI root @@ -1012,11 +941,6 @@ class TerminalController: BaseTerminalController { delegate: self )) - // If our titlebar style is "hidden" we adjust the style appropriately - if (config.macosTitlebarStyle == "hidden") { - applyHiddenTitlebarStyle() - } - // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -1218,7 +1142,7 @@ class TerminalController: BaseTerminalController { override func titleDidChange(to: String) { super.titleDidChange(to: to) - guard let window = window as? TerminalWindow else { return } + guard let window = window as? LegacyTerminalWindow else { return } // Custom toolbar-based title used when titlebar tabs are enabled. if let toolbar = window.toolbar as? TerminalToolbar { diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift new file mode 100644 index 000000000..f2d3b9b85 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -0,0 +1,78 @@ +import AppKit + +class HiddenTitlebarTerminalWindow: TerminalWindow { + override func awakeFromNib() { + super.awakeFromNib() + + // Setup our initial style + reapplyHiddenStyle() + + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(fullscreenDidExit(_:)), + name: .fullscreenDidExit, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + rect.origin.y = 0 + rect.size.height = self.frame.height + return rect + } + + /// Apply the hidden titlebar style. + private func reapplyHiddenStyle() { + 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 + titleVisibility = .hidden + titlebarAppearsTransparent = true + + // Hide the traffic lights (window control buttons) + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. + 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 = contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + + // MARK: Notifications + + @objc private func fullscreenDidExit(_ notification: Notification) { + // Make sure they're talking about our window + guard let fullscreen = notification.object as? FullscreenBase else { return } + guard fullscreen.window == self else { return } + + // On exit we need to reapply the style because macOS breaks it usually. + // This is safe to call repeatedly so if its not broken its still safe. + reapplyHiddenStyle() + } +} diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift similarity index 95% rename from macos/Sources/Features/Terminal/TerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift index 0b43582f3..208e86343 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift @@ -1,9 +1,8 @@ import Cocoa -class TerminalWindow: NSWindow { - /// This is the key in UserDefaults to use for the default `level` value. - static let defaultLevelKey: String = "TerminalDefaultLevel" - +/// The terminal window that we originally had in Ghostty for a long time. Kind of a soupy mess +/// of styling. +class LegacyTerminalWindow: TerminalWindow { @objc dynamic var keyEquivalent: String = "" /// This is used to determine if certain elements should be drawn light or dark and should @@ -56,11 +55,6 @@ class TerminalWindow: NSWindow { } } - // Both of these must be true for windows without decorations to be able to - // still become key/main and receive events. - override var canBecomeKey: Bool { return true } - override var canBecomeMain: Bool { return true } - // MARK: - Lifecycle override func awakeFromNib() { @@ -77,8 +71,6 @@ class TerminalWindow: NSWindow { if titlebarTabs { generateToolbar() } - - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } deinit { @@ -135,25 +127,6 @@ class TerminalWindow: NSWindow { } } - // We override this so that with the hidden titlebar style the titlebar - // area is not draggable. - override var contentLayoutRect: CGRect { - var rect = super.contentLayoutRect - - // If we are using a hidden titlebar style, the content layout is the - // full frame making it so that it is not draggable. - if let controller = windowController as? TerminalController, - controller.derivedConfig.macosTitlebarStyle == "hidden" { - rect.origin.y = 0 - rect.size.height = self.frame.height - } - return rect - } - - // The window theme configuration from Ghostty. This is used to control some - // behaviors that don't look quite right in certain situations. - var windowTheme: TerminalWindowTheme? - // We only need to set this once, but need to do it after the window has been created in order // to determine if the theme is using a very dark background, in which case we don't want to // remove the effect view if the default tab bar is being used since the effect created in @@ -703,7 +676,7 @@ fileprivate class WindowDragView: NSView { fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. - private weak var terminalWindow: TerminalWindow? + private weak var terminalWindow: LegacyTerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() @@ -731,7 +704,7 @@ fileprivate class WindowButtonsBackdropView: NSView { fatalError("init(coder:) has not been implemented") } - init(window: TerminalWindow) { + init(window: LegacyTerminalWindow) { self.terminalWindow = window self.isLightTheme = window.isLightTheme @@ -746,10 +719,3 @@ fileprivate class WindowButtonsBackdropView: NSView { layer?.addSublayer(overlayLayer) } } - -enum TerminalWindowTheme: String { - case auto - case system - case light - case dark -} diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib similarity index 86% rename from macos/Sources/Features/Terminal/Terminal.xib rename to macos/Sources/Features/Terminal/Window Styles/Terminal.xib index 65b03b6eb..cfbb2221c 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib @@ -1,8 +1,8 @@ - + - + @@ -17,10 +17,10 @@ - + - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib new file mode 100644 index 000000000..eb4675657 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib new file mode 100644 index 000000000..61ed6f782 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib new file mode 100644 index 000000000..ada6959b3 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift new file mode 100644 index 000000000..74744a962 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -0,0 +1,75 @@ +import AppKit + +/// The base class for all standalone, "normal" terminal windows. This sets the basic +/// style and configuration of the window based on the app configuration. +class TerminalWindow: NSWindow { + /// This is the key in UserDefaults to use for the default `level` value. This is + /// used by the manual float on top menu item feature. + static let defaultLevelKey: String = "TerminalDefaultLevel" + + // MARK: NSWindow Overrides + + override func awakeFromNib() { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return } + + // All new windows are based on the app config at the time of creation. + let config = appDelegate.ghostty.config + + // If window decorations are disabled, remove our title + if (!config.windowDecorations) { styleMask.remove(.titled) } + + // Set our window positioning to coordinates if config value exists, otherwise + // fallback to original centering behavior + setInitialWindowPosition( + x: config.windowPositionX, + y: config.windowPositionY, + windowDecorations: config.windowDecorations) + + // If our traffic buttons should be hidden, then hide them + if config.macosWindowButtons == .hidden { + hideWindowButtons() + } + + // Get our saved level + level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + } + + // Both of these must be true for windows without decorations to be able to + // still become key/main and receive events. + override var canBecomeKey: Bool { return true } + override var canBecomeMain: Bool { return true } + + // MARK: Positioning And Styling + + /// This is called by the controller when there is a need to reset the window apperance. + func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {} + + private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + // If we don't have an X/Y then we try to use the previously saved window pos. + guard let x, let y else { + if (!LastWindowPosition.shared.restore(self)) { + center() + } + + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = screen ?? NSScreen.screens.first else { + center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + frame.height))) + } + + private func hideWindowButtons() { + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift new file mode 100644 index 000000000..4b3336874 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -0,0 +1,72 @@ +import AppKit + +class TransparentTitlebarTerminalWindow: TerminalWindow { + private var reapplyTimer: Timer? + + override func awakeFromNib() { + super.awakeFromNib() + } + + deinit { + reapplyTimer?.invalidate() + } + + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + if #available(macOS 26.0, *) { + syncAppearanceTahoe(surfaceConfig) + } else { + syncAppearanceVentura(surfaceConfig) + } + } + + @available(macOS 26.0, *) + private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + guard let titlebarBackgroundView else { return } + titlebarBackgroundView.isHidden = true + backgroundColor = NSColor(surfaceConfig.backgroundColor) + } + + @available(macOS 13.0, *) + private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + guard let titlebarContainer else { return } + + let configBgColor = NSColor(surfaceConfig.backgroundColor) + + // Set our window background color so it shows up + backgroundColor = configBgColor + + // Set the background color of our titlebar to match + titlebarContainer.wantsLayer = true + titlebarContainer.layer?.backgroundColor = configBgColor.withAlphaComponent(surfaceConfig.backgroundOpacity).cgColor + } + + private var titlebarBackgroundView: NSView? { + titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") + } + + private var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + return titlebarContainerView + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + return titlebarContainerView + } + + return nil + } + + private var titlebarContainerView: NSView? { + contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } +} diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 48284df74..121d9a62a 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -13,6 +13,19 @@ extension NSView { return false } +} + +// MARK: View Traversal and Search + +extension NSView { + /// Returns the absolute root view by walking up the superview chain. + var rootView: NSView { + var root: NSView = self + while let superview = root.superview { + root = superview + } + return root + } /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { @@ -54,4 +67,18 @@ extension NSView { return nil } + + /// Finds and returns the first view with the given class name starting from the absolute root of the view hierarchy. + /// This includes private views like title bar views. + func firstViewFromRoot(withClassName name: String) -> NSView? { + let root = rootView + + // Check if the root view itself matches + if String(describing: type(of: root)) == name { + return root + } + + // Otherwise search descendants + return root.firstDescendant(withClassName: name) + } } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6b10ceb40..3200608d0 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -78,10 +78,12 @@ class FullscreenBase { } @objc private func didEnterFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) delegate?.fullscreenDidChange() } @objc private func didExitFullScreenNotification(_ notification: Notification) { + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) delegate?.fullscreenDidChange() } } @@ -238,6 +240,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.window.makeFirstResponder(firstResponder) } + NotificationCenter.default.post(name: .fullscreenDidEnter, object: self) self.delegate?.fullscreenDidChange() } } @@ -268,7 +271,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. - if let window = window as? TerminalWindow, + if let window = window as? LegacyTerminalWindow, window.titlebarTabs { window.titlebarTabs = true } @@ -303,6 +306,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.makeKeyAndOrderFront(nil) // Notify the delegate + NotificationCenter.default.post(name: .fullscreenDidExit, object: self) self.delegate?.fullscreenDidChange() } @@ -422,3 +426,8 @@ class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { override var properties: Properties { Properties(paddedNotch: true) } } + +extension Notification.Name { + static let fullscreenDidEnter = Notification.Name("com.mitchellh.fullscreenDidEnter") + static let fullscreenDidExit = Notification.Name("com.mitchellh.fullscreenDidExit") +} From 7d02977482a3e8f6c1565c24ee26986038e0bf16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 07:00:40 -0700 Subject: [PATCH 214/245] macos: add NSView hierarchy debugging code --- .../TransparentTitlebarTerminalWindow.swift | 11 ++++- .../Helpers/Extensions/NSView+Extension.swift | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 4b3336874..d9d42365a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -1,14 +1,21 @@ import AppKit class TransparentTitlebarTerminalWindow: TerminalWindow { - private var reapplyTimer: Timer? + private var debugTimer: Timer? override func awakeFromNib() { super.awakeFromNib() + + // Debug timer to print view hierarchy every second + debugTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + print("=== TransparentTitlebarTerminalWindow Debug ===") + self?.contentView?.rootView.printViewHierarchy() + print("===============================================\n") + } } deinit { - reapplyTimer?.invalidate() + debugTimer?.invalidate() } override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 121d9a62a..14c07f6c9 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -82,3 +82,51 @@ extension NSView { return root.firstDescendant(withClassName: name) } } + +// MARK: Debug + +extension NSView { + /// Prints the view hierarchy from the root in a tree-like ASCII format. + /// + /// I need this because the "Capture View Hiearchy" was broken under some scenarios in + /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out + /// the view hierarchy without halting the program. + func printViewHierarchy() { + let root = rootView + print("View Hierarchy from Root:") + print(root.viewHierarchyDescription()) + } + + /// Returns a string representation of the view hierarchy in a tree-like format. + private func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { + var result = "" + + // Add the tree branch characters + result += indent + if !indent.isEmpty { + result += isLast ? "└── " : "├── " + } + + // Add the class name and optional identifier + let className = String(describing: type(of: self)) + result += className + + // Add identifier if present + if let identifier = self.identifier { + result += " (id: \(identifier.rawValue))" + } + + // Add frame info + result += " [frame: \(frame)]" + result += "\n" + + // Process subviews + for (index, subview) in subviews.enumerated() { + let isLastSubview = index == subviews.count - 1 + let newIndent = indent + (isLast ? " " : "│ ") + result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview) + } + + return result + } +} From 6ce7f612a66ea6e50884b426a4b0c3cfd6dd68be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 07:29:44 -0700 Subject: [PATCH 215/245] macos: transparent titlebar needs to be rehidden when tabs change --- .../TransparentTitlebarTerminalWindow.swift | 64 ++++++++++++++++--- .../Helpers/Extensions/NSView+Extension.swift | 30 +++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index d9d42365a..9f7b5e62f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -1,24 +1,39 @@ import AppKit class TransparentTitlebarTerminalWindow: TerminalWindow { - private var debugTimer: Timer? + // We need to restore our last synced appearance so that we can reapply + // the appearance in certain scenarios. + private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? + + // KVO observations + private var tabGroupWindowsObservation: NSKeyValueObservation? override func awakeFromNib() { super.awakeFromNib() - - // Debug timer to print view hierarchy every second - debugTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - print("=== TransparentTitlebarTerminalWindow Debug ===") - self?.contentView?.rootView.printViewHierarchy() - print("===============================================\n") - } + + // We need to observe the tab group because we need to redraw on + // tabbed window changes and there is no notification for that. + setupTabGroupObservation() } deinit { - debugTimer?.invalidate() + tabGroupWindowsObservation?.invalidate() } + override func becomeMain() { + // On macOS Tahoe, the tab bar redraws and restores non-transparency when + // switching tabs. To overcome this, we resync the appearance whenever this + // window becomes main (focused). + if #available(macOS 26.0, *), + let lastSurfaceConfig { + syncAppearance(lastSurfaceConfig) + } + } + + // MARK: Appearance + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + lastSurfaceConfig = surfaceConfig if #available(macOS 26.0, *) { syncAppearanceTahoe(surfaceConfig) } else { @@ -47,6 +62,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { titlebarContainer.layer?.backgroundColor = configBgColor.withAlphaComponent(surfaceConfig.backgroundOpacity).cgColor } + // MARK: View Finders + private var titlebarBackgroundView: NSView? { titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") } @@ -76,4 +93,33 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { private var titlebarContainerView: NSView? { contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") } + + // MARK: Tab Group Observation + + private func setupTabGroupObservation() { + // Remove existing observation if any + tabGroupWindowsObservation?.invalidate() + tabGroupWindowsObservation = nil + + // Check if tabGroup is available + guard let tabGroup else { return } + + // Set up KVO observation for the windows array. Whenever it changes + // we resync the appearance because it can cause macOS to redraw the + // tab bar. + tabGroupWindowsObservation = tabGroup.observe( + \.windows, + options: [.new] + ) { [weak self] _, _ in + // NOTE: At one point, I guarded this on only if we went from 0 to N + // or N to 0 under the assumption that the tab bar would only get + // replaced on those cases. This turned out to be false (Tahoe). + // It's cheap enough to always redraw this so we should just do it + // unconditionally. + + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } } diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 14c07f6c9..0cf71138d 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -118,6 +118,36 @@ extension NSView { // Add frame info result += " [frame: \(frame)]" + + // Add visual properties + var properties: [String] = [] + + // Hidden status + if isHidden { + properties.append("hidden") + } + + // Opaque status + properties.append(isOpaque ? "opaque" : "transparent") + + // Layer backing + if wantsLayer { + properties.append("layer-backed") + if let bgColor = layer?.backgroundColor { + let color = NSColor(cgColor: bgColor) + if let rgb = color?.usingColorSpace(.deviceRGB) { + properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)", + rgb.redComponent * 255, + rgb.greenComponent * 255, + rgb.blueComponent * 255, + rgb.alphaComponent)) + } else { + properties.append("bg:\(bgColor)") + } + } + } + + result += " [\(properties.joined(separator: ", "))]" result += "\n" // Process subviews From 3595b2a8476ff16dae2ba266ef672d4fa295a777 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 12:37:15 -0700 Subject: [PATCH 216/245] macos: transparent titlebar handles transparent background --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../Terminal/TerminalController.swift | 53 +++-------- .../Window Styles/TerminalWindow.swift | 95 ++++++++++++++++++- .../TransparentTitlebarTerminalWindow.swift | 22 ++++- .../Helpers/Extensions/Double+Extension.swift | 5 + 5 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/Double+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 594579744..c00d6119a 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; + A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -134,6 +135,7 @@ 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; + A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; @@ -464,6 +466,7 @@ isa = PBXGroup; children = ( A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + A50297342DFA0F3300B4E924 /* Double+Extension.swift */, A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, @@ -737,6 +740,7 @@ A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, + A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7fb8f9a07..5adef8ded 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -449,57 +449,34 @@ class TerminalController: BaseTerminalController { } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + // Let our window handle its own appearance if let window = window as? TerminalWindow { window.syncAppearance(surfaceConfig) } - guard let window = self.window as? LegacyTerminalWindow else { return } + guard let window else { return } - // Set our explicit appearance if we need to based on the configuration. - window.appearance = surfaceConfig.windowAppearance + if let window = window as? LegacyTerminalWindow { + // Update our window light/darkness based on our updated background color + window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor - // Update our window light/darkness based on our updated background color - window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + // Sync our zoom state for splits + window.surfaceIsZoomed = surfaceTree.zoomed != nil - // Sync our zoom state for splits - window.surfaceIsZoomed = surfaceTree.zoomed != nil + // Set the font for the window and tab titles. + if let titleFontName = surfaceConfig.windowTitleFontFamily { + window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) + } else { + window.titlebarFont = nil + } + } // If our window is not visible, then we do nothing. Some things such as blurring // have no effect if the window is not visible. Ultimately, we'll have this called // at some point when a surface becomes focused. guard window.isVisible else { return } - // Set the font for the window and tab titles. - if let titleFontName = surfaceConfig.windowTitleFontFamily { - window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) - } else { - window.titlebarFont = nil - } - - // If we have window transparency then set it transparent. Otherwise set it opaque. - - // Window transparency only takes effect if our window is not native fullscreen. - // In native fullscreen we disable transparency/opacity because the background - // becomes gray and widgets show through. - if (!window.styleMask.contains(.fullScreen) && - surfaceConfig.backgroundOpacity < 1 - ) { - window.isOpaque = false - - // This is weird, but we don't use ".clear" because this creates a look that - // matches Terminal.app much more closer. This lets users transition from - // Terminal.app more easily. - window.backgroundColor = .white.withAlphaComponent(0.001) - - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) - } else { - window.isOpaque = true - window.backgroundColor = .windowBackgroundColor - } - - window.hasShadow = surfaceConfig.macosWindowShadow - - guard window.hasStyledTabs else { return } + guard let window = window as? LegacyTerminalWindow, window.hasStyledTabs else { return } // Our background color depends on if our focused surface borders the top or not. // If it does, we match the focused surface. If it doesn't, we use the app diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 74744a962..daf5b4554 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -1,4 +1,5 @@ import AppKit +import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic /// style and configuration of the window based on the app configuration. @@ -7,6 +8,14 @@ class TerminalWindow: NSWindow { /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" + /// The configuration derived from the Ghostty config so we don't need to rely on references. + private var derivedConfig: DerivedConfig? + + /// Gets the terminal controller from the window controller. + var terminalController: TerminalController? { + windowController as? TerminalController + } + // MARK: NSWindow Overrides override func awakeFromNib() { @@ -15,6 +24,9 @@ class TerminalWindow: NSWindow { // All new windows are based on the app config at the time of creation. let config = appDelegate.ghostty.config + // Setup our initial config + derivedConfig = .init(config) + // If window decorations are disabled, remove our title if (!config.windowDecorations) { styleMask.remove(.titled) } @@ -42,7 +54,71 @@ class TerminalWindow: NSWindow { // MARK: Positioning And Styling /// This is called by the controller when there is a need to reset the window apperance. - func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {} + func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + // If our window is not visible, then we do nothing. Some things such as blurring + // have no effect if the window is not visible. Ultimately, we'll have this called + // at some point when a surface becomes focused. + guard isVisible else { return } + + // Basic properties + appearance = surfaceConfig.windowAppearance + hasShadow = surfaceConfig.macosWindowShadow + + // Window transparency only takes effect if our window is not native fullscreen. + // In native fullscreen we disable transparency/opacity because the background + // becomes gray and widgets show through. + if !styleMask.contains(.fullScreen) && + surfaceConfig.backgroundOpacity < 1 + { + isOpaque = false + + // This is weird, but we don't use ".clear" because this creates a look that + // matches Terminal.app much more closer. This lets users transition from + // Terminal.app more easily. + backgroundColor = .white.withAlphaComponent(0.001) + + if let appDelegate = NSApp.delegate as? AppDelegate { + ghostty_set_window_background_blur( + appDelegate.ghostty.app, + Unmanaged.passUnretained(self).toOpaque()) + } + } else { + isOpaque = true + + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) + self.backgroundColor = backgroundColor.withAlphaComponent(1) + } + } + + /// The preferred window background color. The current window background color may not be set + /// to this, since this is dynamic based on the state of the surface tree. + /// + /// This background color will include alpha transparency if set. If the caller doesn't want that, + /// change the alpha channel again manually. + var preferredBackgroundColor: NSColor? { + if let terminalController, !terminalController.surfaceTree.isEmpty { + // If our focused surface borders the top then we prefer its background color + if let focusedSurface = terminalController.focusedSurface, + let treeRoot = terminalController.surfaceTree.root, + let focusedNode = treeRoot.node(view: focusedSurface), + treeRoot.spatial().doesBorder(side: .up, from: focusedNode), + let backgroundcolor = focusedSurface.backgroundColor { + let alpha = focusedSurface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return NSColor(backgroundcolor).withAlphaComponent(alpha) + } + + // Doesn't border the top or we don't have a focused surface, so + // we try to match the top-left surface. + let topLeftSurface = terminalController.surfaceTree.root?.leftmostLeaf() + if let topLeftBgColor = topLeftSurface?.backgroundColor { + let alpha = topLeftSurface?.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) ?? 1 + return NSColor(topLeftBgColor).withAlphaComponent(alpha) + } + } + + let alpha = derivedConfig?.backgroundOpacity.clamped(to: 0.001...1) ?? 1 + return derivedConfig?.backgroundColor.withAlphaComponent(alpha) + } private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { // If we don't have an X/Y then we try to use the previously saved window pos. @@ -72,4 +148,21 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + // MARK: Config + + struct DerivedConfig { + let backgroundColor: NSColor + let backgroundOpacity: Double + + init() { + self.backgroundColor = NSColor.windowBackgroundColor + self.backgroundOpacity = 1 + } + + init(_ config: Ghostty.Config) { + self.backgroundColor = NSColor(config.backgroundColor) + self.backgroundOpacity = config.backgroundOpacity + } + } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 9f7b5e62f..dfe2d35a1 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -33,6 +33,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // MARK: Appearance override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) + lastSurfaceConfig = surfaceConfig if #available(macOS 26.0, *) { syncAppearanceTahoe(surfaceConfig) @@ -43,9 +45,23 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { @available(macOS 26.0, *) private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { - guard let titlebarBackgroundView else { return } - titlebarBackgroundView.isHidden = true - backgroundColor = NSColor(surfaceConfig.backgroundColor) + // When we have transparency, we need to set the titlebar background to match the + // window background but with opacity. The window background is set using the + // "preferred background color" property. + // + // As an inverse, if we don't have transparency, we don't bother with this because + // the window background will be set to the correct color so we can just hide the + // titlebar completely and we're good to go. + if !isOpaque { + if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { + titlebarView.wantsLayer = true + titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + } + } + + // In all cases, we have to hide the background view since this has multiple subviews + // that force a background color. + titlebarBackgroundView?.isHidden = true } @available(macOS 13.0, *) diff --git a/macos/Sources/Helpers/Extensions/Double+Extension.swift b/macos/Sources/Helpers/Extensions/Double+Extension.swift new file mode 100644 index 000000000..8d1151bac --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Double+Extension.swift @@ -0,0 +1,5 @@ +extension Double { + func clamped(to range: ClosedRange) -> Double { + return Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} From dfa7a114def0f4a9617ef3433dd67c3bb288b924 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 13:12:25 -0700 Subject: [PATCH 217/245] macos: make transparent titlebars robust against show/hide tabs --- .../TransparentTitlebarTerminalWindow.swift | 81 +++++++++++++------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index dfe2d35a1..98dd9f834 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -1,32 +1,41 @@ import AppKit +/// A terminal window style that provides a transparent titlebar effect. With this effect, the titlebar +/// matches the background color of the window. class TransparentTitlebarTerminalWindow: TerminalWindow { - // We need to restore our last synced appearance so that we can reapply - // the appearance in certain scenarios. + /// Stores the last surface configuration to reapply appearance when needed. + /// This is necessary because various macOS operations (tab switching, tab bar + /// visibility changes) can reset the titlebar appearance. private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? - // KVO observations + /// KVO observation for tab group window changes. private var tabGroupWindowsObservation: NSKeyValueObservation? + private var tabBarVisibleObservation: NSKeyValueObservation? override func awakeFromNib() { super.awakeFromNib() - // We need to observe the tab group because we need to redraw on - // tabbed window changes and there is no notification for that. - setupTabGroupObservation() + // Setup all the KVO we will use, see the docs for the respective functions + // to learn why we need KVO. + setupKVO() } deinit { tabGroupWindowsObservation?.invalidate() + tabBarVisibleObservation?.invalidate() } override func becomeMain() { - // On macOS Tahoe, the tab bar redraws and restores non-transparency when - // switching tabs. To overcome this, we resync the appearance whenever this - // window becomes main (focused). - if #available(macOS 26.0, *), - let lastSurfaceConfig { - syncAppearance(lastSurfaceConfig) + guard let lastSurfaceConfig else { return } + syncAppearance(lastSurfaceConfig) + + // This is a nasty edge case. If we're going from 2 to 1 tab and the tab bar + // automatically disappears, then we need to resync our appearance because + // at some point macOS replaces the tab views. + if tabGroup?.windows.count ?? 0 == 2 { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + self?.syncAppearance(self?.lastSurfaceConfig ?? lastSurfaceConfig) + } } } @@ -34,8 +43,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { super.syncAppearance(surfaceConfig) - + + // Save our config in case we need to reapply lastSurfaceConfig = surfaceConfig + + // Everytime we change appearance, set KVO up again in case any of our + // references changed (e.g. tabGroup is new). + setupKVO() + if #available(macOS 26.0, *) { syncAppearanceTahoe(surfaceConfig) } else { @@ -67,15 +82,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { @available(macOS 13.0, *) private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { guard let titlebarContainer else { return } - - let configBgColor = NSColor(surfaceConfig.backgroundColor) - - // Set our window background color so it shows up - backgroundColor = configBgColor - - // Set the background color of our titlebar to match titlebarContainer.wantsLayer = true - titlebarContainer.layer?.backgroundColor = configBgColor.withAlphaComponent(surfaceConfig.backgroundOpacity).cgColor + titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor } // MARK: View Finders @@ -111,7 +119,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } // MARK: Tab Group Observation - + + private func setupKVO() { + // See the docs for the respective setup functions for why. + setupTabGroupObservation() + setupTabBarVisibleObservation() + } + + /// Monitors the tabGroup windows value for any changes and resyncs the appearance on change. + /// This is necessary because when the windows change, the tab bar and titlebar are recreated + /// which breaks our changes. private func setupTabGroupObservation() { // Remove existing observation if any tabGroupWindowsObservation?.invalidate() @@ -126,7 +143,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { tabGroupWindowsObservation = tabGroup.observe( \.windows, options: [.new] - ) { [weak self] _, _ in + ) { [weak self] _, change in // NOTE: At one point, I guarded this on only if we went from 0 to N // or N to 0 under the assumption that the tab bar would only get // replaced on those cases. This turned out to be false (Tahoe). @@ -138,4 +155,22 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { self.syncAppearance(lastSurfaceConfig) } } + + /// Monitors the tab bar for visibility. This lets the "Show/Hide Tab Bar" manual menu item + /// to not break our appearance. + private func setupTabBarVisibleObservation() { + // Remove existing observation if any + tabBarVisibleObservation?.invalidate() + tabBarVisibleObservation = nil + + // Set up KVO observation for isTabBarVisible + tabBarVisibleObservation = tabGroup?.observe( + \.isTabBarVisible, + options: [.new] + ) { [weak self] _, change in + guard let self else { return } + guard let lastSurfaceConfig else { return } + self.syncAppearance(lastSurfaceConfig) + } + } } From a804dab28845675271afa13a80e23fc524386c78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 14:35:49 -0700 Subject: [PATCH 218/245] macos: native terminal style works with new subclasses --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +- .../Terminal/BaseTerminalController.swift | 6 +- .../Terminal/TerminalController.swift | 54 ++++++++---- .../TerminalTransparentTitlebar.xib | 2 +- .../Window Styles/TerminalWindow.swift | 88 +++++++++++++++++++ .../TransparentTitlebarTerminalWindow.swift | 2 + .../Helpers/Extensions/NSView+Extension.swift | 15 ++++ 7 files changed, 151 insertions(+), 22 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index c00d6119a..5f5b3013c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -402,12 +402,12 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, - A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, - A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, - A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, + A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, + A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, + A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, ); path = "Window Styles"; diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e91199358..849f13b34 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -568,7 +568,11 @@ class BaseTerminalController: NSWindowController, // Not zoomed or different node zoomed, zoom this node surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) } - + + // Move focus to our window. Importantly this ensures that if we click the + // reset zoom button in a tab bar of an unfocused tab that we become focused. + window?.makeKeyAndOrderFront(nil) + // Ensure focus stays on the target surface. We lose focus when we do // this so we need to grab it again. DispatchQueue.main.async { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5adef8ded..082a3c806 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -14,6 +14,7 @@ class TerminalController: BaseTerminalController { guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { + case "native": "Terminal" case "tabs": defaultValue case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" @@ -132,6 +133,9 @@ class TerminalController: BaseTerminalController { if let window = window as? LegacyTerminalWindow { window.surfaceIsZoomed = to.zoomed != nil } + if let window = window as? TerminalWindow { + window.surfaceIsZoomed2 = to.zoomed != nil + } // If our surface tree is now nil then we close our window. if (to.isEmpty) { @@ -395,28 +399,44 @@ class TerminalController: BaseTerminalController { /// changes, when a window is closed, and when tabs are reordered /// with the mouse. func relabelTabs() { - // Reset this to false. It'll be set back to true later. - tabListenForFrame = false - - guard let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] else { return } - // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. - tabListenForFrame = windows.count > 1 + tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1 - for (tab, window) in zip(1..., windows) { - // We need to clear any windows beyond this because they have had - // a keyEquivalent set previously. - guard tab <= 9 else { - window.keyEquivalent = "" - continue + if let windows = window?.tabbedWindows as? [TerminalWindow] { + for (tab, window) in zip(1..., windows) { + // We need to clear any windows beyond this because they have had + // a keyEquivalent set previously. + guard tab <= 9 else { + window.keyEquivalent2 = "" + continue + } + + let action = "goto_tab:\(tab)" + if let equiv = ghostty.config.keyboardShortcut(for: action) { + window.keyEquivalent2 = "\(equiv)" + } else { + window.keyEquivalent2 = "" + } } + } - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent = "\(equiv)" - } else { - window.keyEquivalent = "" + // Legacy + if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] { + for (tab, window) in zip(1..., windows) { + // We need to clear any windows beyond this because they have had + // a keyEquivalent set previously. + guard tab <= 9 else { + window.keyEquivalent = "" + continue + } + + let action = "goto_tab:\(tab)" + if let equiv = ghostty.config.keyboardShortcut(for: action) { + window.keyEquivalent = "\(equiv)" + } else { + window.keyEquivalent = "" + } } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib index ada6959b3..25922e2f3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib @@ -17,7 +17,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index daf5b4554..9fac08c4b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic @@ -42,6 +43,14 @@ class TerminalWindow: NSWindow { hideWindowButtons() } + // Setup the accessory view for tabs that shows our keyboard shortcuts, + // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues + // where buttons were not clickable. + let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + stackView.setHuggingPriority(.defaultHigh, for: .horizontal) + stackView.spacing = 3 + tab.accessoryView = stackView + // Get our saved level level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } @@ -51,6 +60,85 @@ class TerminalWindow: NSWindow { override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + override func becomeKey() { + super.becomeKey() + resetZoomTabButton.contentTintColor = .controlAccentColor + } + + override func resignKey() { + super.resignKey() + resetZoomTabButton.contentTintColor = .secondaryLabelColor + } + + override func mergeAllWindows(_ sender: Any?) { + super.mergeAllWindows(sender) + + // It takes an event loop cycle to merge all the windows so we set a + // short timer to relabel the tabs (issue #1902) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.terminalController?.relabelTabs() + } + } + + // MARK: Tab Key Equivalents + + // TODO: rename once Legacy window removes + var keyEquivalent2: String? = nil { + didSet { + // When our key equivalent is set, we must update the tab label. + guard let keyEquivalent2 else { + keyEquivalentLabel.attributedStringValue = NSAttributedString() + return + } + + keyEquivalentLabel.attributedStringValue = NSAttributedString( + string: "\(keyEquivalent2) ", + attributes: [ + .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ]) + } + } + + /// The label that has the key equivalent for tab views. + private lazy var keyEquivalentLabel: NSTextField = { + let label = NSTextField(labelWithAttributedString: NSAttributedString()) + label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) + label.postsFrameChangedNotifications = true + return label + }() + + // MARK: Surface Zoom + + /// Set to true if a surface is currently zoomed to show the reset zoom button. + var surfaceIsZoomed2: Bool = false { + didSet { + // Show/hide our reset zoom button depending on if we're zoomed. + // We want to show it if we are zoomed. + resetZoomTabButton.isHidden = !surfaceIsZoomed2 + } + } + + private lazy var resetZoomTabButton: NSButton = generateResetZoomButton() + + private func generateResetZoomButton() -> NSButton { + let button = NSButton() + button.isHidden = true + button.target = terminalController + button.action = #selector(TerminalController.splitZoom(_:)) + button.isBordered = false + button.allowsExpansionToolTips = true + button.toolTip = "Reset Zoom" + button.contentTintColor = .controlAccentColor + button.state = .on + button.image = NSImage(named:"ResetZoom") + button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(equalToConstant: 20).isActive = true + button.heightAnchor.constraint(equalToConstant: 20).isActive = true + return button + } + // MARK: Positioning And Styling /// This is called by the controller when there is a need to reset the window apperance. diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 98dd9f834..ada84ff12 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -26,6 +26,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } override func becomeMain() { + super.becomeMain() + guard let lastSurfaceConfig else { return } syncAppearance(lastSurfaceConfig) diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 0cf71138d..aa56fe32e 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -27,6 +27,21 @@ extension NSView { return root } + /// Checks if a view contains another view in its hierarchy. + func contains(_ view: NSView) -> Bool { + if self == view { + return true + } + + for subview in subviews { + if subview.contains(view) { + return true + } + } + + return false + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { From 63e56d0402a04696fbaff2eb7e5dbbf8f6257740 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 15:08:12 -0700 Subject: [PATCH 219/245] macos: titlebar fonts work with new terminal window --- .../Terminal/TerminalController.swift | 11 ++++ .../Window Styles/LegacyTerminalWindow.swift | 42 ------------- .../Window Styles/TerminalWindow.swift | 62 ++++++++++++++++++- .../TransparentTitlebarTerminalWindow.swift | 26 -------- 4 files changed, 72 insertions(+), 69 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 082a3c806..cf771d556 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -471,6 +471,17 @@ class TerminalController: BaseTerminalController { private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance if let window = window as? TerminalWindow { + // Sync our zoom state for splits + window.surfaceIsZoomed2 = surfaceTree.zoomed != nil + + // Set the font for the window and tab titles. + if let titleFontName = surfaceConfig.windowTitleFontFamily { + window.titlebarFont2 = NSFont(name: titleFontName, size: NSFont.systemFontSize) + } else { + window.titlebarFont2 = nil + } + + // Call this last in case it uses any of the properties above. window.syncAppearance(surfaceConfig) } diff --git a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift index 208e86343..e63681463 100644 --- a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift @@ -77,48 +77,6 @@ class LegacyTerminalWindow: TerminalWindow { bindings.forEach() { $0.invalidate() } } - // MARK: Titlebar Helpers - // These helpers are generic to what we're trying to achieve (i.e. titlebar - // style tabs, titlebar styling, etc.). They're just here to make it easier. - - private var titlebarContainer: NSView? { - // If we aren't fullscreen then the titlebar container is part of our window. - if !styleMask.contains(.fullScreen) { - guard let view = contentView?.superview ?? contentView else { return nil } - return titlebarContainerView(in: view) - } - - // If we are fullscreen, the titlebar container view is part of a separate - // "fullscreen window", we need to find the window and then get the view. - for window in NSApplication.shared.windows { - // This is the private window class that contains the toolbar - guard window.className == "NSToolbarFullScreenWindow" else { continue } - - // The parent will match our window. This is used to filter the correct - // fullscreen window if we have multiple. - guard window.parent == self else { continue } - - guard let view = window.contentView else { continue } - return titlebarContainerView(in: view) - } - - return nil - } - - private func titlebarContainerView(in view: NSView) -> NSView? { - if view.className == "NSTitlebarContainerView" { - return view - } - - for subview in view.subviews { - if let found = titlebarContainerView(in: subview) { - return found - } - } - - return nil - } - // MARK: - NSWindow override var title: String { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9fac08c4b..1e9ca1b01 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -72,7 +72,7 @@ class TerminalWindow: NSWindow { override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) - + // It takes an event loop cycle to merge all the windows so we set a // short timer to relabel the tabs (issue #1902) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in @@ -139,6 +139,66 @@ class TerminalWindow: NSWindow { return button } + // MARK: Title Text + + override var title: String { + didSet { + // Whenever we change the window title we must also update our + // tab title if we're using custom fonts. + tab.attributedTitle = attributedTitle + } + } + + // Used to set the titlebar font. + var titlebarFont2: NSFont? { + didSet { + let font = titlebarFont2 ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) + + titlebarTextField?.font = font + tab.attributedTitle = attributedTitle + } + } + + // Find the NSTextField responsible for displaying the titlebar's title. + private var titlebarTextField: NSTextField? { + titlebarContainer? + .firstDescendant(withClassName: "NSTitlebarView")? + .firstDescendant(withClassName: "NSTextField") as? NSTextField + } + + // Return a styled representation of our title property. + private var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont2 else { return nil } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: titlebarFont, + .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, + ] + return NSAttributedString(string: title, attributes: attributes) + } + + var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") + } + + return nil + } + // MARK: Positioning And Styling /// This is called by the controller when there is a need to reset the window apperance. diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index ada84ff12..f949b6094 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -94,32 +94,6 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView") } - private var titlebarContainer: NSView? { - // If we aren't fullscreen then the titlebar container is part of our window. - if !styleMask.contains(.fullScreen) { - return titlebarContainerView - } - - // If we are fullscreen, the titlebar container view is part of a separate - // "fullscreen window", we need to find the window and then get the view. - for window in NSApplication.shared.windows { - // This is the private window class that contains the toolbar - guard window.className == "NSToolbarFullScreenWindow" else { continue } - - // The parent will match our window. This is used to filter the correct - // fullscreen window if we have multiple. - guard window.parent == self else { continue } - - return titlebarContainerView - } - - return nil - } - - private var titlebarContainerView: NSView? { - contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView") - } - // MARK: Tab Group Observation private func setupKVO() { From e5cb33e9114a7b0818043c0f210bc2039b10cc00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 15:09:42 -0700 Subject: [PATCH 220/245] typos --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- macos/Sources/Helpers/Extensions/NSView+Extension.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1e9ca1b01..eb4b4a6da 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -201,7 +201,7 @@ class TerminalWindow: NSWindow { // MARK: Positioning And Styling - /// This is called by the controller when there is a need to reset the window apperance. + /// This is called by the controller when there is a need to reset the window appearance. func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // If our window is not visible, then we do nothing. Some things such as blurring // have no effect if the window is not visible. Ultimately, we'll have this called diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index aa56fe32e..b958130f1 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -103,7 +103,7 @@ extension NSView { extension NSView { /// Prints the view hierarchy from the root in a tree-like ASCII format. /// - /// I need this because the "Capture View Hiearchy" was broken under some scenarios in + /// I need this because the "Capture View Hierarchy" was broken under some scenarios in /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out /// the view hierarchy without halting the program. func printViewHierarchy() { From ccfd33022f2402b294aafdc9ce8224b15069a5f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 11 Jun 2025 15:15:06 -0700 Subject: [PATCH 221/245] macos: only titlebar tabs uses legacy styling now --- macos/Sources/Features/Terminal/TerminalController.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cf771d556..86b47b9bd 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -7,15 +7,13 @@ import GhosttyKit /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { - //NOTE(mitchellh): switch to this when we've transitioned all legacy logic out - //let defaultValue = "Terminal" - let defaultValue = "TerminalLegacy" + let defaultValue = "Terminal" guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { case "native": "Terminal" - case "tabs": defaultValue + case "tabs": "TerminalLegacy" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" default: defaultValue From fd785f98bb5d794645079918df2828c4e0e09e1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 11:36:38 -0700 Subject: [PATCH 222/245] macos: titlebar tabs uses legacy window for now --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++ .../Terminal/TerminalController.swift | 56 +++-------- .../Window Styles/LegacyTerminalWindow.swift | 93 ++----------------- .../TabsTitlebarTerminalWindow.swift | 58 ++++++++++++ .../Window Styles/TerminalHiddenTitlebar.xib | 2 +- .../Window Styles/TerminalTabsTitlebar.xib | 31 +++++++ .../Window Styles/TerminalWindow.swift | 12 +-- 7 files changed, 124 insertions(+), 136 deletions(-) create mode 100644 macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift create mode 100644 macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5f5b3013c..3f1cddf44 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; + A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */; }; + A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */; }; A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; @@ -137,6 +139,8 @@ 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; + A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsTitlebarTerminalWindow.swift; sourceTree = ""; }; + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebar.xib; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; @@ -404,10 +408,12 @@ A59630992AEE1C6400D64628 /* Terminal.xib */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, + A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, ); path = "Window Styles"; @@ -694,6 +700,7 @@ A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, + A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -761,6 +768,7 @@ A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, + A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 86b47b9bd..d59f71619 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -13,6 +13,7 @@ class TerminalController: BaseTerminalController { let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { case "native": "Terminal" + //case "tabs": "TerminalTabsTitlebar" case "tabs": "TerminalLegacy" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" @@ -128,11 +129,8 @@ class TerminalController: BaseTerminalController { invalidateRestorableState() // Update our zoom state - if let window = window as? LegacyTerminalWindow { - window.surfaceIsZoomed = to.zoomed != nil - } if let window = window as? TerminalWindow { - window.surfaceIsZoomed2 = to.zoomed != nil + window.surfaceIsZoomed = to.zoomed != nil } // If our surface tree is now nil then we close our window. @@ -418,25 +416,6 @@ class TerminalController: BaseTerminalController { } } } - - // Legacy - if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] { - for (tab, window) in zip(1..., windows) { - // We need to clear any windows beyond this because they have had - // a keyEquivalent set previously. - guard tab <= 9 else { - window.keyEquivalent = "" - continue - } - - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent = "\(equiv)" - } else { - window.keyEquivalent = "" - } - } - } } private func fixTabBar() { @@ -470,13 +449,13 @@ class TerminalController: BaseTerminalController { // Let our window handle its own appearance if let window = window as? TerminalWindow { // Sync our zoom state for splits - window.surfaceIsZoomed2 = surfaceTree.zoomed != nil + window.surfaceIsZoomed = surfaceTree.zoomed != nil // Set the font for the window and tab titles. if let titleFontName = surfaceConfig.windowTitleFontFamily { - window.titlebarFont2 = NSFont(name: titleFontName, size: NSFont.systemFontSize) + window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { - window.titlebarFont2 = nil + window.titlebarFont = nil } // Call this last in case it uses any of the properties above. @@ -488,16 +467,6 @@ class TerminalController: BaseTerminalController { if let window = window as? LegacyTerminalWindow { // Update our window light/darkness based on our updated background color window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor - - // Sync our zoom state for splits - window.surfaceIsZoomed = surfaceTree.zoomed != nil - - // Set the font for the window and tab titles. - if let titleFontName = surfaceConfig.windowTitleFontFamily { - window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) - } else { - window.titlebarFont = nil - } } // If our window is not visible, then we do nothing. Some things such as blurring @@ -916,18 +885,15 @@ class TerminalController: BaseTerminalController { } // TODO: remove - if let window = window as? LegacyTerminalWindow { + if let window = window as? LegacyTerminalWindow, + config.macosTitlebarStyle == "tabs" { // Handle titlebar tabs config option. Something about what we do while setting up the // titlebar tabs interferes with the window restore process unless window.tabbingMode // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - if (config.macosTitlebarStyle == "tabs") { - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic - } - } else if (config.macosTitlebarStyle == "transparent") { - window.transparentTabs = true + window.tabbingMode = .preferred + window.titlebarTabs = true + DispatchQueue.main.async { + window.tabbingMode = .automatic } if window.hasStyledTabs { diff --git a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift index e63681463..89afbf72f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift @@ -3,12 +3,16 @@ import Cocoa /// The terminal window that we originally had in Ghostty for a long time. Kind of a soupy mess /// of styling. class LegacyTerminalWindow: TerminalWindow { - @objc dynamic var keyEquivalent: String = "" - /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. var isLightTheme: Bool = false + override var surfaceIsZoomed: Bool { + didSet { + updateResetZoomTitlebarButtonVisibility() + } + } + lazy var titlebarColor: NSColor = backgroundColor { didSet { guard let titlebarContainer else { return } @@ -17,33 +21,6 @@ class LegacyTerminalWindow: TerminalWindow { } } - private lazy var keyEquivalentLabel: NSTextField = { - let label = NSTextField(labelWithAttributedString: NSAttributedString()) - label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) - label.postsFrameChangedNotifications = true - - return label - }() - - private lazy var bindings = [ - observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in - guard let tabGroup = self?.tabGroup else { return } - - self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed - self?.updateResetZoomTitlebarButtonVisibility() - }, - - observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), - .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes) - - self?.keyEquivalentLabel.attributedStringValue = attributedString - }, - ] - // false if all three traffic lights are missing/hidden, otherwise true private var hasWindowButtons: Bool { get { @@ -60,31 +37,13 @@ class LegacyTerminalWindow: TerminalWindow { override func awakeFromNib() { super.awakeFromNib() - _ = bindings - - // Create the tab accessory view that houses the key-equivalent label and optional un-zoom button - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) - stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 - tab.accessoryView = stackView - if titlebarTabs { generateToolbar() } } - deinit { - bindings.forEach() { $0.invalidate() } - } - // MARK: - NSWindow - override var title: String { - didSet { - tab.attributedTitle = attributedTitle - } - } - // We only need to set this once, but need to do it after the window has been created in order // to determine if the theme is using a very dark background, in which case we don't want to // remove the effect view if the default tab bar is being used since the effect created in @@ -101,7 +60,6 @@ class LegacyTerminalWindow: TerminalWindow { super.becomeKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .controlAccentColor resetZoomToolbarButton.contentTintColor = .controlAccentColor tab.attributedTitle = attributedTitle } @@ -110,7 +68,6 @@ class LegacyTerminalWindow: TerminalWindow { super.resignKey() updateNewTabButtonOpacity() - resetZoomTabButton.contentTintColor = .secondaryLabelColor resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor tab.attributedTitle = attributedTitle } @@ -284,16 +241,8 @@ class LegacyTerminalWindow: TerminalWindow { // MARK: - Split Zoom Button - @objc dynamic var surfaceIsZoomed: Bool = false - private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - private lazy var resetZoomTabButton: NSButton = { - let button = generateResetZoomButton() - button.action = #selector(selectTabAndZoom(_:)) - return button - }() - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { guard let titlebarContainer else { return nil } let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) @@ -356,37 +305,13 @@ class LegacyTerminalWindow: TerminalWindow { // MARK: - Titlebar Font // Used to set the titlebar font. - var titlebarFont: NSFont? { + override var titlebarFont: NSFont? { didSet { - let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) - - titlebarTextField?.font = font - tab.attributedTitle = attributedTitle - - if let toolbar = toolbar as? TerminalToolbar { - toolbar.titleFont = font - } + guard let toolbar = toolbar as? TerminalToolbar else { return } + toolbar.titleFont = titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize) } } - // Find the NSTextField responsible for displaying the titlebar's title. - private var titlebarTextField: NSTextField? { - guard let titlebarView = titlebarContainer?.subviews - .first(where: { $0.className == "NSTitlebarView" }) else { return nil } - return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField - } - - // Return a styled representation of our title property. - private var attributedTitle: NSAttributedString? { - guard let titlebarFont else { return nil } - - let attributes: [NSAttributedString.Key: Any] = [ - .font: titlebarFont, - .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, - ] - return NSAttributedString(string: title, attributes: attributes) - } - // MARK: - Titlebar Tabs private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil diff --git a/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift new file mode 100644 index 000000000..858b54829 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI + +class TabsTitlebarTerminalWindow: TerminalWindow, NSToolbarDelegate { + override func awakeFromNib() { + super.awakeFromNib() + + // We must hide the title since we're going to be moving tabs into + // the titlebar which have their own title. + titleVisibility = .hidden + + // Create a toolbar + let toolbar = NSToolbar(identifier: "TerminalToolbar") + toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) + self.toolbar = toolbar + //toolbarStyle = .unifiedCompact + } + + // MARK: NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.title, .flexibleSpace, .space] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.flexibleSpace, .title, .flexibleSpace] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + switch itemIdentifier { + case .title: + let item = NSToolbarItem(itemIdentifier: .title) + item.view = NSHostingView(rootView: TitleItem()) + item.visibilityPriority = .user + item.isEnabled = true + return item + default: + return NSToolbarItem(itemIdentifier: itemIdentifier) + } + } + +} + +extension NSToolbarItem.Identifier { + /// Displays the title of the window + static let title = NSToolbarItem.Identifier("Title") +} + +extension TabsTitlebarTerminalWindow { + struct TitleItem: View { + var body: some View { + Text("HELLO THIS IS A PRETTY LONG TITLE") + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib index eb4675657..1a2a6c192 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -17,7 +17,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib new file mode 100644 index 000000000..779b6e094 --- /dev/null +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index eb4b4a6da..a0e18d283 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -111,11 +111,11 @@ class TerminalWindow: NSWindow { // MARK: Surface Zoom /// Set to true if a surface is currently zoomed to show the reset zoom button. - var surfaceIsZoomed2: Bool = false { + var surfaceIsZoomed: Bool = false { didSet { // Show/hide our reset zoom button depending on if we're zoomed. // We want to show it if we are zoomed. - resetZoomTabButton.isHidden = !surfaceIsZoomed2 + resetZoomTabButton.isHidden = !surfaceIsZoomed } } @@ -150,9 +150,9 @@ class TerminalWindow: NSWindow { } // Used to set the titlebar font. - var titlebarFont2: NSFont? { + var titlebarFont: NSFont? { didSet { - let font = titlebarFont2 ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) + let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) titlebarTextField?.font = font tab.attributedTitle = attributedTitle @@ -167,8 +167,8 @@ class TerminalWindow: NSWindow { } // Return a styled representation of our title property. - private var attributedTitle: NSAttributedString? { - guard let titlebarFont = titlebarFont2 else { return nil } + var attributedTitle: NSAttributedString? { + guard let titlebarFont = titlebarFont else { return nil } let attributes: [NSAttributedString.Key: Any] = [ .font: titlebarFont, From 5877913ab8104d9887efb6dbacee8c2bc7b39200 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 12:02:31 -0700 Subject: [PATCH 223/245] macoS: Split out terminal tabs for ventura vs tahoe --- macos/Ghostty.xcodeproj/project.pbxproj | 32 ++--- .../Terminal/TerminalController.swift | 114 +++--------------- .../HiddenTitlebarTerminalWindow.swift | 29 +++-- .../Window Styles/TerminalHiddenTitlebar.xib | 2 +- ...gacy.xib => TerminalTabsTitlebarTahoe.xib} | 2 +- ...ar.xib => TerminalTabsTitlebarVentura.xib} | 4 +- .../Window Styles/TerminalWindow.swift | 2 +- ... => TitlebarTabsTahoeTerminalWindow.swift} | 5 +- ...> TitlebarTabsVenturaTerminalWindow.swift} | 72 +++++++++-- macos/Sources/Helpers/Fullscreen.swift | 3 +- 10 files changed, 120 insertions(+), 145 deletions(-) rename macos/Sources/Features/Terminal/Window Styles/{TerminalLegacy.xib => TerminalTabsTitlebarTahoe.xib} (94%) rename macos/Sources/Features/Terminal/Window Styles/{TerminalTabsTitlebar.xib => TerminalTabsTitlebarVentura.xib} (91%) rename macos/Sources/Features/Terminal/Window Styles/{TabsTitlebarTerminalWindow.swift => TitlebarTabsTahoeTerminalWindow.swift} (90%) rename macos/Sources/Features/Terminal/Window Styles/{LegacyTerminalWindow.swift => TitlebarTabsVenturaTerminalWindow.swift} (91%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3f1cddf44..cd0c17f9b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -16,9 +16,9 @@ A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; - A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */; }; - A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */; }; - A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */; }; + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */; }; + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */; }; + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */; }; A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; }; A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; }; A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; }; @@ -57,7 +57,7 @@ A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; - A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */; }; + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */; }; A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; }; A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; @@ -139,9 +139,9 @@ 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; - A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsTitlebarTerminalWindow.swift; sourceTree = ""; }; - A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebar.xib; sourceTree = ""; }; - A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTerminalWindow.swift; sourceTree = ""; }; + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsVenturaTerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = ""; }; A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -174,7 +174,7 @@ A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; - A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalLegacy.xib; sourceTree = ""; }; + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarVentura.xib; sourceTree = ""; }; A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = ""; }; A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; @@ -407,13 +407,13 @@ children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, - A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, - A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebar.xib */, + A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */, + A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */, A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */, - A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */, - A51544FD2DFB1110009E85D8 /* TabsTitlebarTerminalWindow.swift */, + A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */, + A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, ); path = "Window Styles"; @@ -682,7 +682,7 @@ buildActionMask = 2147483647; files = ( FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */, - A5593FE52DF8DE3000B47B10 /* TerminalLegacy.xib in Resources */, + A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, @@ -700,7 +700,7 @@ A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */, 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */, - A51545002DFB112E009E85D8 /* TerminalTabsTitlebar.xib in Resources */, + A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -768,7 +768,7 @@ A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, - A51544FE2DFB111C009E85D8 /* TabsTitlebarTerminalWindow.swift in Sources */, + A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, @@ -777,7 +777,7 @@ A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A51B78472AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift in Sources */, + A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index d59f71619..977a064e0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -13,10 +13,15 @@ class TerminalController: BaseTerminalController { let config = appDelegate.ghostty.config let nib = switch config.macosTitlebarStyle { case "native": "Terminal" - //case "tabs": "TerminalTabsTitlebar" - case "tabs": "TerminalLegacy" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" + case "tabs": + if #available(macOS 26.0, *) { + // TODO: Switch to Tahoe when ready + "TerminalTabsTitlebarVentura" + } else { + "TerminalTabsTitlebarVentura" + } default: defaultValue } @@ -447,68 +452,20 @@ class TerminalController: BaseTerminalController { private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance - if let window = window as? TerminalWindow { - // Sync our zoom state for splits - window.surfaceIsZoomed = surfaceTree.zoomed != nil + guard let window = window as? TerminalWindow else { return } - // Set the font for the window and tab titles. - if let titleFontName = surfaceConfig.windowTitleFontFamily { - window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) - } else { - window.titlebarFont = nil - } + // Sync our zoom state for splits + window.surfaceIsZoomed = surfaceTree.zoomed != nil - // Call this last in case it uses any of the properties above. - window.syncAppearance(surfaceConfig) - } - - guard let window else { return } - - if let window = window as? LegacyTerminalWindow { - // Update our window light/darkness based on our updated background color - window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor - } - - // If our window is not visible, then we do nothing. Some things such as blurring - // have no effect if the window is not visible. Ultimately, we'll have this called - // at some point when a surface becomes focused. - guard window.isVisible else { return } - - guard let window = window as? LegacyTerminalWindow, window.hasStyledTabs else { return } - - // Our background color depends on if our focused surface borders the top or not. - // If it does, we match the focused surface. If it doesn't, we use the app - // configuration. - let backgroundColor: OSColor - if !surfaceTree.isEmpty { - if let focusedSurface = focusedSurface, - let treeRoot = surfaceTree.root, - let focusedNode = treeRoot.node(view: focusedSurface), - treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { - // 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. - let topLeftSurface = surfaceTree.root?.leftmostLeaf() - backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor) - } + // Set the font for the window and tab titles. + if let titleFontName = surfaceConfig.windowTitleFontFamily { + window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { - backgroundColor = OSColor(self.derivedConfig.backgroundColor) + window.titlebarFont = nil } - window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity) - if (window.isOpaque) { - // Bg color is only synced if we have no transparency. This is because - // the transparency is handled at the surface level (window.backgroundColor - // ignores alpha components) - window.backgroundColor = backgroundColor - - // If there is transparency, calling this will make the titlebar opaque - // so we only call this if we are opaque. - window.updateTabBar() - } + // Call this last in case it uses any of the properties above. + window.syncAppearance(surfaceConfig) } /// Returns the default size of the window. This is contextual based on the focused surface because @@ -884,28 +841,6 @@ class TerminalController: BaseTerminalController { } } - // TODO: remove - if let window = window as? LegacyTerminalWindow, - config.macosTitlebarStyle == "tabs" { - // Handle titlebar tabs config option. Something about what we do while setting up the - // titlebar tabs interferes with the window restore process unless window.tabbingMode - // is set to .preferred, so we set it, and switch back to automatic as soon as we can. - window.tabbingMode = .preferred - window.titlebarTabs = true - DispatchQueue.main.async { - window.tabbingMode = .automatic - } - - if window.hasStyledTabs { - // Set the background color of the window - let backgroundColor = NSColor(config.backgroundColor) - window.backgroundColor = backgroundColor - - // This makes sure our titlebar renders correctly when there is a transparent background - window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity) - } - } - // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, @@ -1110,23 +1045,6 @@ class TerminalController: BaseTerminalController { } //MARK: - TerminalViewDelegate - - override func titleDidChange(to: String) { - super.titleDidChange(to: to) - - guard let window = window as? LegacyTerminalWindow else { return } - - // Custom toolbar-based title used when titlebar tabs are enabled. - if let toolbar = window.toolbar as? TerminalToolbar { - if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") { - // Updating the title text as above automatically reveals the - // native title view in macOS 15.0 and above. Since we're using - // a custom view instead, we need to re-hide it. - window.titleVisibility = .hidden - } - toolbar.titleText = to - } - } override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index f2d3b9b85..5f4d6b177 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -19,15 +19,6 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { NotificationCenter.default.removeObserver(self) } - // We override this so that with the hidden titlebar style the titlebar - // area is not draggable. - override var contentLayoutRect: CGRect { - var rect = super.contentLayoutRect - rect.origin.y = 0 - rect.size.height = self.frame.height - return rect - } - /// Apply the hidden titlebar style. private func reapplyHiddenStyle() { styleMask = [ @@ -64,6 +55,26 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { } } + // MARK: NSWindow + + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + reapplyHiddenStyle() + } + } + + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + rect.origin.y = 0 + rect.size.height = self.frame.height + return rect + } + // MARK: Notifications @objc private func fullscreenDidExit(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib index 1a2a6c192..eb4675657 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib @@ -17,7 +17,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib similarity index 94% rename from macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib rename to macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib index 61ed6f782..deaeded9f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalLegacy.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib @@ -13,7 +13,7 @@ - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib similarity index 91% rename from macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib rename to macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib index 779b6e094..bf53a4510 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebar.xib +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib @@ -13,11 +13,11 @@ - + - + diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a0e18d283..f6b53c289 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -10,7 +10,7 @@ class TerminalWindow: NSWindow { static let defaultLevelKey: String = "TerminalDefaultLevel" /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig? + private(set) var derivedConfig: DerivedConfig? /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { diff --git a/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift similarity index 90% rename from macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 858b54829..c45e93d79 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TabsTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -1,7 +1,8 @@ import AppKit import SwiftUI -class TabsTitlebarTerminalWindow: TerminalWindow, NSToolbarDelegate { +/// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. +class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { override func awakeFromNib() { super.awakeFromNib() @@ -49,7 +50,7 @@ extension NSToolbarItem.Identifier { static let title = NSToolbarItem.Identifier("Title") } -extension TabsTitlebarTerminalWindow { +extension TitlebarTabsTahoeTerminalWindow { struct TitleItem: View { var body: some View { Text("HELLO THIS IS A PRETTY LONG TITLE") diff --git a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift similarity index 91% rename from macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift rename to macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 89afbf72f..2f8eb5840 100644 --- a/macos/Sources/Features/Terminal/Window Styles/LegacyTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -1,11 +1,10 @@ import Cocoa -/// The terminal window that we originally had in Ghostty for a long time. Kind of a soupy mess -/// of styling. -class LegacyTerminalWindow: TerminalWindow { +/// Titlebar tabs for macOS 13 to 15. +class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. - var isLightTheme: Bool = false + fileprivate var isLightTheme: Bool = false override var surfaceIsZoomed: Bool { didSet { @@ -32,17 +31,30 @@ class LegacyTerminalWindow: TerminalWindow { } } - // MARK: - Lifecycle + // MARK: NSWindow override func awakeFromNib() { super.awakeFromNib() - if titlebarTabs { - generateToolbar() - } - } + // Handle titlebar tabs config option. Something about what we do while setting up the + // titlebar tabs interferes with the window restore process unless window.tabbingMode + // is set to .preferred, so we set it, and switch back to automatic as soon as we can. + tabbingMode = .preferred + DispatchQueue.main.async { + self.tabbingMode = .automatic + } - // MARK: - NSWindow + titlebarTabs = true + + // This should always be true since our super sets this up. + if let derivedConfig { + // Set the background color of the window + backgroundColor = derivedConfig.backgroundColor + + // This makes sure our titlebar renders correctly when there is a transparent background + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) + } + } // We only need to set this once, but need to do it after the window has been created in order // to determine if the theme is using a very dark background, in which case we don't want to @@ -145,7 +157,29 @@ class LegacyTerminalWindow: TerminalWindow { } } - // MARK: - Tab Bar Styling + // MARK: Appearance + + override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { + super.syncAppearance(surfaceConfig) + + // Update our window light/darkness based on our updated background color + isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor + + // Update our titlebar color + if let preferredBackgroundColor { + titlebarColor = preferredBackgroundColor + } else if let derivedConfig { + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) + } + + if (isOpaque) { + // If there is transparency, calling this will make the titlebar opaque + // so we only call this if we are opaque. + updateTabBar() + } + } + + // MARK: Tab Bar Styling // This is true if we should apply styles to the titlebar or tab bar. var hasStyledTabs: Bool { @@ -333,6 +367,18 @@ class LegacyTerminalWindow: TerminalWindow { } } + override var title: String { + didSet { + // Updating the title text as above automatically reveals the + // native title view in macOS 15.0 and above. Since we're using + // a custom view instead, we need to re-hide it. + titleVisibility = .hidden + if let toolbar = toolbar as? TerminalToolbar { + toolbar.titleText = title + } + } + } + // We have to regenerate a toolbar when the titlebar tabs setting changes since our // custom toolbar conditionally generates the items based on this setting. I tried to // invalidate the toolbar items and force a refresh, but as far as I can tell that @@ -559,7 +605,7 @@ fileprivate class WindowDragView: NSView { fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. - private weak var terminalWindow: LegacyTerminalWindow? + private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() @@ -587,7 +633,7 @@ fileprivate class WindowButtonsBackdropView: NSView { fatalError("init(coder:) has not been implemented") } - init(window: LegacyTerminalWindow) { + init(window: TitlebarTabsVenturaTerminalWindow) { self.terminalWindow = window self.isLightTheme = window.isLightTheme diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 3200608d0..d1dac49a3 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -271,8 +271,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. - if let window = window as? LegacyTerminalWindow, - window.titlebarTabs { + if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { window.titlebarTabs = true } From 70029bf82ae13cc5697b7961db08fa2178740327 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 13:39:17 -0700 Subject: [PATCH 224/245] macos: tahoe terminal tabs shows title --- .../Terminal/TerminalController.swift | 8 ++-- .../Window Styles/TerminalWindow.swift | 7 ++-- .../TitlebarTabsTahoeTerminalWindow.swift | 42 +++++++++++++++++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 977a064e0..848617a53 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -18,7 +18,7 @@ class TerminalController: BaseTerminalController { case "tabs": if #available(macOS 26.0, *) { // TODO: Switch to Tahoe when ready - "TerminalTabsTitlebarVentura" + "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } @@ -409,15 +409,15 @@ class TerminalController: BaseTerminalController { // We need to clear any windows beyond this because they have had // a keyEquivalent set previously. guard tab <= 9 else { - window.keyEquivalent2 = "" + window.keyEquivalent = "" continue } let action = "goto_tab:\(tab)" if let equiv = ghostty.config.keyboardShortcut(for: action) { - window.keyEquivalent2 = "\(equiv)" + window.keyEquivalent = "\(equiv)" } else { - window.keyEquivalent2 = "" + window.keyEquivalent = "" } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index f6b53c289..907e0b250 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -82,17 +82,16 @@ class TerminalWindow: NSWindow { // MARK: Tab Key Equivalents - // TODO: rename once Legacy window removes - var keyEquivalent2: String? = nil { + var keyEquivalent: String? = nil { didSet { // When our key equivalent is set, we must update the tab label. - guard let keyEquivalent2 else { + guard let keyEquivalent else { keyEquivalentLabel.attributedStringValue = NSAttributedString() return } keyEquivalentLabel.attributedStringValue = NSAttributedString( - string: "\(keyEquivalent2) ", + string: "\(keyEquivalent) ", attributes: [ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor, diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index c45e93d79..a139b1b62 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -3,6 +3,9 @@ import SwiftUI /// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { + /// The view model for SwiftUI views + private var viewModel = ViewModel() + override func awakeFromNib() { super.awakeFromNib() @@ -15,7 +18,23 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { toolbar.delegate = self toolbar.centeredItemIdentifiers.insert(.title) self.toolbar = toolbar - //toolbarStyle = .unifiedCompact + toolbarStyle = .unifiedCompact + } + + // MARK: NSWindow + + override var title: String { + didSet { + viewModel.title = title + } + } + + override func update() { + super.update() + + if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { + glass.isHidden = true + } } // MARK: NSToolbarDelegate @@ -34,7 +53,7 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { switch itemIdentifier { case .title: let item = NSToolbarItem(itemIdentifier: .title) - item.view = NSHostingView(rootView: TitleItem()) + item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) item.visibilityPriority = .user item.isEnabled = true return item @@ -43,6 +62,11 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { } } + // MARK: SwiftUI + + class ViewModel: ObservableObject { + @Published var title: String = "👻 Ghostty" + } } extension NSToolbarItem.Identifier { @@ -51,9 +75,21 @@ extension NSToolbarItem.Identifier { } extension TitlebarTabsTahoeTerminalWindow { + /// Displays the window title struct TitleItem: View { + @ObservedObject var viewModel: ViewModel + + var title: String { + // An empty title makes this view zero-sized and NSToolbar on macOS + // tahoe just deletes the item when that happens. So we use a space + // instead to ensure there's always some size. + viewModel.title.isEmpty ? " " : viewModel.title + } + var body: some View { - Text("HELLO THIS IS A PRETTY LONG TITLE") + Text(title) + .lineLimit(1) + .truncationMode(.tail) } } } From 658ec2eb6f13a7896720a3e95db87d2b808309d6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 14:33:18 -0700 Subject: [PATCH 225/245] macos: add reset zoom to all window titles --- .../Terminal/BaseTerminalController.swift | 2 + .../Terminal/TerminalController.swift | 4 +- .../Window Styles/TerminalWindow.swift | 62 +++++++++++++++++++ macos/Sources/Helpers/Fullscreen.swift | 12 ++-- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 849f13b34..bc91b920e 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -758,6 +758,8 @@ class BaseTerminalController: NSWindowController, } } + func fullscreenDidChange() {} + // MARK: Clipboard Confirmation @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 848617a53..cff230249 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -145,7 +145,9 @@ class TerminalController: BaseTerminalController { } - func fullscreenDidChange() { + override func fullscreenDidChange() { + super.fullscreenDidChange() + // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 907e0b250..4221d9ba4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -9,6 +9,9 @@ class TerminalWindow: NSWindow { /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" + /// The view model for SwiftUI views + private var viewModel = ViewModel() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig? @@ -19,6 +22,15 @@ class TerminalWindow: NSWindow { // MARK: NSWindow Overrides + override var toolbar: NSToolbar? { + didSet { + DispatchQueue.main.async { + // When we have a toolbar, our SwiftUI view needs to know for layout + self.viewModel.hasToolbar = self.toolbar != nil + } + } + } + override func awakeFromNib() { guard let appDelegate = NSApp.delegate as? AppDelegate else { return } @@ -43,6 +55,18 @@ class TerminalWindow: NSWindow { hideWindowButtons() } + // Create our reset zoom titlebar accessory. + let resetZoomAccessory = NSTitlebarAccessoryViewController() + resetZoomAccessory.layoutAttribute = .right + resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView( + viewModel: viewModel, + action: { [weak self] in + guard let self else { return } + self.terminalController?.splitZoom(self) + })) + addTitlebarAccessoryViewController(resetZoomAccessory) + resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false + // Setup the accessory view for tabs that shows our keyboard shortcuts, // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues // where buttons were not clickable. @@ -115,6 +139,10 @@ class TerminalWindow: NSWindow { // Show/hide our reset zoom button depending on if we're zoomed. // We want to show it if we are zoomed. resetZoomTabButton.isHidden = !surfaceIsZoomed + + DispatchQueue.main.async { + self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed + } } } @@ -313,3 +341,37 @@ class TerminalWindow: NSWindow { } } } + +// MARK: SwiftUI View + +extension TerminalWindow { + class ViewModel: ObservableObject { + @Published var isSurfaceZoomed: Bool = false + @Published var hasToolbar: Bool = false + } + + struct ResetZoomAccessoryView: View { + @ObservedObject var viewModel: ViewModel + let action: () -> Void + + var body: some View { + if viewModel.isSurfaceZoomed { + VStack { + Button(action: action) { + Image("ResetZoom") + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + .help("Reset Split Zoom") + .frame(width: 20, height: 20) + Spacer() + } + // With a toolbar, the window title is taller, so we need more padding + // to properly align. + .padding(.top, viewModel.hasToolbar ? 10 : 5) + // We always need space at the end of the titlebar + .padding(.trailing, 10) + } + } + } +} diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index d1dac49a3..49cab0756 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -45,10 +45,6 @@ protocol FullscreenDelegate: AnyObject { func fullscreenDidChange() } -extension FullscreenDelegate { - func fullscreenDidChange() {} -} - /// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own. class FullscreenBase { let window: NSWindow @@ -269,6 +265,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.styleMask = savedState.styleMask window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true) + // Removing the "titled" style also derefs all our accessory view controllers + // so we need to restore those. + for c in savedState.titlebarAccessoryViewControllers { + window.addTitlebarAccessoryViewController(c) + } + // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { @@ -383,6 +385,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask + let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool @@ -394,6 +397,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask + self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false if let cgWindowId = window.cgWindowId { From de40e7ce028b57dc993fafc3c18be863d52437c1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 14:36:33 -0700 Subject: [PATCH 226/245] macos: non-native fullscreen should restore toolbars --- macos/Sources/Helpers/Fullscreen.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 49cab0756..a2294a0af 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -271,6 +271,10 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { window.addTitlebarAccessoryViewController(c) } + // Removing "titled" also clears our toolbar + window.toolbar = savedState.toolbar + window.toolbarStyle = savedState.toolbarStyle + // This is a hack that I want to remove from this but for now, we need to // fix up the titlebar tabs here before we do everything below. if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { @@ -385,6 +389,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let tabGroupIndex: Int? let contentFrame: NSRect let styleMask: NSWindow.StyleMask + let toolbar: NSToolbar? + let toolbarStyle: NSWindow.ToolbarStyle let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController] let dock: Bool let menu: Bool @@ -397,6 +403,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask + self.toolbar = window.toolbar + self.toolbarStyle = window.toolbarStyle self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers self.dock = window.screen?.hasDock ?? false From 5c8f1948cecf1c4dc63fb53fae0ef6203dd0d691 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 14:42:08 -0700 Subject: [PATCH 227/245] macos: remove the duplicated reset zoom accessory view from legacy --- .../Terminal/TerminalController.swift | 3 +- .../TitlebarTabsVenturaTerminalWindow.swift | 36 ------------------- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cff230249..9fbf154bc 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -18,7 +18,8 @@ class TerminalController: BaseTerminalController { case "tabs": if #available(macOS 26.0, *) { // TODO: Switch to Tahoe when ready - "TerminalTabsTitlebarTahoe" + "TerminalTabsTitlebarVentura" + //"TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 2f8eb5840..bc6a66a87 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -6,12 +6,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// be updated whenever the window background color or surrounding elements changes. fileprivate var isLightTheme: Bool = false - override var surfaceIsZoomed: Bool { - didSet { - updateResetZoomTitlebarButtonVisibility() - } - } - lazy var titlebarColor: NSColor = backgroundColor { didSet { guard let titlebarContainer else { return } @@ -108,8 +102,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } } - updateResetZoomTitlebarButtonVisibility() - // The remainder of this function only applies to styled tabs. guard hasStyledTabs else { return } @@ -277,33 +269,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton() - private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { - guard let titlebarContainer else { return nil } - let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) - let view = NSView(frame: NSRect(origin: .zero, size: size)) - - let button = generateResetZoomButton() - button.frame.origin.x = size.width/2 - button.bounds.width/2 - button.frame.origin.y = size.height/2 - button.bounds.height/2 - view.addSubview(button) - - let titlebarAccessoryViewController = NSTitlebarAccessoryViewController() - titlebarAccessoryViewController.view = view - titlebarAccessoryViewController.layoutAttribute = .right - - return titlebarAccessoryViewController - }() - - private func updateResetZoomTitlebarButtonVisibility() { - guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return } - - if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) { - addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController) - } - - resetZoomTitlebarAccessoryViewController.view.isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed - } - private func generateResetZoomButton() -> NSButton { let button = NSButton() button.target = nil @@ -394,7 +359,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true } - updateResetZoomTitlebarButtonVisibility() } // For titlebar tabs, we want to hide the separator view so that we get rid From 6ae8bd737ae63bb15ba796512319632054248e21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 15:11:35 -0700 Subject: [PATCH 228/245] macos: hide the reset zoom titlebar accessory when tab bar is shown --- .../Window Styles/TerminalWindow.swift | 23 ++++++++++++++++++- .../Helpers/Extensions/Array+Extension.swift | 4 ++++ .../Helpers/Extensions/NSView+Extension.swift | 17 +++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4221d9ba4..fe7293d5b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -104,6 +104,26 @@ class TerminalWindow: NSWindow { } } + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + super.addTitlebarAccessoryViewController(childViewController) + + // Tab bar is attached as a titlebar accessory view controller (layout bottom). We + // can detect when it is shown or hidden by overriding add/remove and searching for + // it. This has been verified to work on macOS 12 to 26 + if childViewController.view.contains(className: "NSTabBar") { + viewModel.hasTabBar = true + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + if let childViewController = titlebarAccessoryViewControllers[safe: index], + childViewController.view.contains(className: "NSTabBar") { + viewModel.hasTabBar = false + } + + super.removeTitlebarAccessoryViewController(at: index) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -348,6 +368,7 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false + @Published var hasTabBar: Bool = false } struct ResetZoomAccessoryView: View { @@ -355,7 +376,7 @@ extension TerminalWindow { let action: () -> Void var body: some View { - if viewModel.isSurfaceZoomed { + if viewModel.isSurfaceZoomed && !viewModel.hasTabBar { VStack { Button(action: action) { Image("ResetZoom") diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift index 6f005a349..12f2de43d 100644 --- a/macos/Sources/Helpers/Extensions/Array+Extension.swift +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -1,4 +1,8 @@ extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } + /// Returns the index before i, with wraparound. Assumes i is a valid index. func indexWrapping(before i: Int) -> Int { if i == 0 { diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index b958130f1..0da84abda 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -42,6 +42,21 @@ extension NSView { return false } + /// Checks if the view contains the given class in its hierarchy. + func contains(className name: String) -> Bool { + if String(describing: type(of: self)) == name { + return true + } + + for subview in subviews { + if subview.contains(className: name) { + return true + } + } + + return false + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { @@ -113,7 +128,7 @@ extension NSView { } /// Returns a string representation of the view hierarchy in a tree-like format. - private func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { + func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { var result = "" // Add the tree branch characters From 5f9967024744827314535026221565763fd4791d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 16:37:26 -0700 Subject: [PATCH 229/245] macos: tahoe titlebar tabs taking shape --- .../Terminal/TerminalController.swift | 4 +- .../Window Styles/TerminalWindow.swift | 36 +++++- .../TitlebarTabsTahoeTerminalWindow.swift | 106 ++++++++++++++++-- .../Helpers/Extensions/NSView+Extension.swift | 10 ++ 4 files changed, 142 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 9fbf154bc..0b0b264d3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -18,8 +18,8 @@ class TerminalController: BaseTerminalController { case "tabs": if #available(macOS 26.0, *) { // TODO: Switch to Tahoe when ready - "TerminalTabsTitlebarVentura" - //"TerminalTabsTitlebarTahoe" + //"TerminalTabsTitlebarVentura" + "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index fe7293d5b..032886802 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -110,20 +110,50 @@ class TerminalWindow: NSWindow { // Tab bar is attached as a titlebar accessory view controller (layout bottom). We // can detect when it is shown or hidden by overriding add/remove and searching for // it. This has been verified to work on macOS 12 to 26 - if childViewController.view.contains(className: "NSTabBar") { + if isTabBar(childViewController) { + childViewController.identifier = Self.tabBarIdentifier viewModel.hasTabBar = true } } override func removeTitlebarAccessoryViewController(at index: Int) { - if let childViewController = titlebarAccessoryViewControllers[safe: index], - childViewController.view.contains(className: "NSTabBar") { + if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { viewModel.hasTabBar = false } super.removeTitlebarAccessoryViewController(at: index) } + // MARK: Tab Bar + + /// This identifier is attached to the tab bar view controller when we detect it being + /// added. + private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { + if childViewController.identifier == nil { + // The good case + if childViewController.view.contains(className: "NSTabBar") { + return true + } + + // When a new window is attached to an existing tab group, AppKit adds + // an empty NSView as an accessory view and adds the tab bar later. If + // we're at the bottom and are a single NSView we assume its a tab bar. + if childViewController.layoutAttribute == .bottom && + childViewController.view.className == "NSView" && + childViewController.view.subviews.isEmpty { + return true + } + + return false + } + + // View controllers should be tagged with this as soon as possible to + // increase our accuracy. We do this manually. + return childViewController.identifier == Self.tabBarIdentifier + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a139b1b62..42bcabee7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -20,7 +20,6 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { self.toolbar = toolbar toolbarStyle = .unifiedCompact } - // MARK: NSWindow override var title: String { @@ -29,11 +28,92 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { } } - override func update() { - super.update() + override var toolbar: NSToolbar? { + didSet{ + guard toolbar != nil else { return } - if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { - glass.isHidden = true + // When a toolbar is added, remove the Liquid Glass look because we're + // abusing the toolbar as a tab bar. + if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { + glass.isHidden = true + } + } + } + + override func becomeMain() { + super.becomeMain() + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + self.contentView?.printViewHierarchy() + } + } + + // This is called by macOS for native tabbing in order to add the tab bar. We hook into + // this, detect the tab bar being added, and override its behavior. + override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { + // If this is the tab bar then we need to set it up for the titlebar + guard isTabBar(childViewController) else { + super.addTitlebarAccessoryViewController(childViewController) + return + } + + // Some setup needs to happen BEFORE it is added, such as layout. If + // we don't do this before the call below, we'll trigger an AppKit + // assertion. + childViewController.layoutAttribute = .right + + super.addTitlebarAccessoryViewController(childViewController) + + // View model updates must happen on their own ticks + DispatchQueue.main.async { + self.viewModel.hasTabBar = true + } + + // Setup the tab bar to go into the titlebar. + DispatchQueue.main.async { + // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ + // If we don't do this then on launch windows with restored state with tabs will end + // up with messed up tab bars that don't show all tabs. + let accessoryView = childViewController.view + guard let clipView = accessoryView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // The container is the view that we'll constrain our tab bar within. + let container = toolbarView + + // Constrain the accessory clip view (the parent of the accessory view + // usually that clips the children) to the container view. + clipView.translatesAutoresizingMaskIntoConstraints = false + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 78).isActive = true + clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true + clipView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true + clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true + clipView.needsLayout = true + + // Constrain the actual accessory view (the tab bar) to the clip view + // so it takes up the full space. + accessoryView.translatesAutoresizingMaskIntoConstraints = false + accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor).isActive = true + accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor).isActive = true + accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor).isActive = true + accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor).isActive = true + accessoryView.needsLayout = true + } + } + + override func removeTitlebarAccessoryViewController(at index: Int) { + guard let childViewController = titlebarAccessoryViewControllers[safe: index], + isTabBar(childViewController) else { + super.removeTitlebarAccessoryViewController(at: index) + return + } + + super.removeTitlebarAccessoryViewController(at: index) + + // View model needs to be updated on another tick because it + // triggers view updates. + DispatchQueue.main.async { + self.viewModel.hasTabBar = false } } @@ -66,6 +146,7 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { class ViewModel: ObservableObject { @Published var title: String = "👻 Ghostty" + @Published var hasTabBar: Bool = false } } @@ -83,13 +164,20 @@ extension TitlebarTabsTahoeTerminalWindow { // An empty title makes this view zero-sized and NSToolbar on macOS // tahoe just deletes the item when that happens. So we use a space // instead to ensure there's always some size. - viewModel.title.isEmpty ? " " : viewModel.title + return viewModel.title.isEmpty ? " " : viewModel.title } var body: some View { - Text(title) - .lineLimit(1) - .truncationMode(.tail) + if !viewModel.hasTabBar { + Text(title) + .lineLimit(1) + .truncationMode(.tail) + } else { + // 1x1.gif strikes again! For real: if we render a zero-sized + // view here then the toolbar just disappears our view. I don't + // know. + Color.clear.frame(width: 1, height: 1) + } } } } diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 0da84abda..b3628d406 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -57,6 +57,16 @@ extension NSView { return false } + /// Finds the superview with the given class name. + func firstSuperview(withClassName name: String) -> NSView? { + guard let superview else { return nil } + if String(describing: type(of: superview)) == name { + return superview + } + + return superview.firstSuperview(withClassName: name) + } + /// Recursively finds and returns the first descendant view that has the given class name. func firstDescendant(withClassName name: String) -> NSView? { for subview in subviews { From d84c30ce71546efc36e671beac1febf5b2a92875 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 18:10:25 -0700 Subject: [PATCH 230/245] macos: titlebar tabs should be transparent --- .../Window Styles/TitlebarTabsTahoeTerminalWindow.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 42bcabee7..ca88322ed 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -2,7 +2,10 @@ import AppKit import SwiftUI /// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later. -class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate { +/// +/// This inherits from transparent styling so that the titlebar matches the background color +/// of the window. +class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() From 9d9c451b0a893c35c29ba453722cfb337d73817a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 20:03:19 -0700 Subject: [PATCH 231/245] macos: titlebar tabs handle hidden traffic buttons --- .../Terminal/Window Styles/TerminalWindow.swift | 9 ++++++--- .../TitlebarTabsTahoeTerminalWindow.swift | 11 +++++++++-- .../TitlebarTabsVenturaTerminalWindow.swift | 13 +++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 032886802..d9b98695e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -13,7 +13,7 @@ class TerminalWindow: NSWindow { private var viewModel = ViewModel() /// The configuration derived from the Ghostty config so we don't need to rely on references. - private(set) var derivedConfig: DerivedConfig? + private(set) var derivedConfig: DerivedConfig = .init() /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { @@ -341,8 +341,8 @@ class TerminalWindow: NSWindow { } } - let alpha = derivedConfig?.backgroundOpacity.clamped(to: 0.001...1) ?? 1 - return derivedConfig?.backgroundColor.withAlphaComponent(alpha) + let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return derivedConfig.backgroundColor.withAlphaComponent(alpha) } private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { @@ -379,15 +379,18 @@ class TerminalWindow: NSWindow { struct DerivedConfig { let backgroundColor: NSColor let backgroundOpacity: Double + let macosWindowButtons: Ghostty.MacOSWindowButtons init() { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 + self.macosWindowButtons = .visible } init(_ config: Ghostty.Config) { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity + self.macosWindowButtons = config.macosWindowButtons } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index ca88322ed..3dc505088 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -84,12 +84,19 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // The container is the view that we'll constrain our tab bar within. let container = toolbarView + // The padding for the tab bar. If we're showing window buttons then + // we need to offset the window buttons. + let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + case .hidden: 0 + case .visible: 70 + } + // Constrain the accessory clip view (the parent of the accessory view // usually that clips the children) to the container view. clipView.translatesAutoresizingMaskIntoConstraints = false - clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 78).isActive = true + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding).isActive = true clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true - clipView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true + clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2).isActive = true clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true clipView.needsLayout = true diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index bc6a66a87..6e19d144d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -40,14 +40,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { titlebarTabs = true - // This should always be true since our super sets this up. - if let derivedConfig { - // Set the background color of the window - backgroundColor = derivedConfig.backgroundColor + // Set the background color of the window + backgroundColor = derivedConfig.backgroundColor - // This makes sure our titlebar renders correctly when there is a transparent background - titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) - } + // This makes sure our titlebar renders correctly when there is a transparent background + titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } // We only need to set this once, but need to do it after the window has been created in order @@ -160,7 +157,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // Update our titlebar color if let preferredBackgroundColor { titlebarColor = preferredBackgroundColor - } else if let derivedConfig { + } else { titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } From 17ad77b5b0b7b7ead22bcfda11c9250d064d408d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 12 Jun 2025 21:33:40 -0700 Subject: [PATCH 232/245] macos: fix background color of terminal window to match surface --- .../Window Styles/TerminalWindow.swift | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d9b98695e..a1bb1d86d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -322,22 +322,23 @@ class TerminalWindow: NSWindow { /// change the alpha channel again manually. var preferredBackgroundColor: NSColor? { if let terminalController, !terminalController.surfaceTree.isEmpty { + let surface: Ghostty.SurfaceView? + // If our focused surface borders the top then we prefer its background color if let focusedSurface = terminalController.focusedSurface, let treeRoot = terminalController.surfaceTree.root, let focusedNode = treeRoot.node(view: focusedSurface), - treeRoot.spatial().doesBorder(side: .up, from: focusedNode), - let backgroundcolor = focusedSurface.backgroundColor { - let alpha = focusedSurface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) - return NSColor(backgroundcolor).withAlphaComponent(alpha) + treeRoot.spatial().doesBorder(side: .up, from: focusedNode) { + surface = focusedSurface + } else { + // If it doesn't border the top, we use the top-left leaf + surface = terminalController.surfaceTree.root?.leftmostLeaf() } - // Doesn't border the top or we don't have a focused surface, so - // we try to match the top-left surface. - let topLeftSurface = terminalController.surfaceTree.root?.leftmostLeaf() - if let topLeftBgColor = topLeftSurface?.backgroundColor { - let alpha = topLeftSurface?.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) ?? 1 - return NSColor(topLeftBgColor).withAlphaComponent(alpha) + if let surface { + let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor + let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) + return NSColor(backgroundColor).withAlphaComponent(alpha) } } From 00d41239dad768d903790b86d56bd8a3bb1de82b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 11:11:00 -0700 Subject: [PATCH 233/245] macOS: prep the tab bar when system appearance changes --- .../TitlebarTabsTahoeTerminalWindow.swift | 182 +++++++++++++----- 1 file changed, 129 insertions(+), 53 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 3dc505088..ac4fae12a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -9,20 +9,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// The view model for SwiftUI views private var viewModel = ViewModel() - override func awakeFromNib() { - super.awakeFromNib() - - // We must hide the title since we're going to be moving tabs into - // the titlebar which have their own title. - titleVisibility = .hidden - - // Create a toolbar - let toolbar = NSToolbar(identifier: "TerminalToolbar") - toolbar.delegate = self - toolbar.centeredItemIdentifiers.insert(.title) - self.toolbar = toolbar - toolbarStyle = .unifiedCompact + deinit { + tabBarObserver = nil } + // MARK: NSWindow override var title: String { @@ -43,11 +33,27 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool } } + override func awakeFromNib() { + super.awakeFromNib() + + // We must hide the title since we're going to be moving tabs into + // the titlebar which have their own title. + titleVisibility = .hidden + + // Create a toolbar + let toolbar = NSToolbar(identifier: "TerminalToolbar") + toolbar.delegate = self + toolbar.centeredItemIdentifiers.insert(.title) + self.toolbar = toolbar + toolbarStyle = .unifiedCompact + } + override func becomeMain() { super.becomeMain() - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { - self.contentView?.printViewHierarchy() - } + + // Check if we have a tab bar and set it up if we have to. See the comment + // on this function to learn why we need to check this here. + setupTabBar() } // This is called by macOS for native tabbing in order to add the tab bar. We hook into @@ -66,48 +72,12 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool super.addTitlebarAccessoryViewController(childViewController) - // View model updates must happen on their own ticks - DispatchQueue.main.async { - self.viewModel.hasTabBar = true - } - // Setup the tab bar to go into the titlebar. DispatchQueue.main.async { // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/ // If we don't do this then on launch windows with restored state with tabs will end // up with messed up tab bars that don't show all tabs. - let accessoryView = childViewController.view - guard let clipView = accessoryView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } - guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } - guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } - - // The container is the view that we'll constrain our tab bar within. - let container = toolbarView - - // The padding for the tab bar. If we're showing window buttons then - // we need to offset the window buttons. - let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { - case .hidden: 0 - case .visible: 70 - } - - // Constrain the accessory clip view (the parent of the accessory view - // usually that clips the children) to the container view. - clipView.translatesAutoresizingMaskIntoConstraints = false - clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding).isActive = true - clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true - clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2).isActive = true - clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true - clipView.needsLayout = true - - // Constrain the actual accessory view (the tab bar) to the clip view - // so it takes up the full space. - accessoryView.translatesAutoresizingMaskIntoConstraints = false - accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor).isActive = true - accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor).isActive = true - accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor).isActive = true - accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor).isActive = true - accessoryView.needsLayout = true + self.setupTabBar() } } @@ -120,11 +90,117 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool super.removeTitlebarAccessoryViewController(at: index) + removeTabBar() + } + + // MARK: Tab Bar Setup + + private var tabBarObserver: NSObjectProtocol? { + didSet { + // When we change this we want to clear our old observer + guard let oldValue else { return } + NotificationCenter.default.removeObserver(oldValue) + } + } + + /// Take the NSTabBar that is on the window and convert it into titlebar tabs. + /// + /// Let me explain more background on what is happening here. When a tab bar is created, only the + /// main window actually has an NSTabBar. When an NSWindow in the tab group gains main, AppKit + /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar + /// is removed from the view hierarchy. + /// + /// We can't detect this via `addTitlebarAccessoryViewController` because AppKit + /// _always_ creates an accessory view controller for every window in the tab group, but puts a + /// zero-sized NSView into it (that the tab bar is then attached to later). + /// + /// The best way I've found to detect this is to search for and setup the tab bar anytime the + /// window gains focus. There are probably edge cases to check but to resolve all this I made + /// this function which is idempotent to call. + /// + /// There are more scenarios to look out for and they're documented within the method. + func setupTabBar() { + // We only want to setup the observer once + guard tabBarObserver == nil else { return } + + // Find our tab bar. If it doesn't exist we don't do anything. + guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } + + // View model updates must happen on their own ticks. + DispatchQueue.main.async { + self.viewModel.hasTabBar = true + } + + // Find our clip view + guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let accessoryView = clipView.subviews[safe: 0] else { return } + guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // The container is the view that we'll constrain our tab bar within. + let container = toolbarView + + // The padding for the tab bar. If we're showing window buttons then + // we need to offset the window buttons. + let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + case .hidden: 0 + case .visible: 70 + } + + // Constrain the accessory clip view (the parent of the accessory view + // usually that clips the children) to the container view. + clipView.translatesAutoresizingMaskIntoConstraints = false + accessoryView.translatesAutoresizingMaskIntoConstraints = false + + // Setup all our constraints + NSLayoutConstraint.activate([ + clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding), + clipView.rightAnchor.constraint(equalTo: container.rightAnchor), + clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), + clipView.heightAnchor.constraint(equalTo: container.heightAnchor), + accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor), + accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor), + accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor), + accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor), + ]) + + clipView.needsLayout = true + accessoryView.needsLayout = true + + // We need to setup an observer for the NSTabBar frame. When we change system + // appearance, the tab bar temporarily becomes width/height 0 and breaks all our + // constraints and AppKit responds by nuking the whole tab bar cause it doesn't + // know what to do with it. We need to detect this before bad things happen. + tabBar.postsFrameChangedNotifications = true + tabBarObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: tabBar, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + // Check if either width or height is zero + guard tabBar.frame.size.width == 0 || tabBar.frame.size.height == 0 else { return } + + // Remove the observer so we can call setup again. + self.tabBarObserver = nil + + // Wait a tick to let the new tab bars appear and then set them up. + DispatchQueue.main.async { + self.setupTabBar() + } + } + } + + func removeTabBar() { // View model needs to be updated on another tick because it // triggers view updates. DispatchQueue.main.async { self.viewModel.hasTabBar = false } + + // Clear our observations + self.tabBarObserver = nil } // MARK: NSToolbarDelegate From b1b74d3421693fa9d7042fd9167603ade3619aa3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 12:25:21 -0700 Subject: [PATCH 234/245] comments --- .../TitlebarTabsTahoeTerminalWindow.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index ac4fae12a..145c37c59 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -25,8 +25,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool didSet{ guard toolbar != nil else { return } - // When a toolbar is added, remove the Liquid Glass look because we're - // abusing the toolbar as a tab bar. + // When a toolbar is added, remove the Liquid Glass look to have a cleaner + // appearance for our custom titlebar tabs. if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { glass.isHidden = true } @@ -110,9 +110,9 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar /// is removed from the view hierarchy. /// - /// We can't detect this via `addTitlebarAccessoryViewController` because AppKit - /// _always_ creates an accessory view controller for every window in the tab group, but puts a - /// zero-sized NSView into it (that the tab bar is then attached to later). + /// We can't reliably detect this via `addTitlebarAccessoryViewController` because AppKit + /// creates an accessory view controller for every window in the tab group, but only attaches + /// the actual NSTabBar to the main window's accessory view. /// /// The best way I've found to detect this is to search for and setup the tab bar anytime the /// window gains focus. There are probably edge cases to check but to resolve all this I made @@ -167,10 +167,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool clipView.needsLayout = true accessoryView.needsLayout = true - // We need to setup an observer for the NSTabBar frame. When we change system - // appearance, the tab bar temporarily becomes width/height 0 and breaks all our - // constraints and AppKit responds by nuking the whole tab bar cause it doesn't - // know what to do with it. We need to detect this before bad things happen. + // Setup an observer for the NSTabBar frame. When system appearance changes or + // other events occur, the tab bar can temporarily become zero-sized. When this + // happens, we need to remove our custom constraints and re-apply them once the + // tab bar has proper dimensions again to avoid constraint conflicts. tabBar.postsFrameChangedNotifications = true tabBarObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, From 59812c3b02ec6fffe0d9febf3b234faf27bd4dbc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 12:27:44 -0700 Subject: [PATCH 235/245] macos: remove TODO --- macos/Sources/Features/Terminal/TerminalController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 0b0b264d3..03a4e548e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -17,8 +17,6 @@ class TerminalController: BaseTerminalController { case "transparent": "TerminalTransparentTitlebar" case "tabs": if #available(macOS 26.0, *) { - // TODO: Switch to Tahoe when ready - //"TerminalTabsTitlebarVentura" "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" From f7f0514b9ff8065e7e0b75cf0e05e75d1ef00642 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 13:14:14 -0700 Subject: [PATCH 236/245] macos: move old toolbar into ventura file --- macos/Ghostty.xcodeproj/project.pbxproj | 4 - .../Features/Terminal/TerminalToolbar.swift | 130 ----------------- .../TitlebarTabsVenturaTerminalWindow.swift | 131 ++++++++++++++++++ 3 files changed, 131 insertions(+), 134 deletions(-) delete mode 100644 macos/Sources/Features/Terminal/TerminalToolbar.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index cd0c17f9b..e9c02ef41 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -120,7 +120,6 @@ A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; @@ -239,7 +238,6 @@ A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; C1F26EA62B738B9900404083 /* NSView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extension.swift"; sourceTree = ""; }; C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = ""; }; @@ -507,7 +505,6 @@ A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, - AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); @@ -799,7 +796,6 @@ A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */, A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */, A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */, - AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */, C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift deleted file mode 100644 index 9da14562c..000000000 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Cocoa - -// Custom NSToolbar subclass that displays a centered window title, -// in order to accommodate the titlebar tabs feature. -class TerminalToolbar: NSToolbar, NSToolbarDelegate { - private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") - - var titleText: String { - get { - titleTextField.stringValue - } - - set { - titleTextField.stringValue = newValue - } - } - - var titleFont: NSFont? { - get { - titleTextField.font - } - - set { - titleTextField.font = newValue - } - } - - var titleIsHidden: Bool { - get { - titleTextField.isHidden - } - - set { - titleTextField.isHidden = newValue - } - } - - override init(identifier: NSToolbar.Identifier) { - super.init(identifier: identifier) - - delegate = self - centeredItemIdentifiers.insert(.titleText) - } - - func toolbar(_ toolbar: NSToolbar, - itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, - willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - var item: NSToolbarItem - - switch itemIdentifier { - case .titleText: - item = NSToolbarItem(itemIdentifier: .titleText) - item.view = self.titleTextField - item.visibilityPriority = .user - - // This ensures the title text field doesn't disappear when shrinking the view - self.titleTextField.translatesAutoresizingMaskIntoConstraints = false - self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) - self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - - // Add constraints to the toolbar item's view - NSLayoutConstraint.activate([ - // Set the height constraint to match the toolbar's height - self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed - ]) - - item.isEnabled = true - case .resetZoom: - item = NSToolbarItem(itemIdentifier: .resetZoom) - default: - item = NSToolbarItem(itemIdentifier: itemIdentifier) - } - - return item - } - - func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [.titleText, .flexibleSpace, .space, .resetZoom] - } - - func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - // These space items are here to ensure that the title remains centered when it starts - // getting smaller than the max size so starts clipping. Lucky for us, two of the - // built-in spacers plus the un-zoom button item seems to exactly match the space - // on the left that's reserved for the window buttons. - return [.flexibleSpace, .titleText, .flexibleSpace] - } -} - -/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. -fileprivate class CenteredDynamicLabel: NSTextField { - override func viewDidMoveToSuperview() { - // Configure the text field - isEditable = false - isBordered = false - drawsBackground = false - alignment = .center - lineBreakMode = .byTruncatingTail - cell?.truncatesLastVisibleLine = true - - // Use Auto Layout - translatesAutoresizingMaskIntoConstraints = false - - // Set content hugging and compression resistance priorities - setContentHuggingPriority(.defaultLow, for: .horizontal) - setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - } - - // Vertically center the text - override func draw(_ dirtyRect: NSRect) { - guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { - super.draw(dirtyRect) - return - } - - let textSize = attributedString.size() - - let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better - - let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, - width: self.bounds.width, height: textSize.height) - - attributedString.draw(in: centeredRect) - } -} - -extension NSToolbarItem.Identifier { - static let resetZoom = NSToolbarItem.Identifier("ResetZoom") - static let titleText = NSToolbarItem.Identifier("TitleText") -} diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 6e19d144d..20280b982 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -609,3 +609,134 @@ fileprivate class WindowButtonsBackdropView: NSView { layer?.addSublayer(overlayLayer) } } + +// MARK: Toolbar + +// Custom NSToolbar subclass that displays a centered window title, +// in order to accommodate the titlebar tabs feature. +fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate { + private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") + + var titleText: String { + get { + titleTextField.stringValue + } + + set { + titleTextField.stringValue = newValue + } + } + + var titleFont: NSFont? { + get { + titleTextField.font + } + + set { + titleTextField.font = newValue + } + } + + var titleIsHidden: Bool { + get { + titleTextField.isHidden + } + + set { + titleTextField.isHidden = newValue + } + } + + override init(identifier: NSToolbar.Identifier) { + super.init(identifier: identifier) + + delegate = self + centeredItemIdentifiers.insert(.titleText) + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + var item: NSToolbarItem + + switch itemIdentifier { + case .titleText: + item = NSToolbarItem(itemIdentifier: .titleText) + item.view = self.titleTextField + item.visibilityPriority = .user + + // This ensures the title text field doesn't disappear when shrinking the view + self.titleTextField.translatesAutoresizingMaskIntoConstraints = false + self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) + self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + // Add constraints to the toolbar item's view + NSLayoutConstraint.activate([ + // Set the height constraint to match the toolbar's height + self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed + ]) + + item.isEnabled = true + case .resetZoom: + item = NSToolbarItem(itemIdentifier: .resetZoom) + default: + item = NSToolbarItem(itemIdentifier: itemIdentifier) + } + + return item + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.titleText, .flexibleSpace, .space, .resetZoom] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + // These space items are here to ensure that the title remains centered when it starts + // getting smaller than the max size so starts clipping. Lucky for us, two of the + // built-in spacers plus the un-zoom button item seems to exactly match the space + // on the left that's reserved for the window buttons. + return [.flexibleSpace, .titleText, .flexibleSpace] + } +} + +/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. +fileprivate class CenteredDynamicLabel: NSTextField { + override func viewDidMoveToSuperview() { + // Configure the text field + isEditable = false + isBordered = false + drawsBackground = false + alignment = .center + lineBreakMode = .byTruncatingTail + cell?.truncatesLastVisibleLine = true + + // Use Auto Layout + translatesAutoresizingMaskIntoConstraints = false + + // Set content hugging and compression resistance priorities + setContentHuggingPriority(.defaultLow, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + } + + // Vertically center the text + override func draw(_ dirtyRect: NSRect) { + guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else { + super.draw(dirtyRect) + return + } + + let textSize = attributedString.size() + + let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better + + let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset, + width: self.bounds.width, height: textSize.height) + + attributedString.draw(in: centeredRect) + } +} + +extension NSToolbarItem.Identifier { + static let resetZoom = NSToolbarItem.Identifier("ResetZoom") + static let titleText = NSToolbarItem.Identifier("TitleText") +} From a7df90ee5529b2cccac5651c57661dac1f350b99 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 13:36:03 -0700 Subject: [PATCH 237/245] macos: remove split zoom accessory when tabs appear --- .../Window Styles/TerminalWindow.swift | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a1bb1d86d..d588a5944 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -12,6 +12,9 @@ class TerminalWindow: NSWindow { /// The view model for SwiftUI views private var viewModel = ViewModel() + /// Reset split zoom button in titlebar + private let resetZoomAccessory = NSTitlebarAccessoryViewController() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() @@ -56,7 +59,6 @@ class TerminalWindow: NSWindow { } // Create our reset zoom titlebar accessory. - let resetZoomAccessory = NSTitlebarAccessoryViewController() resetZoomAccessory.layoutAttribute = .right resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView( viewModel: viewModel, @@ -94,6 +96,18 @@ class TerminalWindow: NSWindow { resetZoomTabButton.contentTintColor = .secondaryLabelColor } + override func becomeMain() { + super.becomeMain() + + // Its possible we miss the accessory titlebar call so we check again + // whenever the window becomes main. Both of these are idempotent. + if hasTabBar { + tabBarDidAppear() + } else { + tabBarDidDisappear() + } + } + override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) @@ -112,13 +126,13 @@ class TerminalWindow: NSWindow { // it. This has been verified to work on macOS 12 to 26 if isTabBar(childViewController) { childViewController.identifier = Self.tabBarIdentifier - viewModel.hasTabBar = true + tabBarDidAppear() } } override func removeTitlebarAccessoryViewController(at index: Int) { if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) { - viewModel.hasTabBar = false + tabBarDidDisappear() } super.removeTitlebarAccessoryViewController(at: index) @@ -130,6 +144,11 @@ class TerminalWindow: NSWindow { /// added. private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + /// Returns true if there is a tab bar visible on this window. + var hasTabBar: Bool { + contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil + } + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { if childViewController.identifier == nil { // The good case @@ -154,6 +173,28 @@ class TerminalWindow: NSWindow { return childViewController.identifier == Self.tabBarIdentifier } + /// Ensures we only run didAppear/didDisappear once per state. + private var tabBarDidAppearRan = false + + private func tabBarDidAppear() { + guard !tabBarDidAppearRan else { return } + tabBarDidAppearRan = true + + // Remove our reset zoom accessory. For some reason having a SwiftUI + // titlebar accessory causes our content view scaling to be wrong. + // Removing it fixes it, we just need to remember to add it again later. + if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { + removeTitlebarAccessoryViewController(at: idx) + } + } + + private func tabBarDidDisappear() { + guard tabBarDidAppearRan else { return } + tabBarDidAppearRan = false + + addTitlebarAccessoryViewController(resetZoomAccessory) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -402,7 +443,6 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false - @Published var hasTabBar: Bool = false } struct ResetZoomAccessoryView: View { @@ -410,7 +450,7 @@ extension TerminalWindow { let action: () -> Void var body: some View { - if viewModel.isSurfaceZoomed && !viewModel.hasTabBar { + if viewModel.isSurfaceZoomed { VStack { Button(action: action) { Image("ResetZoom") From 8cfc904c0c86a8e87cb6f5234aa6fe55e4bd2d53 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 14:38:07 -0700 Subject: [PATCH 238/245] macos: fix up some sequoia regressions --- .../Window Styles/TerminalWindow.swift | 27 +++++++++++-------- macos/Sources/Helpers/Fullscreen.swift | 12 +++------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d588a5944..74181089d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -173,13 +173,7 @@ class TerminalWindow: NSWindow { return childViewController.identifier == Self.tabBarIdentifier } - /// Ensures we only run didAppear/didDisappear once per state. - private var tabBarDidAppearRan = false - private func tabBarDidAppear() { - guard !tabBarDidAppearRan else { return } - tabBarDidAppearRan = true - // Remove our reset zoom accessory. For some reason having a SwiftUI // titlebar accessory causes our content view scaling to be wrong. // Removing it fixes it, we just need to remember to add it again later. @@ -189,10 +183,11 @@ class TerminalWindow: NSWindow { } private func tabBarDidDisappear() { - guard tabBarDidAppearRan else { return } - tabBarDidAppearRan = false - - addTitlebarAccessoryViewController(resetZoomAccessory) + if styleMask.contains(.titled) { + if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil { + addTitlebarAccessoryViewController(resetZoomAccessory) + } + } } // MARK: Tab Key Equivalents @@ -448,6 +443,16 @@ extension TerminalWindow { struct ResetZoomAccessoryView: View { @ObservedObject var viewModel: ViewModel let action: () -> Void + + // The padding from the top that the view appears. This was all just manually + // measured based on the OS. + var topPadding: CGFloat { + if #available(macOS 26.0, *) { + return viewModel.hasToolbar ? 10 : 5 + } else { + return viewModel.hasToolbar ? 9 : 4 + } + } var body: some View { if viewModel.isSurfaceZoomed { @@ -463,7 +468,7 @@ extension TerminalWindow { } // With a toolbar, the window title is taller, so we need more padding // to properly align. - .padding(.top, viewModel.hasToolbar ? 10 : 5) + .padding(.top, topPadding) // We always need space at the end of the titlebar .padding(.trailing, 10) } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index a2294a0af..d78775a1d 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -268,19 +268,15 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing the "titled" style also derefs all our accessory view controllers // so we need to restore those. for c in savedState.titlebarAccessoryViewControllers { - window.addTitlebarAccessoryViewController(c) + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { + window.addTitlebarAccessoryViewController(c) + } } // Removing "titled" also clears our toolbar window.toolbar = savedState.toolbar window.toolbarStyle = savedState.toolbarStyle - - // This is a hack that I want to remove from this but for now, we need to - // fix up the titlebar tabs here before we do everything below. - if let window = window as? TitlebarTabsVenturaTerminalWindow, window.titlebarTabs { - window.titlebarTabs = true - } - + // If the window was previously in a tab group that isn't empty now, // we re-add it. We have to do this because our process of doing non-native // fullscreen removes the window from the tab group. From 1388c277d5978094994b3b928b9d79a950974989 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 14:43:01 -0700 Subject: [PATCH 239/245] macos: sequoia should use same tab bar identifier as TerminalWindow --- .../Terminal/Window Styles/TerminalWindow.swift | 2 +- .../TitlebarTabsVenturaTerminalWindow.swift | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 74181089d..f9dfb9591 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -142,7 +142,7 @@ class TerminalWindow: NSWindow { /// This identifier is attached to the tab bar view controller when we detect it being /// added. - private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") /// Returns true if there is a tab bar visible on this window. var hasTabBar: Bool { diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 20280b982..a236df107 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -196,8 +196,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // We can only update titlebar tabs if there is a titlebar. Without the // styleMask check the app will crash (issue #1876) if titlebarTabs && styleMask.contains(.titled) { - guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.TabBarController}) else { return } - + guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.tabBarIdentifier}) else { return } tabBarAccessoryViewController.layoutAttribute = .right pushTabsToTitlebar(tabBarAccessoryViewController) } @@ -314,9 +313,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private var windowDragHandle: WindowDragView? = nil - // The tab bar controller ID from macOS - static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController") - // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { didSet { @@ -384,10 +380,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { - let isTabBar = self.titlebarTabs && ( - childViewController.layoutAttribute == .bottom || - childViewController.identifier == Self.TabBarController - ) + let isTabBar = self.titlebarTabs && isTabBar(childViewController) if (isTabBar) { // Ensure it has the right layoutAttribute to force it next to our titlebar @@ -399,7 +392,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // Mark the controller for future reference so we can easily find it. Otherwise // the tab bar has no ID by default. - childViewController.identifier = Self.TabBarController + childViewController.identifier = Self.tabBarIdentifier } super.addTitlebarAccessoryViewController(childViewController) @@ -410,7 +403,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } override func removeTitlebarAccessoryViewController(at index: Int) { - let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController + let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier super.removeTitlebarAccessoryViewController(at: index) if (isTabBar) { resetCustomTabBarViews() From ac4b0dcac0b5b864a212ddf2c08280b72c5a8016 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 14:57:49 -0700 Subject: [PATCH 240/245] macos: fix transparent tabs on sequoia --- .../TitlebarTabsVenturaTerminalWindow.swift | 16 ------- .../TransparentTitlebarTerminalWindow.swift | 47 +++++++++++++++++-- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index a236df107..99111b55b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -99,9 +99,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } } - // The remainder of this function only applies to styled tabs. - guard hasStyledTabs else { return } - titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none if titlebarTabs { hideToolbarOverflowButton() @@ -170,19 +167,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // MARK: Tab Bar Styling - // This is true if we should apply styles to the titlebar or tab bar. - var hasStyledTabs: Bool { - // If we have titlebar tabs then we always style. - guard !titlebarTabs else { return true } - - // We style the tabs if they're transparent - return transparentTabs - } - - // Set to true if the background color should bleed through the titlebar/tab bar. - // This only applies to non-titlebar tabs. - var transparentTabs: Bool = false - var hasVeryDarkBackground: Bool { backgroundColor.luminance < 0.05 } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index f949b6094..94e938326 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -11,6 +11,13 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { /// KVO observation for tab group window changes. private var tabGroupWindowsObservation: NSKeyValueObservation? private var tabBarVisibleObservation: NSKeyValueObservation? + + deinit { + tabGroupWindowsObservation?.invalidate() + tabBarVisibleObservation?.invalidate() + } + + // MARK: NSWindow override func awakeFromNib() { super.awakeFromNib() @@ -19,11 +26,6 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // to learn why we need KVO. setupKVO() } - - deinit { - tabGroupWindowsObservation?.invalidate() - tabBarVisibleObservation?.invalidate() - } override func becomeMain() { super.becomeMain() @@ -40,6 +42,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } } } + + override func update() { + super.update() + + // On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our + // titlebar to be truly transparent. + if #unavailable(macOS 26.0) { + hideEffectView() + } + } // MARK: Appearance @@ -86,6 +98,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { guard let titlebarContainer else { return } titlebarContainer.wantsLayer = true titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor + effectViewIsHidden = false } // MARK: View Finders @@ -149,4 +162,28 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { self.syncAppearance(lastSurfaceConfig) } } + + // MARK: macOS 13 to 15 + + // We only need to set this once, but need to do it after the window has been created in order + // to determine if the theme is using a very dark background, in which case we don't want to + // remove the effect view if the default tab bar is being used since the effect created in + // `updateTabsForVeryDarkBackgrounds` creates a confusing visual design. + private var effectViewIsHidden = false + + private func hideEffectView() { + guard !effectViewIsHidden else { return } + + // By hiding the visual effect view, we allow the window's (or titlebar's in this case) + // background color to show through. If we were to set `titlebarAppearsTransparent` to true + // the selected tab would look fine, but the unselected ones and new tab button backgrounds + // would be an opaque color. When the titlebar isn't transparent, however, the system applies + // a compositing effect to the unselected tab backgrounds, which makes them blend with the + // titlebar's/window's background. + if let effectView = titlebarContainer?.descendants(withClassName: "NSVisualEffectView").first { + effectView.isHidden = true + } + + effectViewIsHidden = true + } } From 1b6142b271b72048f82648920f6de0b2b48960f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 15:02:05 -0700 Subject: [PATCH 241/245] macos: don't restore tab bar with non-native fs --- macos/Sources/Helpers/Fullscreen.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index d78775a1d..f3940a9aa 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -268,6 +268,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing the "titled" style also derefs all our accessory view controllers // so we need to restore those. for c in savedState.titlebarAccessoryViewControllers { + // Restoring the tab bar causes all sorts of problems. Its best to just ignore it, + // even though this is kind of a hack. + if let window = window as? TerminalWindow, window.isTabBar(c) { + continue + } + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { window.addTitlebarAccessoryViewController(c) } From 928603c23e1e59717e1c042e3d39487066d9f12f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Jun 2025 20:20:49 -0700 Subject: [PATCH 242/245] macos: use a runtime liquid glass check for our Tahoe styling --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++-- .../Terminal/TerminalController.swift | 2 +- .../Window Styles/TerminalWindow.swift | 2 +- .../TransparentTitlebarTerminalWindow.swift | 4 +- macos/Sources/Helpers/AppInfo.swift | 44 +++++++++++++++++++ macos/Sources/Helpers/Xcode.swift | 10 ----- 6 files changed, 52 insertions(+), 18 deletions(-) create mode 100644 macos/Sources/Helpers/AppInfo.swift delete mode 100644 macos/Sources/Helpers/Xcode.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e9c02ef41..4943f2f4d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -90,7 +90,7 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; }; A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; }; - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; }; A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; @@ -205,7 +205,7 @@ A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; - A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = ""; }; A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -312,8 +312,8 @@ children = ( A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, + A5A6F7292CC41B8700B232A5 /* AppInfo.swift */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, - A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, @@ -756,7 +756,7 @@ A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, - A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, + A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 03a4e548e..49b3fea34 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -16,7 +16,7 @@ class TerminalController: BaseTerminalController { case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" case "tabs": - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), hasLiquidGlass() { "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index f9dfb9591..e24323113 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -447,7 +447,7 @@ extension TerminalWindow { // The padding from the top that the view appears. This was all just manually // measured based on the OS. var topPadding: CGFloat { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), hasLiquidGlass() { return viewModel.hasToolbar ? 10 : 5 } else { return viewModel.hasToolbar ? 9 : 4 diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 94e938326..1a92fa024 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -48,7 +48,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our // titlebar to be truly transparent. - if #unavailable(macOS 26.0) { + if !effectViewIsHidden && !hasLiquidGlass() { hideEffectView() } } @@ -65,7 +65,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // references changed (e.g. tabGroup is new). setupKVO() - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *), hasLiquidGlass() { syncAppearanceTahoe(surfaceConfig) } else { syncAppearanceVentura(surfaceConfig) diff --git a/macos/Sources/Helpers/AppInfo.swift b/macos/Sources/Helpers/AppInfo.swift new file mode 100644 index 000000000..cf66e332d --- /dev/null +++ b/macos/Sources/Helpers/AppInfo.swift @@ -0,0 +1,44 @@ +import Foundation + +/// True if we appear to be running in Xcode. +func isRunningInXcode() -> Bool { + if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { + return true + } + + return false +} + +/// True if we have liquid glass available. +func hasLiquidGlass() -> Bool { + // Can't have liquid glass unless we're in macOS 26+ + if #unavailable(macOS 26.0) { + return false + } + + // If we aren't running SDK 26.0 or later then we definitely + // do not have liquid glass. + guard let sdkName = Bundle.main.infoDictionary?["DTSDKName"] as? String else { + // If we don't have this, we assume we're built against the latest + // since we're on macOS 26+ + return true + } + + // If the SDK doesn't start with macosx then we just assume we + // have it because we already verified we're on macOS above. + guard sdkName.hasPrefix("macosx") else { + return true + } + + // The SDK version must be at least 26 + let versionString = String(sdkName.dropFirst("macosx".count)) + guard let major = if let dotIndex = versionString.firstIndex(of: ".") { + Int(String(versionString[..= 26 +} diff --git a/macos/Sources/Helpers/Xcode.swift b/macos/Sources/Helpers/Xcode.swift deleted file mode 100644 index 281bad18b..000000000 --- a/macos/Sources/Helpers/Xcode.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// True if we appear to be running in Xcode. -func isRunningInXcode() -> Bool { - if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { - return true - } - - return false -} From 5b9f4acbc8d3498614d8784008f735d9333e3752 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 14 Jun 2025 12:30:09 -0700 Subject: [PATCH 243/245] ci: update macOS builders to Sequoia (15) and Xcode 16.4 We have been building on macOS 14 and Xcode 16.0 for a longggg time now. This gets us to a version that will be running Xcode 26 eventually so we can ultimately build for Tahoe on a stable OS. This should change nothing in the interim. --- .github/workflows/release-pr.yml | 8 ++++---- .github/workflows/release-tag.yml | 6 +++--- .github/workflows/release-tip.yml | 12 ++++++------ .github/workflows/test.yml | 12 ++++++------ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 574b1ab73..3f89bd702 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -47,7 +47,7 @@ jobs: sentry-cli dif upload --project ghostty --wait dsym.zip build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -94,7 +94,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - sudo xcode-select -s /Applications/Xcode_16.0.app + sudo xcode-select -s /Applications/Xcode_16.4.app xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. @@ -199,7 +199,7 @@ jobs: destination-dir: ./ build-macos-debug: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -246,7 +246,7 @@ jobs: - name: Build Ghostty.app run: | cd macos - sudo xcode-select -s /Applications/Xcode_16.0.app + sudo xcode-select -s /Applications/Xcode_16.4.app xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index db8049df7..3deafd066 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -120,7 +120,7 @@ jobs: build-macos: needs: [setup] - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 env: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} @@ -139,7 +139,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Setup Sparkle env: @@ -288,7 +288,7 @@ jobs: appcast: needs: [setup, build-macos] - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia env: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 73a1ddeeb..6c6399afd 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -154,7 +154,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -173,7 +173,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app # Setup Sparkle - name: Setup Sparkle @@ -369,7 +369,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -388,7 +388,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app # Setup Sparkle - name: Setup Sparkle @@ -544,7 +544,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -563,7 +563,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app # Setup Sparkle - name: Setup Sparkle diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a98584a2..814acec8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -270,7 +270,7 @@ jobs: ghostty-source.tar.gz build-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -286,7 +286,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: get the Zig deps id: deps @@ -361,7 +361,7 @@ jobs: xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" build-macos-matrix: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -377,7 +377,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: get the Zig deps id: deps @@ -679,7 +679,7 @@ jobs: nix develop -c zig build -Dsentry=${{ matrix.sentry }} test-macos: - runs-on: namespace-profile-ghostty-macos + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -695,7 +695,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_16.0.app + run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: get the Zig deps id: deps From c4a978b07aa1ada5bd6817b2194fa8f853bbff5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 14 Jun 2025 13:49:58 -0700 Subject: [PATCH 244/245] macos: set toolbar title `isBordered` to avoid glass view This was recommended by the WWDC25 session on AppKit updates. My hack was not the right approach. --- .../TitlebarTabsTahoeTerminalWindow.swift | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 145c37c59..9381f7329 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -21,18 +21,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool } } - override var toolbar: NSToolbar? { - didSet{ - guard toolbar != nil else { return } - - // When a toolbar is added, remove the Liquid Glass look to have a cleaner - // appearance for our custom titlebar tabs. - if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") { - glass.isHidden = true - } - } - } - override func awakeFromNib() { super.awakeFromNib() @@ -222,6 +210,11 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) item.visibilityPriority = .user item.isEnabled = true + + // This is the documented way to avoid the glass view on an item. + // We don't want glass on our title. + item.isBordered = false + return item default: return NSToolbarItem(itemIdentifier: itemIdentifier) From 202020cd7d10bb6fa7b61c5d67033ddb5565b52c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 14 Jun 2025 14:21:40 -0700 Subject: [PATCH 245/245] macos: menu item symbols for Tahoe This is recommended for macOS Tahoe and all standard menu items now have associated images. This makes our app look more polished and native for macOS Tahoe. For icon choice, I tried to copy other native macOS apps as much as possible, mostly from Xcode. It looks like a lot of apps aren't updated yet. I'm absolutely open to suggestions for better icons but I think these are a good starting point. One menu change is I moved "reset font size" above "increase font size" which better matches other apps (e.g. Terminal.app). --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ macos/Sources/App/macOS/AppDelegate.swift | 40 ++++++++++++++++++- macos/Sources/App/macOS/MainMenu.xib | 16 ++++---- .../Sources/Ghostty/SurfaceView_AppKit.swift | 25 ++++++++---- .../Extensions/NSMenuItem+Extension.swift | 11 +++++ 5 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 4943f2f4d..a5663202b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; }; A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; @@ -210,6 +211,7 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Extension.swift"; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; @@ -478,6 +480,7 @@ A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, + A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, @@ -763,6 +766,7 @@ A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, + A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7fb52a025..f460017f5 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -166,7 +166,7 @@ class AppDelegate: NSObject, // This registers the Ghostty => Services menu to exist. NSApp.servicesMenu = menuServices - + // Setup a local event monitor for app-level keyboard shortcuts. See // localEventHandler for more info why. _ = NSEvent.addLocalMonitorForEvents( @@ -242,6 +242,9 @@ class AppDelegate: NSObject, ghostty_app_set_color_scheme(app, scheme) } + + // Setup our menu + setupMenuImages() } func applicationDidBecomeActive(_ notification: Notification) { @@ -392,6 +395,41 @@ class AppDelegate: NSObject, return dockMenu } + /// Setup all the images for our menu items. + private func setupMenuImages() { + // Note: This COULD Be done all in the xib file, but I find it easier to + // modify this stuff as code. + self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") + self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") + self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") + self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") + self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") + self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") + self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") + self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") + self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") + self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") + self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") + self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") + self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") + self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") + self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") + self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") + self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") + self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") + self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") + self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") + self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") + self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") + self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") + self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.3.layers.3d.top.filled") + } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 7130d544e..c9bff8b4a 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -251,18 +251,18 @@ - - - - - - + + + + + + diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e4f6f507c..3e87176fc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1281,6 +1281,10 @@ extension Ghostty { let menu = NSMenu() + // We just use a floating var so we can easily setup metadata on each item + // in a row without storing it all. + var item: NSMenuItem + // If we have a selection, add copy if self.selectedRange().length > 0 { menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") @@ -1288,16 +1292,23 @@ extension Ghostty { menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") menu.addItem(.separator()) - menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + item = menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + item = menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + item = menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") menu.addItem(.separator()) - menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") + item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "pencil.line") return menu } diff --git a/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift new file mode 100644 index 000000000..e512904ef --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift @@ -0,0 +1,11 @@ +import AppKit + +extension NSMenuItem { + /// Sets the image property from a symbol if we want images on our menu items. + func setImageIfDesired(systemSymbolName symbol: String) { + // We only set on macOS 26 when icons on menu items became the norm. + if #available(macOS 26, *) { + image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + } + } +}