From fe52eb9494f1a4a631b66953046e07a1d29bb32b Mon Sep 17 00:00:00 2001 From: greathongtu Date: Sat, 27 Sep 2025 15:16:27 +0800 Subject: [PATCH 001/605] Adding keybind 'w' to the +list-themes TUI that would write out a file that contained themes --- src/cli/list_themes.zig | 43 +++++++++++++++++++++++++++++++++++++- src/config/config-template | 7 +++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index cc6cfaf3e..e54bbf307 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -192,6 +192,28 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { return 0; } +fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 { + const main_cfg_path = try Config.preferredDefaultFilePath(alloc); + defer alloc.free(main_cfg_path); + + const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName; + return try std.fs.path.join(alloc, &.{ base_dir, "auto", "theme.ghostty" }); +} + +fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { + const auto_path = try resolveAutoThemePath(alloc); + defer alloc.free(auto_path); + + if (std.fs.path.dirname(auto_path)) |dir| { + try std.fs.cwd().makePath(dir); + } + + var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true }); + defer f.close(); + + try f.writer().print("theme = {s}\n", .{theme_name}); +} + const Event = union(enum) { key_press: vaxis.Key, mouse: vaxis.Mouse, @@ -483,6 +505,9 @@ const Preview = struct { self.should_quit = true; if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) self.mode = .normal; + if (key.matches('w', .{})) { + self.saveSelectedTheme(); + } }, } }, @@ -694,7 +719,7 @@ const Preview = struct { .help => { win.hideCursor(); const width = 60; - const height = 20; + const height = 22; const child = win.child( .{ .x_off = win.width / 2 -| width / 2, @@ -729,6 +754,7 @@ const Preview = struct { .{ .keys = "/", .help = "Start search." }, .{ .keys = "^X, ^/", .help = "Clear search." }, .{ .keys = "⏎", .help = "Save theme or close search window." }, + .{ .keys = "w", .help = "Write theme to auto config file." }, }; for (key_help, 0..) |help, captured_i| { @@ -805,6 +831,9 @@ const Preview = struct { try std.fmt.allocPrint(alloc, "theme = {s}", .{theme.theme}), "", "Save the configuration file and then reload it to apply the new theme.", + "", + "Or press 'w' to write an auto theme file.", + "", "For more details on configuration and themes, visit the Ghostty documentation:", "", "https://ghostty.org/docs/config/reference", @@ -1653,6 +1682,18 @@ const Preview = struct { }); } } + + fn saveSelectedTheme(self: *Preview) void { + if (self.filtered.items.len == 0) + return; + + const idx = self.filtered.items[self.current]; + const theme = self.themes[idx]; + + writeAutoThemeFile(self.allocator, theme.theme) catch { + return; + }; + } }; fn color(config: Config, palette: usize) vaxis.Color { diff --git a/src/config/config-template b/src/config/config-template index 63309137a..d71c36a9e 100644 --- a/src/config/config-template +++ b/src/config/config-template @@ -24,6 +24,13 @@ # reloaded while running; some only apply to new windows and others may require # a full restart to take effect. +# Auto theme include +# ================== +# This include makes it easy to pick a theme via `ghostty +list-themes`: +# press Enter on a theme, then 'w' to write the auto theme file. +# This path is relative to this config file. +config-file = ?auto/theme.ghostty + # Config syntax crash course # ========================== # # The config file consists of simple key-value pairs, From 906dac3145e063d4b5a5f6dd10db1d55027ced79 Mon Sep 17 00:00:00 2001 From: greathongtu Date: Sat, 4 Oct 2025 21:17:04 +0800 Subject: [PATCH 002/605] io as interface --- src/cli/list_themes.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index e54bbf307..9b11947df 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -211,7 +211,10 @@ fn writeAutoThemeFile(alloc: std.mem.Allocator, theme_name: []const u8) !void { var f = try std.fs.createFileAbsolute(auto_path, .{ .truncate = true }); defer f.close(); - try f.writer().print("theme = {s}\n", .{theme_name}); + var buf: [128]u8 = undefined; + var w = f.writer(&buf); + try w.interface.print("theme = {s}\n", .{theme_name}); + try w.interface.flush(); } const Event = union(enum) { From 9339ccf769b122d1f171462f73d423de1b05df09 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 26 Oct 2025 16:09:14 -0700 Subject: [PATCH 003/605] Decouple balanced top and left window paddings --- src/renderer/size.zig | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/renderer/size.zig b/src/renderer/size.zig index b26c1581e..d8b529c26 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -44,6 +44,15 @@ pub const Size = struct { self.grid(), self.cell, ); + + // The top/bottom padding is interesting. Subjectively, lots of padding + // at the top looks bad. So instead of always being equal (like left/right), + // we force the top padding to be at most equal to the maximum left padding, + // which is the balanced explicit horizontal padding plus half a cell width. + const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_padding_left; + self.padding.top -= vshift; + self.padding.bottom += vshift; } }; @@ -258,16 +267,12 @@ pub const Padding = struct { const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width; const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height; - // The left/right padding is just an equal split. + // The padding is split equally along both axes. const padding_right = @floor(space_right / 2); const padding_left = padding_right; - // The top/bottom padding is interesting. Subjectively, lots of padding - // at the top looks bad. So instead of always being equal (like left/right), - // we force the top padding to be at most equal to the left, and the bottom - // padding is the difference thereafter. - const padding_top = @min(padding_left, @floor(space_bot / 2)); - const padding_bot = space_bot - padding_top; + const padding_bot = @floor(space_bot / 2); + const padding_top = padding_bot; const zero = @as(f32, 0); return .{ From 45ead5ea99bdf08686d3b7cd2e0e2c005ca91c4a Mon Sep 17 00:00:00 2001 From: David Matos Date: Sun, 16 Nov 2025 09:20:29 +0100 Subject: [PATCH 004/605] Provide shell ssh integration for nushell --- src/shell-integration/README.md | 13 ++ .../vendor/autoload/ghostty-integration.nu | 114 ++++++++++++++++++ src/termio/shell_integration.zig | 30 +++-- 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 1fd11091d..5ef1106af 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -88,3 +88,16 @@ if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration fi ``` + +### Nushell + +For `nushell` Ghostty prepends to the `XDG_DATA_DIRS` directory. Nushell automatically +loads configuration files in `/nushell/vendor/autoload/*.nu` on startup. These +directories are represented in `Nu` by `$nu.vendor-autoload-dirs`. For more details see + +[Nushell documentation](https://www.nushell.sh/book/configuration.html#configuration-overview) + +> [!NOTE] +> +> Ghostty only prepends to `XDG_DATA_DIRS` in the case where the `ssh-*` features are enabled. +> There is no shell integration for the other currently supported features. diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu new file mode 100644 index 000000000..167f9ca21 --- /dev/null +++ b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu @@ -0,0 +1,114 @@ +# Enables SSH environment variable compatibility. +# Converts TERM from xterm-ghostty to xterm-256color +# and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION +# check your sshd_config on remote host to see if these variables are accepted +def set_ssh_env []: nothing -> record> { + return {ssh_term: "xterm-256color", ssh_opts: ["-o", "SetEnv COLORTERM=truecolor", "-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"]} +} + +# Enables automatic terminfo installation on remote hosts. +# Attempts to install Ghostty's terminfo entry using infocmp and tic when +# connecting to hosts that lack it. +# Requires infocmp to be available locally and tic to be available on remote hosts. +# Caches installations to avoid repeat installations. +def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { + mut ssh_opts = $ssh_opts + let ssh_cfg = ( + run-external "ssh" "-G" ...($ssh_args) + | lines + | each {|e| if $e =~ '\buser\b' or $e =~ '\bhostname\b' {split row ' '}} + ) + let ssh_user = $ssh_cfg.0.1 + let ssh_hostname = $ssh_cfg.1.1 + let ssh_id = $"($ssh_user)@($ssh_hostname)" + let ghostty_bin = $env.GHOSTTY_BIN_DIR + "/ghostty" + let check_cache_cmd = [$ghostty_bin, "+ssh-cache", $"--host=($ssh_id)"] + + let is_cached = ( + run-external ...$check_cache_cmd + | complete + | get exit_code + | $in == 0 + ) + + if not $is_cached { + let ssh_opts_copy = $ssh_opts + let terminfo_data = try {infocmp -0 -x xterm-ghostty} catch { + print "Warning: Could not generate terminfo data." + return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts_copy} + } + + print $"Setting up xterm-ghostty terminfo on ($ssh_hostname)..." + + let tmp_dir = $"/tmp/ghostty-ssh-($ssh_user).XXXXXX" + let mktemp_cmd = ["mktemp", "-d", $tmp_dir] + let ctrl_dir = try { + run-external ...$mktemp_cmd + | str trim + } catch { + $"/tmp/ghostty-ssh-($ssh_user).($nu.pid)" + } + let ctrl_path = $"($ctrl_dir)/socket" + + let master_parts = $ssh_opts ++ ["-o", "ControlMaster=yes", "-o", $"ControlPath=($ctrl_path)", "-o", "ControlPersist=60s"] ++ $ssh_args + + let infocmp_cmd = ["ssh"] ++ $master_parts ++ ["infocmp", "xterm-ghostty"] + + let terminfo_present = ( + run-external ...$infocmp_cmd + | complete + | get exit_code + | $in == 0 + ) + + if (not $terminfo_present) { + let install_terminfo_cmd = ["ssh"] ++ $master_parts ++ ["mkdir", "-p", "~/.terminfo", "&&", "tic", "-x", "-"] + + ($terminfo_data | run-external ...$install_terminfo_cmd) | complete | get exit_code | if $in != 0 { + print "Warning: Failed to install terminfo." + return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts} + } + let state_dir = try { $env.XDG_STATE_HOME } catch { $env.HOME | path join ".local/state" } + let ghostty_state_dir = $state_dir | path join "ghostty" + + let cache_add_cmd = [$ghostty_bin, "+ssh-cache", $"--add=($ssh_id)"] + # Bug?: If I dont add TMPDIR, it complains about renameacrossmountpoints + with-env { TMPDIR: $ghostty_state_dir } { + run-external ...$cache_add_cmd o+e>| ignore + } + } + $ssh_opts ++= ["-o", $"ControlPath=($ctrl_path)"] + } + + return {ssh_term: "xterm-ghostty", ssh_opts: $ssh_opts} +} + +# SSH Integration +export def --wrapped ssh [...ssh_args: string] { + if ($ssh_args | is-empty) { + run-external "ssh" + } + mut session = {ssh_term: "", ssh_opts: []} + let shell_features = $env.GHOSTTY_SHELL_FEATURES | split row ',' + + if "ssh-env" in $shell_features { + $session = set_ssh_env + } + if "ssh-terminfo" in $shell_features { + $session = set_ssh_terminfo $session.ssh_opts $ssh_args + } + + let ssh_parts = $session.ssh_opts ++ $ssh_args + with-env { TERM: $session.ssh_term } { + run-external "ssh" ...$ssh_parts + } +} + +# Removes Ghostty's data directory from XDG_DATA_DIRS +let ghostty_data_dir = $env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR +$env.XDG_DATA_DIRS = $env.XDG_DATA_DIRS + | split row ':' + | where ( + | $it !~ $ghostty_data_dir + ) + | str join ':' diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8b2648dbd..afe1f547f 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -68,6 +68,7 @@ pub fn setup( command, env, exe, + features, ); // Setup our feature env vars @@ -76,13 +77,15 @@ pub fn setup( return result; } -fn setupShell( - alloc_arena: Allocator, - resource_dir: []const u8, - command: config.Command, - env: *EnvMap, - exe: []const u8, -) !?ShellIntegration { +fn setupShell(alloc_arena: Allocator, resource_dir: []const u8, command: config.Command, env: *EnvMap, exe: []const u8, features: config.ShellIntegrationFeatures) !?ShellIntegration { + if (std.mem.eql(u8, "nu", exe)) { + try setupNu(alloc_arena, resource_dir, env, features); + return null; + // // return .{ + // // .shell = .nu, + // // .command = try command.clone(alloc_arena), + // // }; + } if (std.mem.eql(u8, "bash", exe)) { // Apple distributes their own patched version of Bash 3.2 // on macOS that disables the ENV-based POSIX startup path. @@ -652,6 +655,19 @@ test "xdg: existing XDG_DATA_DIRS" { try testing.expectEqualStrings("./shell-integration:/opt/share", env.get("XDG_DATA_DIRS").?); } +/// Setup the nushell shell integration. This works by setting +/// XDG_DATA_DIRS so that it can be picked automatically by +/// nushell on startup. +/// Only implements `ssh-*` shell features. Rest are not supported. +fn setupNu(alloc_arena: Allocator, resource_dir: []const u8, env: *EnvMap, features: config.ShellIntegrationFeatures) !void { + // This makes sure that `Nu` loads our integration file + // and wraps the `ssh` function only if the `ssh` features + // are enabled. + // Otherwise, it does not do anything. + if (features.@"ssh-env" or features.@"ssh-terminfo") { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + } +} /// Setup the zsh automatic shell integration. This works by setting /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config. From 52f94f445d5ca3890da9c7821c09afc4330da3a1 Mon Sep 17 00:00:00 2001 From: David Matos Date: Tue, 18 Nov 2025 17:22:31 +0100 Subject: [PATCH 005/605] Use ^ssh directly rather than run-external Modify README --- src/shell-integration/README.md | 3 +- .../vendor/autoload/ghostty-integration.nu | 42 +++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 5ef1106af..8a01ed625 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -100,4 +100,5 @@ directories are represented in `Nu` by `$nu.vendor-autoload-dirs`. For more deta > [!NOTE] > > Ghostty only prepends to `XDG_DATA_DIRS` in the case where the `ssh-*` features are enabled. -> There is no shell integration for the other currently supported features. +> Nushell supports most features out of the box, so other shell integration features are not +> necessary. diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu index 167f9ca21..1a1b377de 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu @@ -13,19 +13,20 @@ def set_ssh_env []: nothing -> record> # Caches installations to avoid repeat installations. def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { mut ssh_opts = $ssh_opts - let ssh_cfg = ( - run-external "ssh" "-G" ...($ssh_args) + let ssh_cfg = ^ssh -G ...($ssh_args) | lines - | each {|e| if $e =~ '\buser\b' or $e =~ '\bhostname\b' {split row ' '}} - ) - let ssh_user = $ssh_cfg.0.1 - let ssh_hostname = $ssh_cfg.1.1 + | split column -n 2 " " key value + | where key == "user" or key == "hostname" + | transpose -r + | into record + let ssh_user = $ssh_cfg.user + let ssh_hostname = $ssh_cfg.hostname let ssh_id = $"($ssh_user)@($ssh_hostname)" let ghostty_bin = $env.GHOSTTY_BIN_DIR + "/ghostty" - let check_cache_cmd = [$ghostty_bin, "+ssh-cache", $"--host=($ssh_id)"] + let check_cache_cmd = ["+ssh-cache", $"--host=($ssh_id)"] let is_cached = ( - run-external ...$check_cache_cmd + ^$ghostty_bin ...$check_cache_cmd | complete | get exit_code | $in == 0 @@ -40,41 +41,40 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { print $"Setting up xterm-ghostty terminfo on ($ssh_hostname)..." - let tmp_dir = $"/tmp/ghostty-ssh-($ssh_user).XXXXXX" - let mktemp_cmd = ["mktemp", "-d", $tmp_dir] let ctrl_dir = try { - run-external ...$mktemp_cmd - | str trim + mktemp -td $"ghostty-ssh-($ssh_user).XXXXXX" } catch { - $"/tmp/ghostty-ssh-($ssh_user).($nu.pid)" + $"/tmp/ghostty-ssh-($ssh_user).($nu.pid)" } + let ctrl_path = $"($ctrl_dir)/socket" let master_parts = $ssh_opts ++ ["-o", "ControlMaster=yes", "-o", $"ControlPath=($ctrl_path)", "-o", "ControlPersist=60s"] ++ $ssh_args - let infocmp_cmd = ["ssh"] ++ $master_parts ++ ["infocmp", "xterm-ghostty"] + let infocmp_cmd = $master_parts ++ ["infocmp", "xterm-ghostty"] let terminfo_present = ( - run-external ...$infocmp_cmd + ^ssh ...$infocmp_cmd | complete | get exit_code | $in == 0 ) if (not $terminfo_present) { - let install_terminfo_cmd = ["ssh"] ++ $master_parts ++ ["mkdir", "-p", "~/.terminfo", "&&", "tic", "-x", "-"] + let install_terminfo_cmd = $master_parts ++ ["mkdir", "-p", "~/.terminfo", "&&", "tic", "-x", "-"] - ($terminfo_data | run-external ...$install_terminfo_cmd) | complete | get exit_code | if $in != 0 { + ($terminfo_data | ^ssh ...$install_terminfo_cmd) | complete | get exit_code | if $in != 0 { print "Warning: Failed to install terminfo." return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts} } let state_dir = try { $env.XDG_STATE_HOME } catch { $env.HOME | path join ".local/state" } let ghostty_state_dir = $state_dir | path join "ghostty" - let cache_add_cmd = [$ghostty_bin, "+ssh-cache", $"--add=($ssh_id)"] + let cache_add_cmd = ["+ssh-cache", $"--add=($ssh_id)"] + # Bug?: If I dont add TMPDIR, it complains about renameacrossmountpoints with-env { TMPDIR: $ghostty_state_dir } { - run-external ...$cache_add_cmd o+e>| ignore + ^$ghostty_bin ...$cache_add_cmd o+e>| ignore } } $ssh_opts ++= ["-o", $"ControlPath=($ctrl_path)"] @@ -86,7 +86,7 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { # SSH Integration export def --wrapped ssh [...ssh_args: string] { if ($ssh_args | is-empty) { - run-external "ssh" + return (^ssh) } mut session = {ssh_term: "", ssh_opts: []} let shell_features = $env.GHOSTTY_SHELL_FEATURES | split row ',' @@ -100,7 +100,7 @@ export def --wrapped ssh [...ssh_args: string] { let ssh_parts = $session.ssh_opts ++ $ssh_args with-env { TERM: $session.ssh_term } { - run-external "ssh" ...$ssh_parts + ^ssh ...$ssh_parts } } From 3e5c4590da8e6303eface76535b3798c182f598a Mon Sep 17 00:00:00 2001 From: LN Liberda Date: Wed, 8 Oct 2025 05:24:30 +0200 Subject: [PATCH 006/605] Add system integration for highway --- src/build/SharedDeps.zig | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index dfa676bba..e530e4885 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,15 +719,19 @@ pub fn addSimd( } // Highway - if (b.lazyDependency("highway", .{ - .target = target, - .optimize = optimize, - })) |highway_dep| { - m.linkLibrary(highway_dep.artifact("highway")); - if (static_libs) |v| try v.append( - b.allocator, - highway_dep.artifact("highway").getEmittedBin(), - ); + if (b.systemIntegrationOption("highway", .{})) { + m.linkSystemLibrary("libhwy", dynamic_link_opts); + } else { + if (b.lazyDependency("highway", .{ + .target = target, + .optimize = optimize, + })) |highway_dep| { + m.linkLibrary(highway_dep.artifact("highway")); + if (static_libs) |v| try v.append( + b.allocator, + highway_dep.artifact("highway").getEmittedBin(), + ); + } } // utfcpp - This is used as a dependency on our hand-written C++ code @@ -746,6 +750,7 @@ pub fn addSimd( m.addIncludePath(b.path("src")); { // From hwy/detect_targets.h + const HWY_AVX10_2: c_int = 1 << 3; const HWY_AVX3_SPR: c_int = 1 << 4; const HWY_AVX3_ZEN4: c_int = 1 << 6; const HWY_AVX3_DL: c_int = 1 << 7; @@ -756,7 +761,7 @@ pub fn addSimd( // The performance difference between AVX2 and AVX512 is not // significant for our use case and AVX512 is very rare on consumer // hardware anyways. - const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; m.addCSourceFiles(.{ .files = &.{ From 701a2a1e05806094b5752e42caa3032a118fbdba Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 08:53:04 -0600 Subject: [PATCH 007/605] gtk: update nixpkgs and zig-gobject for Gnome 49 --- build.zig.zon | 6 +++--- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flake.lock | 37 +++++++++++++------------------------ flake.nix | 9 ++++----- flatpak/zig-packages.json | 6 +++--- 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index dfccaf61d..92246cdba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,10 +55,10 @@ .lazy = true, }, .gobject = .{ - // https://github.com/jcollie/ghostty-gobject based on zig_gobject + // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd2621b2e..0e3b9b97a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": { + "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=" + "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c38504847..73a769ea4 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV"; + name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst"; - hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="; + url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst"; + hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 6bd86a206..189f7f320 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,6 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz -https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz @@ -27,6 +26,7 @@ https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.t https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz +https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz diff --git a/flake.lock b/flake.lock index 90b97ed4a..0150f7b84 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "type": "github" }, "original": { @@ -36,30 +36,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 315532800, - "narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=", - "rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469", + "lastModified": 1763191728, + "narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=", + "rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz" }, "original": { "type": "tarball", "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1758360447, - "narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=", - "rev": "8eaee110344796db060382e15d3af0a9fc396e0e", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" - } - }, "root": { "inputs": { "flake-compat": "flake-compat", @@ -97,11 +84,11 @@ ] }, "locked": { - "lastModified": 1760401936, - "narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=", + "lastModified": 1763295135, + "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "365085b6652259753b598d43b723858184980bbe", + "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", "type": "github" }, "original": { @@ -112,7 +99,9 @@ }, "zon2nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1758405547, diff --git a/flake.nix b/flake.nix index 3dcfef185..18ca3ac18 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,9 @@ # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. # - # We are currently on unstable to get Zig 0.15 for our package.nix + # We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and + # Gnome 49/Gtk 4.20. + # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; @@ -28,10 +30,7 @@ zon2nix = { url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245"; inputs = { - # Don't override nixpkgs until Zig 0.15 is available in the Nix branch - # we are using for "normal" builds. - # - # nixpkgs.follows = "nixpkgs"; + nixpkgs.follows = "nixpkgs"; }; }; }; diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 8ed18e38b..417284788 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", - "sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a" + "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, { "type": "archive", From f1ab3b20ae6a6798d80cda190073d3d9c079507d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 08:53:36 -0600 Subject: [PATCH 008/605] gtk: support GTK 4.20 media queries in runtime & custom css --- src/apprt/gtk/class/application.zig | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..0d66d16ec 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1580,7 +1580,7 @@ pub const Application = extern struct { .dark; log.debug("style manager changed scheme={}", .{scheme}); - const priv = self.private(); + const priv: *Private = self.private(); const core_app = priv.core_app; core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { log.warn("error updating app color scheme err={}", .{err}); @@ -1593,6 +1593,26 @@ pub const Application = extern struct { ); }; } + + if (gtk_version.atLeast(4, 20, 0)) { + const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) { + .light => gtk.InterfaceColorScheme.light, + .dark => gtk.InterfaceColorScheme.dark, + }; + var value = gobject.ext.Value.newFrom(gtk_scheme); + gobject.Object.setProperty( + priv.css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + for (priv.custom_css_providers.items) |css_provider| { + gobject.Object.setProperty( + css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + } + } } fn handleReloadConfig( From 7ba88a71786392dccb03da8c37937676f46a7cd1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 14 Oct 2025 09:35:54 -0500 Subject: [PATCH 009/605] synthetic: make bytes generation more flexible --- src/synthetic/Bytes.zig | 92 ++++++++++++++++++++++++++++++------- src/synthetic/Osc.zig | 72 +++++++++++++++++------------ src/synthetic/cli/Ascii.zig | 19 +++++--- 3 files changed, 130 insertions(+), 53 deletions(-) diff --git a/src/synthetic/Bytes.zig b/src/synthetic/Bytes.zig index 40a94e0e3..7d4c34a33 100644 --- a/src/synthetic/Bytes.zig +++ b/src/synthetic/Bytes.zig @@ -1,4 +1,4 @@ -/// Generates bytes. +//! Generates bytes. const Bytes = @This(); const std = @import("std"); @@ -7,9 +7,7 @@ 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. +/// The minimum and maximum length of the generated bytes. min_len: usize = 1, max_len: usize = std.math.maxInt(usize), @@ -18,23 +16,79 @@ max_len: usize = std.math.maxInt(usize), /// side effect of the generator, not an intended use case. alphabet: ?[]const u8 = null, -/// Predefined alphabets. -pub const Alphabet = struct { - pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; -}; +/// Generate an alphabet given a function that returns true/false for a +/// given byte. +pub fn generateAlphabet(comptime func: fn (u8) bool) []const u8 { + @setEvalBranchQuota(3000); + var count = 0; + for (0..256) |c| { + if (func(c)) count += 1; + } + var alphabet: [count]u8 = undefined; + var i = 0; + for (0..256) |c| { + if (func(c)) { + alphabet[i] = c; + i += 1; + } + } + const result = alphabet; + return &result; +} pub fn generator(self: *Bytes) Generator { return .init(self, next); } -pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { - std.debug.assert(max_len >= 1); - const len = @min( - self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - max_len, - ); +/// Return a copy of the Bytes, but with a new alphabet. +pub fn newAlphabet(self: *const Bytes, new_alphabet: ?[]const u8) Bytes { + return .{ + .rand = self.rand, + .alphabet = new_alphabet, + .min_len = self.min_len, + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new min_len. The new min +/// len cannot be more than the previous max_len. +pub fn atLeast(self: *const Bytes, new_min_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.max_len, new_min_len), + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new max_len. The new max_len cannot +/// be more the previous max_len. +pub fn atMost(self: *const Bytes, new_max_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.min_len, @min(self.max_len, new_max_len)), + .max_len = @min(self.max_len, new_max_len), + }; +} + +pub fn next(self: *const Bytes, writer: *std.Io.Writer, max_len: usize) std.Io.Writer.Error!void { + _ = try self.atMost(max_len).write(writer); +} + +pub fn format(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!void { + _ = try self.write(writer); +} + +/// Write some random data and return the number of bytes written. +pub fn write(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!usize { + std.debug.assert(self.min_len >= 1); + std.debug.assert(self.max_len >= self.min_len); + + const len = self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len); var buf: [8]u8 = undefined; + var remaining = len; while (remaining > 0) { const data = buf[0..@min(remaining, buf.len)]; @@ -45,6 +99,8 @@ pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Erro try writer.writeAll(data); remaining -= data.len; } + + return len; } test "bytes" { @@ -52,9 +108,11 @@ test "bytes" { var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var v: Bytes = .{ .rand = prng.random() }; - v.min_len = buf.len; - v.max_len = buf.len; + var v: Bytes = .{ + .rand = prng.random(), + .min_len = buf.len, + .max_len = buf.len, + }; const gen = v.generator(); try gen.next(&writer, buf.len); try testing.expectEqual(buf.len, writer.buffered().len); diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index 52940fee9..b43079e1a 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -35,19 +35,26 @@ p_valid: f64 = 1.0, 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; -}; +fn checkKvAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel, ';', '=' => false, + else => std.ascii.isPrint(c), + }; +} + +/// The alphabet for random bytes in OSC key/value pairs (omitting 0x1B, +/// 0x07, ';', '='). +pub const kv_alphabet = Bytes.generateAlphabet(checkKvAlphabet); + +fn checkOscAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel => false, + else => true, + }; +} + +/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). +pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -99,35 +106,43 @@ fn nextUnwrapped(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.E fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKind, max_len: usize) Generator.Error!void { switch (k) { - .change_window_title => { - try writer.writeAll("0;"); // Set window title - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len - 2); + .change_window_title => change_window_title: { + if (max_len < 3) break :change_window_title; + try writer.print("0;{f}", .{self.bytes().atMost(max_len - 3)}); // Set window title }, - .prompt_start => { + .prompt_start => prompt_start: { + if (max_len < 4) break :prompt_start; + var remaining = max_len; + try writer.writeAll("133;A"); // Start prompt + remaining -= 4; // aid - if (self.rand.boolean()) { - var bytes_gen = self.bytes(); - bytes_gen.max_len = 16; + if (self.rand.boolean()) aid: { + if (remaining < 6) break :aid; try writer.writeAll(";aid="); - try bytes_gen.next(writer, max_len); + remaining -= 5; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(16, remaining)).write(writer); } // redraw - if (self.rand.boolean()) { + if (self.rand.boolean()) redraw: { + if (remaining < 9) break :redraw; try writer.writeAll(";redraw="); if (self.rand.boolean()) { try writer.writeAll("1"); } else { try writer.writeAll("0"); } + remaining -= 9; } }, - .prompt_end => try writer.writeAll("133;B"), // End prompt + .prompt_end => prompt_end: { + if (max_len < 4) break :prompt_end; + try writer.writeAll("133;B"); // End prompt + }, } } @@ -139,14 +154,11 @@ fn nextUnwrappedInvalidExact( ) Generator.Error!void { switch (k) { .random => { - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len); + try self.bytes().atMost(max_len).format(writer); }, .good_prefix => { - try writer.writeAll("133;"); - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len - 4); + try writer.print("133;{f}", .{self.bytes().atMost(max_len - 4)}); }, } } @@ -154,7 +166,7 @@ fn nextUnwrappedInvalidExact( fn bytes(self: *const Osc) Bytes { return .{ .rand = self.rand, - .alphabet = bytes_alphabet, + .alphabet = osc_alphabet, }; } diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index b2d57fa88..22ca1ffb5 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -3,12 +3,21 @@ const Ascii = @This(); const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const synthetic = @import("../main.zig"); +const Bytes = @import("../Bytes.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct {}; +fn checkAsciiAlphabet(c: u8) bool { + return switch (c) { + ' ' => false, + else => std.ascii.isPrint(c), + }; +} + +pub const ascii = Bytes.generateAlphabet(checkAsciiAlphabet); + /// Create a new terminal stream handler for the given arguments. pub fn create( alloc: Allocator, @@ -23,12 +32,10 @@ pub fn destroy(self: *Ascii, alloc: Allocator) void { alloc.destroy(self); } -pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { - _ = self; - - var gen: synthetic.Bytes = .{ +pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { + var gen: Bytes = .{ .rand = rand, - .alphabet = synthetic.Bytes.Alphabet.ascii, + .alphabet = ascii, }; while (true) { From 10fcd9111cdeb7a8fc4f7caa3fed0b01e8cb4a9a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 21 Aug 2025 17:38:46 -0500 Subject: [PATCH 010/605] nix: make 'nix flake check' happy --- flake.nix | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 3dcfef185..e744c1a09 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,7 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix { zig = zig.packages.${system}."0.15.2"; wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; @@ -96,6 +96,9 @@ in { type = "app"; program = "${program}"; + meta = { + description = "start a vm from ${toString module}"; + }; } ); in { @@ -121,11 +124,6 @@ ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; - create-vm = import ./nix/vm/create.nix; - create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; - create-gnome-vm = import ./nix/vm/create-gnome.nix; - create-plasma6-vm = import ./nix/vm/create-plasma6.nix; - create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { From ca8313570c4180885a5ab55cdd04bc238292d083 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 21 Aug 2025 17:39:02 -0500 Subject: [PATCH 011/605] nix: add vm-based integration tests --- .gitignore | 1 + flake.lock | 22 ++++++ flake.nix | 12 +++ nix/tests.nix | 167 ++++++++++++++++++++++++++++++++++++++++ nix/vm/common-gnome.nix | 13 ++++ nix/vm/common.nix | 7 +- 6 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 nix/tests.nix diff --git a/.gitignore b/.gitignore index e451b171a..e521f8851 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ zig-cache/ .zig-cache/ zig-out/ /result* +/.nixos-test-history example/*.wasm test/ghostty test/cases/**/*.actual.png diff --git a/flake.lock b/flake.lock index 90b97ed4a..ece49febb 100644 --- a/flake.lock +++ b/flake.lock @@ -34,6 +34,27 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755776884, + "narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.05", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 315532800, @@ -64,6 +85,7 @@ "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", + "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" diff --git a/flake.nix b/flake.nix index e744c1a09..aac42fbc0 100644 --- a/flake.nix +++ b/flake.nix @@ -34,6 +34,13 @@ # nixpkgs.follows = "nixpkgs"; }; }; + + home-manager = { + url = "github:nix-community/home-manager?ref=release-25.05"; + inputs = { + nixpkgs.follows = "nixpkgs"; + }; + }; }; outputs = { @@ -41,6 +48,7 @@ nixpkgs, zig, zon2nix, + home-manager, ... }: builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( @@ -80,6 +88,10 @@ formatter.${system} = pkgs.alejandra; + checks.${system} = import ./nix/tests.nix { + inherit home-manager nixpkgs self system; + }; + apps.${system} = let runVM = ( module: let diff --git a/nix/tests.nix b/nix/tests.nix new file mode 100644 index 000000000..51fafad3e --- /dev/null +++ b/nix/tests.nix @@ -0,0 +1,167 @@ +{ + self, + system, + nixpkgs, + home-manager, + ... +}: let + nixos-version = nixpkgs.lib.trivial.release; + + pkgs = import nixpkgs { + inherit system; + overlays = [ + self.overlays.debug + ]; + }; + + pink_value = "#FF0087"; + + color_test = '' + import tempfile + import subprocess + + def check_for_pink(final=False) -> bool: + with tempfile.NamedTemporaryFile() as tmpin: + machine.send_monitor_command("screendump {}".format(tmpin.name)) + + cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format( + tmpin.name + ) + ret = subprocess.run(cmd, shell=True, capture_output=True) + if ret.returncode != 0: + raise Exception( + "image analysis failed with exit code {}".format(ret.returncode) + ) + + text = ret.stdout.decode("utf-8") + return "${pink_value}" in text + ''; + + mkTestGnome = { + name, + settings, + testScript, + ocr ? false, + }: + pkgs.testers.runNixOSTest { + name = name; + + enableOCR = ocr; + + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + + nodes = { + machine = { + config, + pkgs, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation.vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + }; + + testScript = testScript; + }; +in { + basic-version-check = pkgs.testers.runNixOSTest { + name = "basic-version-check"; + nodes = { + machine = {pkgs, ...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + packages = [ + pkgs.ghostty + ]; + }; + }; + }; + testScript = {...}: '' + machine.succeed("su - ghostty -c 'ghostty +version'") + ''; + }; + + basic-window-check-gnome = mkTestGnome { + name = "basic-window-check-gnome"; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = '' + background = ${pink_value} + ''; + }; + }; + }; + ocr = true; + testScript = {nodes, ...}: let + user = nodes.machine.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + ${color_test} + + with subtest("wait for x"): + start_all() + machine.wait_for_x() + + machine.wait_for_file("${bus_path}") + + with subtest("Ensuring no pink is present without the terminal."): + assert ( + check_for_pink() == False + ), "Pink was present on the screen before we even launched a terminal!" + + machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + machine.succeed("${su "${ghostty} +new-window"}") + machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + machine.sleep(2) + + with subtest("Have the terminal display a color."): + assert( + check_for_pink() == True + ), "Pink was not found on the screen!" + + machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") + ''; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index 0c2bef150..ab4aab9e9 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -22,6 +22,19 @@ }; }; + systemd.user.services = { + "org.gnome.Shell@wayland" = { + serviceConfig = { + ExecStart = [ + # Clear the list before overriding it. + "" + # Eval API is now internal so Shell needs to run in unsafe mode. + "${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode" + ]; + }; + }; + }; + environment.systemPackages = [ pkgs.gnomeExtensions.no-overview ]; diff --git a/nix/vm/common.nix b/nix/vm/common.nix index eefd7c1c0..63b7570b8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -35,12 +35,6 @@ initialPassword = "ghostty"; }; - environment.etc = { - "xdg/autostart/com.mitchellh.ghostty.desktop" = { - source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; - }; - }; - environment.systemPackages = [ pkgs.kitty pkgs.fish @@ -61,6 +55,7 @@ services.displayManager = { autoLogin = { + enable = true; user = "ghostty"; }; }; From f9d6a6d56fa8a3bf71d857ba1bcd0939453c648e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:02:00 -0500 Subject: [PATCH 012/605] nix vm tests: update contributors documentation --- CONTRIBUTING.md | 263 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..34e6b273b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,3 +142,266 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +# Developer Guide + +> [!NOTE] +> +> **The remainder of this file is dedicated to developers actively +> working on Ghostty.** If you're a user reporting an issue, you can +> ignore the rest of this document. + +## Including and Updating Translations + +See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details. + +## Checking for Memory Leaks + +While Zig does an amazing job of finding and preventing memory leaks, +Ghostty uses many third-party libraries that are written in C. Improper usage +of those libraries or bugs in those libraries can cause memory leaks that +Zig cannot detect by itself. + +### On Linux + +On Linux the recommended tool to check for memory leaks is Valgrind. The +recommended way to run Valgrind is via `zig build`: + +```sh +zig build run-valgrind +``` + +This builds a Ghostty executable with Valgrind support and runs Valgrind +with the proper flags to ensure we're suppressing known false positives. + +You can combine the same build args with `run-valgrind` that you can with +`run`, such as specifying additional configurations after a trailing `--`. + +## Input Stack Testing + +The input stack is the part of the codebase that starts with a +key event and ends with text encoding being sent to the pty (it +does not include _rendering_ the text, which is part of the +font or rendering stack). + +If you modify any part of the input stack, you must manually verify +all the following input cases work properly. We unfortunately do +not automate this in any way, but if we can do that one day that'd +save a LOT of grief and time. + +Note: this list may not be exhaustive, I'm still working on it. + +### Linux IME + +IME (Input Method Editors) are a common source of bugs in the input stack, +especially on Linux since there are multiple different IME systems +interacting with different windowing systems and application frameworks +all written by different organizations. + +The following matrix should be tested to ensure that all IME input works +properly: + +1. Wayland, X11 +2. ibus, fcitx, none +3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex +4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors) + +> [!NOTE] +> +> This is a **work in progress**. I'm still working on this list and it +> is not complete. As I find more test cases, I will add them here. + +#### Dead Key Input + +Set your keyboard layout to "Spanish" (or another layout that uses dead keys). + +1. Launch Ghostty +2. Press `'` +3. Press `a` +4. Verify that `á` is displayed + +Note that the dead key may or may not show a preedit state visually. +For ibus and fcitx it does but for the "none" case it does not. Importantly, +the text should be correct when it is sent to the pty. + +We should also test canceling dead key input: + +1. Launch Ghostty +2. Press `'` +3. Press escape +4. Press `a` +5. Verify that `a` is displayed (no diacritic) + +#### CJK Input + +Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The +exact layout doesn't matter. + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Enter` +5. Verify that `こん` is displayed in the terminal. + +We should also test switching input methods while preedit is active, which +should commit the text: + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Ctrl+Shift` to switch to another layout (any) +5. Verify that `こん` is displayed in the terminal as committed text. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` can be any of the VMs defined in the + `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed + with `common` or `create`. +3. The VM will build and then launch. Depending on the speed of your system, this + can take a while, but eventually you should get a new VM window. +4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending + on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be + writable by the VM user, so be careful! + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a VM. + +### Custom VMs + +To easily create a custom VM without modifying the Ghostty source, create a new +directory, then create a file called `flake.nix` with the following text in the +new directory. + +``` +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + ghostty.url = "github:ghostty-org/ghostty"; + }; + outputs = { + nixpkgs, + ghostty, + ... + }: { + nixosConfigurations.custom-vm = ghostty.create-gnome-vm { + nixpkgs = nixpkgs; + system = "x86_64-linux"; + overlay = ghostty.overlays.releasefast; + # module = ./configuration.nix # also works + module = {pkgs, ...}: { + environment.systemPackages = [ + pkgs.btop + ]; + }; + }; + }; +} +``` + +The custom VM can then be run with a command like this: + +``` +nix run .#nixosConfigurations.custom-vm.config.system.build.vm +``` + +A file named `ghostty.qcow2` will be created that is used to persist any changes +made in the VM. To "reset" the VM to default delete the file and it will be +recreated the next time you run the VM. + +### Contributing new VM definitions + +#### VM Acceptance Criteria + +We welcome the contribution of new VM definitions, as long as they meet the following criteria: + +1. They should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH daemons, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. + +## Nix VM Integration Tests + +Several Nix VM tests are provided by the project for testing Ghostty in a "live" +environment rather than just unit tests. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#check...driver`. `` should be + `x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux + VM, not a macOS one). `` should be one of the tests defined in + `nix/tests.nix`. The test will build and then launch. Depending on the speed + of your system, this can take a while. Eventually though the test should + complete. Hopefully successfully, but if not error messages should be printed + out that can be used to diagnose the issue. +3. To run _all_ of the tests, run `nix flake check`. + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a test. + +### Interactively Running Test VMs + +To run a test interactively, run `nix run +.#check...driverInteractive`. This will load a Python console +that can be used to manage the test VMs. In this console run `start_all()` to +start the VM(s). The VMs should boot up and a window should appear showing the +VM's console. + +For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos) + +### SSH Access to Test VMs + +Some test VMs are configured to allow outside SSH access for debugging. To +access the VM, use a command like the following: + +``` +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 ghostty@192.168.122.1 +``` + +The SSH options are important because the SSH host keys will be regenerated +every time the test is started. Without them, your personal SSH known hosts file +will become difficult to manage. The port that is needed to access the VM may +change depending on the test. + +None of the users in the VM have passwords so do not expose these VMs to the Internet. From c77bbe6d7ec5736b4defb183c9daab18cd5f400e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:02:54 -0500 Subject: [PATCH 013/605] nix vms: make base vm more suitable for tests --- nix/vm/common.nix | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 63b7570b8..b2fec28e8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -4,9 +4,6 @@ documentation.nixos.enable = false; - networking.hostName = "ghostty"; - networking.domain = "mitchellh.com"; - virtualisation.vmVariant = { virtualisation.memorySize = 2048; }; @@ -28,11 +25,11 @@ users.groups.ghostty = {}; users.users.ghostty = { + isNormalUser = true; description = "Ghostty"; group = "ghostty"; extraGroups = ["wheel"]; - isNormalUser = true; - initialPassword = "ghostty"; + hashedPassword = ""; }; environment.systemPackages = [ From debec946daf90174801d9cedbb52c084efb095b5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:04:38 -0500 Subject: [PATCH 014/605] nix vm tests: refactor to make gnome vm node builder reusable --- nix/tests.nix | 94 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/nix/tests.nix b/nix/tests.nix index 51fafad3e..33902d4d0 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -37,6 +37,65 @@ return "${pink_value}" in text ''; + mkNodeGnome = { + config, + pkgs, + settings, + sshPort ? null, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation = { + forwardPorts = pkgs.lib.optionals (sshPort != null) [ + { + from = "host"; + host.port = sshPort; + guest.port = 22; + } + ]; + + vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + }; + + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + + security.pam.services.sshd.allowNullPassword = true; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + mkTestGnome = { name, settings, @@ -59,38 +118,11 @@ config, pkgs, ... - }: { - imports = [ - ./vm/wayland-gnome.nix - settings - ]; - - virtualisation.vmVariant = { - virtualisation.host.pkgs = pkgs; + }: + mkNodeGnome { + inherit config pkgs settings; + sshPort = 2222; }; - - users.groups.ghostty = { - gid = 1000; - }; - - users.users.ghostty = { - uid = 1000; - }; - - home-manager = { - users = { - ghostty = { - home = { - username = config.users.users.ghostty.name; - homeDirectory = config.users.users.ghostty.home; - stateVersion = nixos-version; - }; - }; - }; - }; - - system.stateVersion = nixos-version; - }; }; testScript = testScript; From f26a6b949c58f0f7e3587a8f17997e868719abd9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:05:18 -0500 Subject: [PATCH 015/605] nix vm tests: sync ghostty user with other tests --- nix/tests.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 33902d4d0..1ef420cf3 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -136,6 +136,8 @@ in { users.users.ghostty = { isNormalUser = true; group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; packages = [ pkgs.ghostty ]; From 516c416fa4d9c31a82dd1a149f91524f64f2392c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:55:19 -0500 Subject: [PATCH 016/605] nix vm tests: fix ssh command --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34e6b273b..6d8976b21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -395,8 +395,8 @@ Some test VMs are configured to allow outside SSH access for debugging. To access the VM, use a command like the following: ``` -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 root@192.168.122.1 -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 ghostty@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1 ``` The SSH options are important because the SSH host keys will be regenerated From 8386159764fb398c8a60aa71367edb11b62db5c8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:55:54 -0500 Subject: [PATCH 017/605] nix vm tests: add test for ssh-terminfo shell integration feature --- nix/tests.nix | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 1ef420cf3..a9970e80c 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -89,6 +89,13 @@ homeDirectory = config.users.users.ghostty.home; stateVersion = nixos-version; }; + programs.ssh = { + enable = true; + extraOptionOverrides = { + StrictHostKeyChecking = "accept-new"; + UserKnownHostsFile = "/dev/null"; + }; + }; }; }; }; @@ -198,4 +205,79 @@ in { machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") ''; }; + + ssh-integration-test = pkgs.testers.runNixOSTest { + name = "ssh-integration-test"; + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + nodes = { + server = {...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = []; + }; + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + security.pam.services.sshd.allowNullPassword = true; + }; + client = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = let + in '' + shell-integration-features = ssh-terminfo + ''; + }; + }; + }; + sshPort = 2222; + }; + }; + testScript = {nodes, ...}: let + user = nodes.client.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + with subtest("Start server and wait for ssh to be ready."): + server.start() + server.wait_for_open_port(22) + + with subtest("Start client and wait for ghostty window."): + client.start() + client.wait_for_x() + client.wait_for_file("${bus_path}") + client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + client.succeed("${su "${ghostty} +new-window"}") + client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.") + client.sleep(2) + client.send_chars("ssh ghostty@server\n") + server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) + ''; + }; } From 51bda77e3a8f584862299e21dbfc5ab338ea4236 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 30 Nov 2025 10:10:50 -0500 Subject: [PATCH 018/605] macos: teach agents about `zig build run` --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a3e752816..dc2b47a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,4 +30,5 @@ A file for [guiding coding agents](https://agents.md/). - Do not use `xcodebuild` - Use `zig build` to build the macOS app and any shared Zig code +- Use `zig build run` to build and run the macOS app - Run Xcode tests using `zig build test` From a5218198823f7e6994cf3b2a47d2919d3f394691 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 3 Dec 2025 22:04:52 +0100 Subject: [PATCH 019/605] Address changes fixes logic bug when adding cache --- .../vendor/autoload/ghostty-integration.nu | 76 ++++++++----------- src/termio/shell_integration.zig | 13 ++-- 2 files changed, 38 insertions(+), 51 deletions(-) diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu index 1a1b377de..77f7fb64d 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu @@ -14,22 +14,21 @@ def set_ssh_env []: nothing -> record> def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { mut ssh_opts = $ssh_opts let ssh_cfg = ^ssh -G ...($ssh_args) - | lines - | split column -n 2 " " key value - | where key == "user" or key == "hostname" - | transpose -r + | lines + | parse "{key} {value}" + | where key in ["user", "hostname"] + | select key value + | transpose -rd | into record - let ssh_user = $ssh_cfg.user - let ssh_hostname = $ssh_cfg.hostname - let ssh_id = $"($ssh_user)@($ssh_hostname)" - let ghostty_bin = $env.GHOSTTY_BIN_DIR + "/ghostty" - let check_cache_cmd = ["+ssh-cache", $"--host=($ssh_id)"] + | default { user: $env.USER, hostname: "localhost" } + + let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" + let ghostty_bin = $env.GHOSTTY_BIN_DIR | path join "ghostty" let is_cached = ( - ^$ghostty_bin ...$check_cache_cmd + ^$ghostty_bin ...(["+ssh-cache", $"--host=($ssh_id)"]) | complete - | get exit_code - | $in == 0 + | $in.exit_code == 0 ) if not $is_cached { @@ -39,44 +38,36 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts_copy} } - print $"Setting up xterm-ghostty terminfo on ($ssh_hostname)..." + print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." - let ctrl_dir = try { - mktemp -td $"ghostty-ssh-($ssh_user).XXXXXX" - } catch { - $"/tmp/ghostty-ssh-($ssh_user).($nu.pid)" - } - - let ctrl_path = $"($ctrl_dir)/socket" + let ctrl_path = ( + try { + mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" + } catch { + $"/tmp/ghostty-ssh-($ssh_cfg.user).($nu.pid)" + } | path join "socket" + ) let master_parts = $ssh_opts ++ ["-o", "ControlMaster=yes", "-o", $"ControlPath=($ctrl_path)", "-o", "ControlPersist=60s"] ++ $ssh_args - let infocmp_cmd = $master_parts ++ ["infocmp", "xterm-ghostty"] - let terminfo_present = ( - ^ssh ...$infocmp_cmd + ^ssh ...($master_parts ++ ["infocmp", "xterm-ghostty"]) | complete - | get exit_code - | $in == 0 + | $in.exit_code == 0 ) if (not $terminfo_present) { - let install_terminfo_cmd = $master_parts ++ ["mkdir", "-p", "~/.terminfo", "&&", "tic", "-x", "-"] - - ($terminfo_data | ^ssh ...$install_terminfo_cmd) | complete | get exit_code | if $in != 0 { + ( + $terminfo_data + | ^ssh ...($master_parts ++ ["mkdir", "-p", "~/.terminfo", "&&", "tic", "-x", "-"]) + ) + | complete + | if $in.exit_code != 0 { print "Warning: Failed to install terminfo." return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts} - } - let state_dir = try { $env.XDG_STATE_HOME } catch { $env.HOME | path join ".local/state" } - let ghostty_state_dir = $state_dir | path join "ghostty" - - let cache_add_cmd = ["+ssh-cache", $"--add=($ssh_id)"] - - # Bug?: If I dont add TMPDIR, it complains about renameacrossmountpoints - with-env { TMPDIR: $ghostty_state_dir } { - ^$ghostty_bin ...$cache_add_cmd o+e>| ignore } } + ^$ghostty_bin ...(["+ssh-cache", $"--add=($ssh_id)"]) o+e>| ignore $ssh_opts ++= ["-o", $"ControlPath=($ctrl_path)"] } @@ -99,16 +90,15 @@ export def --wrapped ssh [...ssh_args: string] { } let ssh_parts = $session.ssh_opts ++ $ssh_args - with-env { TERM: $session.ssh_term } { + with-env {TERM: $session.ssh_term} { ^ssh ...$ssh_parts } } # Removes Ghostty's data directory from XDG_DATA_DIRS -let ghostty_data_dir = $env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR -$env.XDG_DATA_DIRS = $env.XDG_DATA_DIRS +$env.XDG_DATA_DIRS = ( + $env.XDG_DATA_DIRS | split row ':' - | where ( - | $it !~ $ghostty_data_dir - ) + | where {|path| $path != $env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR } | str join ':' +) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index afe1f547f..570ec85cc 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -78,14 +78,6 @@ pub fn setup( } fn setupShell(alloc_arena: Allocator, resource_dir: []const u8, command: config.Command, env: *EnvMap, exe: []const u8, features: config.ShellIntegrationFeatures) !?ShellIntegration { - if (std.mem.eql(u8, "nu", exe)) { - try setupNu(alloc_arena, resource_dir, env, features); - return null; - // // return .{ - // // .shell = .nu, - // // .command = try command.clone(alloc_arena), - // // }; - } if (std.mem.eql(u8, "bash", exe)) { // Apple distributes their own patched version of Bash 3.2 // on macOS that disables the ENV-based POSIX startup path. @@ -132,6 +124,11 @@ fn setupShell(alloc_arena: Allocator, resource_dir: []const u8, command: config. }; } + if (std.mem.eql(u8, "nu", exe)) { + try setupNu(alloc_arena, resource_dir, env, features); + return null; + } + if (std.mem.eql(u8, "zsh", exe)) { try setupZsh(resource_dir, env); return .{ From c0ce4ef44f9de914de31d94d4ca13b69517e1b03 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 3 Dec 2025 22:31:25 +0100 Subject: [PATCH 020/605] Retain original fmt --- src/termio/shell_integration.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index b93f2ea31..fcbdaef6a 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -77,7 +77,14 @@ pub fn setup( return result; } -fn setupShell(alloc_arena: Allocator, resource_dir: []const u8, command: config.Command, env: *EnvMap, exe: []const u8, features: config.ShellIntegrationFeatures) !?ShellIntegration { +fn setupShell( + alloc_arena: Allocator, + resource_dir: []const u8, + command: config.Command, + env: *EnvMap, + exe: []const u8, + features: config.ShellIntegrationFeatures, +) !?ShellIntegration { if (std.mem.eql(u8, "bash", exe)) { // Apple distributes their own patched version of Bash 3.2 // on macOS that disables the ENV-based POSIX startup path. From d69e16c168d2270081d36b7c9def1a6c08538f8a Mon Sep 17 00:00:00 2001 From: David Matos Date: Thu, 4 Dec 2025 13:08:04 +0100 Subject: [PATCH 021/605] Use external cmd Remove redundant `into record` --- .../nushell/vendor/autoload/ghostty-integration.nu | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu index 77f7fb64d..f8e2c3e16 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu @@ -19,7 +19,6 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { | where key in ["user", "hostname"] | select key value | transpose -rd - | into record | default { user: $env.USER, hostname: "localhost" } let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" @@ -33,7 +32,7 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { if not $is_cached { let ssh_opts_copy = $ssh_opts - let terminfo_data = try {infocmp -0 -x xterm-ghostty} catch { + let terminfo_data = try {^infocmp -0 -x xterm-ghostty} catch { print "Warning: Could not generate terminfo data." return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts_copy} } From 07f4ef8e4778fcc4ac962c5b3058ed070fd59bfc Mon Sep 17 00:00:00 2001 From: David Matos Date: Sat, 6 Dec 2025 00:48:14 +0100 Subject: [PATCH 022/605] Alternative approach by unconditionally setting xdgDataDirs and checking features with nu --- src/shell-integration/README.md | 26 ++++++----- ...egration.nu => ghostty-ssh-integration.nu} | 43 +++++++++++-------- .../vendor/autoload/bootstrap-integration.nu | 29 +++++++++++++ .../vendor/autoload/source-integration.nu | 11 +++++ src/termio/shell_integration.zig | 19 ++------ 5 files changed, 79 insertions(+), 49 deletions(-) rename src/shell-integration/nushell/{vendor/autoload/ghostty-integration.nu => ghostty-ssh-integration.nu} (73%) create mode 100644 src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu create mode 100644 src/shell-integration/nushell/vendor/autoload/source-integration.nu diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 50c01344b..bd1702dde 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -76,6 +76,18 @@ allowing us to automatically integrate with the shell. For details on the Fish startup process, see the [Fish documentation](https://fishshell.com/docs/current/language.html). +### Nushell + +For `nushell` Ghostty prepends to the `XDG_DATA_DIRS` directory. Nushell automatically +loads configuration files in `/nushell/vendor/autoload/*.nu` on startup. These +directories are represented in `Nu` by `$nu.vendor-autoload-dirs`. For more details see + +[Nushell documentation](https://www.nushell.sh/book/configuration.html#configuration-overview) + +> [!NOTE] +> +> Ghostty implements concretely the `ssh-*` features. The rest of the features are supported mostly out of the box by Nushell. + ### Zsh For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration @@ -90,17 +102,3 @@ fi ``` Shell integration requires Zsh 5.1+. - -### Nushell - -For `nushell` Ghostty prepends to the `XDG_DATA_DIRS` directory. Nushell automatically -loads configuration files in `/nushell/vendor/autoload/*.nu` on startup. These -directories are represented in `Nu` by `$nu.vendor-autoload-dirs`. For more details see - -[Nushell documentation](https://www.nushell.sh/book/configuration.html#configuration-overview) - -> [!NOTE] -> -> Ghostty only prepends to `XDG_DATA_DIRS` in the case where the `ssh-*` features are enabled. -> Nushell supports most features out of the box, so other shell integration features are not -> necessary. diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu b/src/shell-integration/nushell/ghostty-ssh-integration.nu similarity index 73% rename from src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu rename to src/shell-integration/nushell/ghostty-ssh-integration.nu index f8e2c3e16..495b96c78 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty-integration.nu +++ b/src/shell-integration/nushell/ghostty-ssh-integration.nu @@ -3,7 +3,10 @@ # and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION # check your sshd_config on remote host to see if these variables are accepted def set_ssh_env []: nothing -> record> { - return {ssh_term: "xterm-256color", ssh_opts: ["-o", "SetEnv COLORTERM=truecolor", "-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"]} + return { + ssh_term: "xterm-256color", + ssh_opts: ["-o", "SetEnv COLORTERM=truecolor", "-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + } } # Enables automatic terminfo installation on remote hosts. @@ -11,7 +14,10 @@ def set_ssh_env []: nothing -> record> # connecting to hosts that lack it. # Requires infocmp to be available locally and tic to be available on remote hosts. # Caches installations to avoid repeat installations. -def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { +def set_ssh_terminfo [ + ssh_opts: list, + ssh_args: list +]: [nothing -> record>] { mut ssh_opts = $ssh_opts let ssh_cfg = ^ssh -G ...($ssh_args) | lines @@ -47,25 +53,24 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { } | path join "socket" ) - let master_parts = $ssh_opts ++ ["-o", "ControlMaster=yes", "-o", $"ControlPath=($ctrl_path)", "-o", "ControlPersist=60s"] ++ $ssh_args + let master_parts = $ssh_opts ++ ["-o" "ControlMaster=yes" "-o" $"ControlPath=($ctrl_path)" "-o" "ControlPersist=60s"] ++ $ssh_args - let terminfo_present = ( - ^ssh ...($master_parts ++ ["infocmp", "xterm-ghostty"]) - | complete - | $in.exit_code == 0 + ($terminfo_data) | ^ssh ...( + $master_parts ++ + [ + ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1' + ] ) - - if (not $terminfo_present) { - ( - $terminfo_data - | ^ssh ...($master_parts ++ ["mkdir", "-p", "~/.terminfo", "&&", "tic", "-x", "-"]) - ) - | complete - | if $in.exit_code != 0 { - print "Warning: Failed to install terminfo." - return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts} - } + | complete + | if $in.exit_code != 0 { + print "Warning: Failed to install terminfo." + return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts} } + ^$ghostty_bin ...(["+ssh-cache", $"--add=($ssh_id)"]) o+e>| ignore $ssh_opts ++= ["-o", $"ControlPath=($ctrl_path)"] } @@ -74,7 +79,7 @@ def set_ssh_terminfo [ssh_opts: list, ssh_args: list] { } # SSH Integration -export def --wrapped ssh [...ssh_args: string] { +export def --wrapped ssh [...ssh_args: string]: any -> any { if ($ssh_args | is-empty) { return (^ssh) } diff --git a/src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu b/src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu new file mode 100644 index 000000000..317ba62d3 --- /dev/null +++ b/src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu @@ -0,0 +1,29 @@ +let enable_integration = $env.GHOSTTY_SHELL_FEATURES | split row ',' + | where ($it in ["ssh-env" "ssh-terminfo"]) + | is-not-empty + +let ghostty_ssh_file = $env.GHOSTTY_RESOURCES_DIR + | path join "shell-integration" "nushell" "ghostty-ssh-integration.nu" + +let ssh_integration_file = $nu.data-dir | path join "ghostty-ssh-integration.nu" +let ssh_file_exists = $ssh_integration_file | path exists + +# TOD0: In case of an update to the `ghostty-ssh-integration.nu` file +# the file wont be updated here, so we need to support +# saving the new file once there is an update + +match [$enable_integration $ssh_file_exists] { + [true false] => { + # $nu.data-dir is not created by default + # https://www.nushell.sh/book/configuration.html#startup-variables + $nu.data-dir | path exists | if (not $in) { mkdir $nu.data-dir } + open $ghostty_ssh_file | save $ssh_integration_file + } + [false true] => { + # We need to check if the user disabled `ssh-integration` and thus + # the integration file needs to be removed so it doesnt get sourced by + # the `source-integration.nu` file + rm $ssh_integration_file + } + _ => { } +} diff --git a/src/shell-integration/nushell/vendor/autoload/source-integration.nu b/src/shell-integration/nushell/vendor/autoload/source-integration.nu new file mode 100644 index 000000000..1c21833a4 --- /dev/null +++ b/src/shell-integration/nushell/vendor/autoload/source-integration.nu @@ -0,0 +1,11 @@ +# Sourcing the `ghostty-integration.nu` cant be on the +# `bootstrap-integration.nu` file because it tries to resolve the `sourced` +# file at parsing time, which would make it source nothing. + +# But here we rely on the fact that `boostrap-integration.nu` gets parsed +# and executed first, and then we can count on `ssh_integration_file` being available + +#https://www.nushell.sh/book/thinking_in_nu.html#example-dynamically-generating-source + +const ssh_integration_file = $nu.data-dir | path join "ghostty-ssh-integration.nu" +source (if ($ssh_integration_file | path exists) { $ssh_integration_file } else { null }) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index fcbdaef6a..8a03945c9 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -68,7 +68,6 @@ pub fn setup( command, env, exe, - features, ); // Setup our feature env vars @@ -83,7 +82,6 @@ fn setupShell( command: config.Command, env: *EnvMap, exe: []const u8, - features: config.ShellIntegrationFeatures, ) !?ShellIntegration { if (std.mem.eql(u8, "bash", exe)) { // Apple distributes their own patched version of Bash 3.2 @@ -132,7 +130,9 @@ fn setupShell( } if (std.mem.eql(u8, "nu", exe)) { - try setupNu(alloc_arena, resource_dir, env, features); + // Sets up XDG_DATA_DIRS so that it can be picked automatically by + // nushell on startup. + try setupXdgDataDirs(alloc_arena, resource_dir, env); return null; } @@ -659,19 +659,6 @@ test "xdg: existing XDG_DATA_DIRS" { try testing.expectEqualStrings("./shell-integration:/opt/share", env.get("XDG_DATA_DIRS").?); } -/// Setup the nushell shell integration. This works by setting -/// XDG_DATA_DIRS so that it can be picked automatically by -/// nushell on startup. -/// Only implements `ssh-*` shell features. Rest are not supported. -fn setupNu(alloc_arena: Allocator, resource_dir: []const u8, env: *EnvMap, features: config.ShellIntegrationFeatures) !void { - // This makes sure that `Nu` loads our integration file - // and wraps the `ssh` function only if the `ssh` features - // are enabled. - // Otherwise, it does not do anything. - if (features.@"ssh-env" or features.@"ssh-terminfo") { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - } -} /// Setup the zsh automatic shell integration. This works by setting /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config. From 0c9082eb7235fc46e8851a412568bbf2f61ef3fa Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:24:47 +0100 Subject: [PATCH 023/605] macOS: fix theme reloading ### Background After #9344, the Ghostty theme won't change after switching systems', and reverting #9344 will bring back the issue it fixed. The reason these two issues are related is because the scheme change is based on changes of `effectiveAppearance`, which is also affected by setting the window's `appearance` or changing `NSAppearance.currentDrawing()`. ### Changes Instead of observing `effectiveAppearance`, we now explicitly update the color scheme of surfaces, so that we can control when it happens to avoid callback loops and redundant updates. ### Regression Tests - [x] #8282 - [x] Reloading with `window-theme = light` should update Ghostty with the default dark theme with a dark window theme (break before [#83104ff](https://github.com/ghostty-org/ghostty/commit/83104ff27a42fbcd5a7dec7677d9ed4f9b9c59c8)) - [x] `window-theme = light \n macos-titlebar-style = native` should update Ghostty with the default dark theme with a light window theme - [x] Reloading from the default config to `theme=light:3024 Day,dark:3024 Night \n window-theme = light`, should update Ghostty with the theme `3024 Day` with a light window theme (break on [#d39cc6d](https://github.com/ghostty-org/ghostty/commit/d39cc6d478edd6e1f412fa680e5166ea4f24c898)) - [x] Using `theme=light:3024 Day,dark:3024 Night`; Switching the system's appearance should change Ghostty's appearance (break on [#d39cc6d](https://github.com/ghostty-org/ghostty/commit/d39cc6d478edd6e1f412fa680e5166ea4f24c898)) - [x] Reloading from `theme=light:3024 Day,dark:3024 Night` with a light window theme to the default config, should update Ghostty with the default dark theme with a dark window theme - [x] Reloading from the default config to `theme=light:3024 Day,dark:3024 Night \n window-theme=dark`, should update Ghostty with the theme `3024 Night` with a dark window theme - [x] Reloading from `theme=light:3024 Day,dark:3024 Night \n window-theme=dark` to `theme=light:3024 Day,dark:3024 Night` with light system appearance, should update Ghostty from dark to light - [x] Reload with quick terminal open # Conflicts: # macos/Sources/Features/Terminal/BaseTerminalController.swift --- .../QuickTerminalController.swift | 1 + .../Terminal/BaseTerminalController.swift | 34 +++++++++++++++++++ .../Terminal/TerminalController.swift | 12 ++----- .../Window Styles/TerminalWindow.swift | 5 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 20 ----------- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4c2052f23..201289736 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -566,6 +566,7 @@ class QuickTerminalController: BaseTerminalController { private func syncAppearance() { guard let window else { return } + defer { updateColorSchemeForSurfaceTree() } // Change the collection behavior of the window depending on the configuration. window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9104e61ff..1c8e258f7 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -72,6 +72,9 @@ class BaseTerminalController: NSWindowController, /// The previous frame information from the window private var savedFrame: SavedFrame? = nil + /// Cache previously applied appearance to avoid unnecessary updates + private var appliedColorScheme: ghostty_color_scheme_e? + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig @@ -1163,4 +1166,35 @@ extension BaseTerminalController: NSMenuItemValidation { return true } } + + // MARK: - Surface Color Scheme + + /// Update the surface tree's color scheme only when it actually changes. + /// + /// Calling ``ghostty_surface_set_color_scheme`` triggers + /// ``syncAppearance(_:)`` via notification, + /// so we avoid redundant calls. + func updateColorSchemeForSurfaceTree() { + /// Derive the target scheme from `window-theme` or system appearance. + /// We set the scheme on surfaces so they pick the correct theme + /// and let ``syncAppearance(_:)`` update the window accordingly. + /// + /// Using App's effectiveAppearance here to prevent incorrect updates. + let themeAppearance = NSApplication.shared.effectiveAppearance + let scheme: ghostty_color_scheme_e + if themeAppearance.isDark { + scheme = GHOSTTY_COLOR_SCHEME_DARK + } else { + scheme = GHOSTTY_COLOR_SCHEME_LIGHT + } + guard scheme != appliedColorScheme else { + return + } + for surfaceView in surfaceTree { + if let surface = surfaceView.surface { + ghostty_surface_set_color_scheme(surface, scheme) + } + } + appliedColorScheme = scheme + } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 93a05b6b9..5cc2c67f1 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -425,15 +425,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } - - // 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(surfaceView) else { return } - - // We can't use surfaceView.derivedConfig because it may not be updated - // yet since it also responds to notifications. - syncAppearance(.init(config)) + /// Surface-level config will be updated in + /// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then + /// ``TerminalController/focusedSurfaceDidChange(to:)`` } /// Update the accessory view of each tab according to the keyboard diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a829ec519..2208d99cf 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -419,6 +419,7 @@ class TerminalWindow: NSWindow { // 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 } + defer { updateColorSchemeForSurfaceTree() } // Basic properties appearance = surfaceConfig.windowAppearance @@ -481,6 +482,10 @@ class TerminalWindow: NSWindow { return derivedConfig.backgroundColor.withAlphaComponent(alpha) } + func updateColorSchemeForSurfaceTree() { + terminalController?.updateColorSchemeForSurfaceTree() + } + private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x, let y else { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 03ef293af..e86df4454 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -369,26 +369,6 @@ extension Ghostty { // Setup our tracking area so we get mouse moved events updateTrackingAreas() - // Observe our appearance so we can report the correct value to libghostty. - // This is the best way I know of to get appearance change notifications. - self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in - guard let appearance = change.newValue else { return } - guard let surface = view.surface else { return } - let scheme: ghostty_color_scheme_e - switch (appearance.name) { - case .aqua, .vibrantLight: - scheme = GHOSTTY_COLOR_SCHEME_LIGHT - - case .darkAqua, .vibrantDark: - scheme = GHOSTTY_COLOR_SCHEME_DARK - - default: - return - } - - ghostty_surface_set_color_scheme(surface, scheme) - } - // The UTTypes that can be dragged onto this view. registerForDraggedTypes(Array(Self.dropTypes)) } From af3a11b54673a0bb8f3c1e6f2d076f69810cbf4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:09:52 -0800 Subject: [PATCH 024/605] terminal/tmux: output has format/comptimeFormat --- src/terminal/tmux/output.zig | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index dcfa89ac3..cff1a982d 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -36,6 +36,36 @@ pub fn parseFormatStruct( return result; } +pub fn comptimeFormat( + comptime vars: []const Variable, + comptime delimiter: u8, +) []const u8 { + comptime { + @setEvalBranchQuota(50000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + try format(&counter.writer, vars, delimiter); + + var buf: [counter.count]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + const final = buf; + return final[0..writer.end]; + } +} + +/// Format a set of variables into the proper format string for tmux +/// that we can handle with `parseFormatStruct`. +pub fn format( + writer: *std.Io.Writer, + vars: []const Variable, + delimiter: u8, +) std.Io.Writer.Error!void { + for (vars, 0..) |variable, i| { + if (i != 0) try writer.writeByte(delimiter); + try writer.print("#{{{t}}}", .{variable}); + } +} + /// Returns a struct type that contains fields for each of the given /// format variables. This can be used with `parseFormatStruct` to /// parse an output string into a format struct. @@ -203,3 +233,41 @@ test "parseFormatStruct with empty layout field" { try testing.expectEqual(1, result.session_id); try testing.expectEqualStrings("", result.window_layout); } + +fn testFormat( + comptime vars: []const Variable, + comptime delimiter: u8, + comptime expected: []const u8, +) !void { + const comptime_result = comptime comptimeFormat(vars, delimiter); + try testing.expectEqualStrings(expected, comptime_result); + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + try testing.expectEqualStrings(expected, buf[0..writer.end]); +} + +test "format single variable" { + try testFormat(&.{.session_id}, ' ', "#{session_id}"); +} + +test "format multiple variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height}"); +} + +test "format with comma delimiter" { + try testFormat(&.{ .window_id, .window_layout }, ',', "#{window_id},#{window_layout}"); +} + +test "format with tab delimiter" { + try testFormat(&.{ .window_width, .window_height }, '\t', "#{window_width}\t#{window_height}"); +} + +test "format empty variables" { + try testFormat(&.{}, ' ', ""); +} + +test "format all variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height, .window_layout }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}"); +} From 0d75a787471a2b1a26dc31d05c5f607d7cab1543 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:01:04 -0800 Subject: [PATCH 025/605] terminal/tmux: start viewer state machine --- src/terminal/tmux.zig | 1 + src/terminal/tmux/viewer.zig | 235 ++++++++++++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/terminal/tmux/viewer.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 82ef5036b..c7cda1442 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -6,6 +6,7 @@ pub const output = @import("tmux/output.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; pub const Layout = layout.Layout; +pub const Viewer = @import("tmux/viewer.zig").Viewer; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig new file mode 100644 index 000000000..7a84f9243 --- /dev/null +++ b/src/terminal/tmux/viewer.zig @@ -0,0 +1,235 @@ +const std = @import("std"); +const testing = std.testing; +const assert = @import("../../quirks.zig").inlineAssert; +const control = @import("control.zig"); +const output = @import("output.zig"); + +const log = std.log.scoped(.terminal_tmux_viewer); + +// NOTE: There is some fragility here that can possibly break if tmux +// changes their implementation. In particular, the order of notifications +// and assurances about what is sent when are based on reading the tmux +// source code as of Dec, 2025. These aren't documented as fixed. +// +// I've tried not to depend on anything that seems like it'd change +// in the future. For example, it seems reasonable that command output +// always comes before session attachment. But, I am noting this here +// in case something breaks in the future we can consider it. We should +// be able to easily unit test all variations seen in the real world. + +/// A viewer is a tmux control mode client that attempts to create +/// a remote view of a tmux session, including providing the ability to send +/// new input to the session. +/// +/// This is the primary use case for tmux control mode, but technically +/// tmux control mode clients can do anything a normal tmux client can do, +/// so the `control.zig` and other files in this folder are more general +/// purpose. +/// +/// This struct helps move through a state machine of connecting to a tmux +/// session, negotiating capabilities, listing window state, etc. +pub const Viewer = struct { + state: State = .startup_block, + + /// The current session ID we're attached to. The default value + /// is meaningless, because this has to be sent down during + /// the startup process. + session_id: usize = 0, + + pub const Action = union(enum) { + /// Tmux has closed the control mode connection, we should end + /// our viewer session in some way. + exit, + + /// Send a command to tmux, e.g. `list-windows`. The caller + /// should not worry about parsing this or reading what command + /// it is; just send it to tmux as-is. This will include the + /// trailing newline so you can send it directly. + command: []const u8, + }; + + /// Initial state + pub const init: Viewer = .{}; + + /// Send in the next tmux notification we got from the control mode + /// protocol. The return value is any action that needs to be taken + /// in reaction to this notification (could be none). + pub fn next(self: *Viewer, n: control.Notification) ?Action { + return switch (self.state) { + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct null; + }, + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => self.nextCommand(n), + }; + } + + fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_block); + + switch (n) { + // This is only sent by the DCS parser when we first get + // DCS 1000p, it should never reach us here. + .enter => unreachable, + + // I don't think this is technically possible (reading the + // tmux source code), but if we see an exit we can semantically + // handle this without issue. + .exit => { + self.state = .defunct; + return .exit; + }, + + // Any begin and end (even error) is fine! Now we wait for + // session-changed to get the initial session ID. session-changed + // is guaranteed to come after the initial command output + // since if the initial command is `attach` tmux will run that, + // queue the notification, then do notificatins. + .block_end, .block_err => { + self.state = .startup_session; + return null; + }, + + // I don't like catch-all else branches but startup is such + // a special case of looking for very specific things that + // are unlikely to expand. + else => return null, + } + } + + fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_session); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .session_changed => |info| { + self.session_id = info.id; + self.state = .list_windows; + return .{ .command = std.fmt.comptimePrint( + "list-windows -F '{s}'", + .{comptime Format.list_windows.comptimeFormat()}, + ) }; + }, + + else => return null, + } + } + + fn nextCommand(self: *Viewer, n: control.Notification) ?Action { + assert(self.state != .startup_block); + assert(self.state != .startup_session); + assert(self.state != .defunct); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .block_end, + .block_err, + => |content| switch (self.state) { + .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { + // TODO: parse the content + _ = content; + return null; + }, + }, + + // TODO: Use exhaustive matching here, determine if we need + // to handle the other cases. + else => return null, + } + } +}; + +const State = enum { + /// We start in this state just after receiving the initial + /// DCS 1000p opening sequence. We wait for an initial + /// begin/end block that is guaranteed to be sent by tmux for + /// the initial control mode command. (See tmux server-client.c + /// where control mode starts). + startup_block, + + /// After receiving the initial block, we wait for a session-changed + /// notification to record the initial session ID. + startup_session, + + /// Tmux has closed the control mode connection + defunct, + + /// We're waiting on a list-windows response from tmux. + list_windows, +}; + +/// Format strings used for commands in our viewer. +const Format = struct { + /// The variables included in this format, in order. + vars: []const output.Variable, + + /// The delimiter to use between variables. This must be a character + /// guaranteed to not appear in any of the variable outputs. + delim: u8, + + const list_windows: Format = .{ + .delim = ' ', + .vars = &.{ + .session_id, + .window_id, + .window_width, + .window_height, + .window_layout, + }, + }; + + /// The format string, available at comptime. + pub fn comptimeFormat(comptime self: Format) []const u8 { + return output.comptimeFormat(self.vars, self.delim); + } + + /// The struct that can contain the parsed output. + pub fn Struct(comptime self: Format) type { + return output.FormatStruct(self.vars); + } +}; + +test "immediate exit" { + var viewer: Viewer = .init; + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} + +test "initial flow" { + var viewer: Viewer = .init; + + // First we receive the initial block end + try testing.expect(viewer.next(.{ .block_end = "" }) == null); + + // Then we receive session-changed with the initial session + { + const action = viewer.next(.{ .session_changed = .{ + .id = 42, + .name = "main", + } }).?; + try testing.expect(action == .command); + try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + try testing.expectEqual(42, viewer.session_id); + } + + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 6e125e100..e25d635c9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -368,7 +368,11 @@ pub const StreamHandler = struct { fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); switch (cmd.*) { - .tmux => |tmux| { + .tmux => |tmux| tmux: { + // If tmux control mode is disabled at the build level, + // then this whole block shouldn't be analyzed. + if (comptime !terminal.options.tmux_control_mode) break :tmux; + // TODO: process it log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); }, From 4c3ef8fa13d12d6b5bba8f9f3e78214187ca8e84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 15:21:26 -0800 Subject: [PATCH 026/605] terminal/tmux: viewer list windows state --- src/terminal/tmux/viewer.zig | 134 ++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 26 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7a84f9243..60666b2aa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -29,12 +30,17 @@ const log = std.log.scoped(.terminal_tmux_viewer); /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. pub const Viewer = struct { - state: State = .startup_block, + /// Allocator used for all internal state. + alloc: Allocator, - /// The current session ID we're attached to. The default value - /// is meaningless, because this has to be sent down during - /// the startup process. - session_id: usize = 0, + /// Current state of the state machine. + state: State, + + /// The current session ID we're attached to. + session_id: usize, + + /// The windows in the current session. + windows: std.ArrayList(Window), pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -48,8 +54,32 @@ pub const Viewer = struct { command: []const u8, }; - /// Initial state - pub const init: Viewer = .{}; + pub const Window = struct { + id: usize, + width: usize, + height: usize, + // TODO: more fields, obviously! + }; + + /// Initialize a new viewer. + /// + /// The given allocator is used for all internal state. You must + /// call deinit when you're done with the viewer to free it. + pub fn init(alloc: Allocator) Viewer { + return .{ + .alloc = alloc, + .state = .startup_block, + // The default value here is meaningless. We don't get started + // until we receive a session-changed notification which will + // set this to a real value. + .session_id = 0, + .windows = .empty, + }; + } + + pub fn deinit(self: *Viewer) void { + self.windows.deinit(self.alloc); + } /// Send in the next tmux notification we got from the control mode /// protocol. The return value is any action that needs to be taken @@ -80,10 +110,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -108,10 +135,7 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; @@ -134,19 +158,17 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), - .block_end, + inline .block_end, .block_err, - => |content| switch (self.state) { + => |content, tag| switch (self.state) { .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { - // TODO: parse the content - _ = content; - return null; + // Move to defunct on error blocks. + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch self.defunct(); }, }, @@ -155,6 +177,53 @@ pub const Viewer = struct { else => return null, } } + + fn receivedListWindows( + self: *Viewer, + content: []const u8, + ) !Action { + assert(self.state == .list_windows); + + // This stores our new window state from this list-windows output. + var windows: std.ArrayList(Window) = .empty; + errdefer windows.deinit(self.alloc); + + // Parse all our windows + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + const data = output.parseFormatStruct( + Format.list_windows.Struct(), + line, + Format.list_windows.delim, + ) catch |err| { + log.info("failed to parse list-windows line: {s}", .{line}); + return err; + }; + + try windows.append(self.alloc, .{ + .id = data.window_id, + .width = data.window_width, + .height = data.window_height, + }); + } + + // TODO: Diff our prior windows + + // Replace our window list + self.windows.deinit(self.alloc); + self.windows = windows; + + return .exit; + } + + fn defunct(self: *Viewer) Action { + self.state = .defunct; + // In the future we may want to deallocate a bunch of memory + // when we go defunct. + return .exit; + } }; const State = enum { @@ -208,13 +277,15 @@ const Format = struct { }; test "immediate exit" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); try testing.expectEqual(.exit, viewer.next(.exit).?); try testing.expect(viewer.next(.exit) == null); } test "initial flow" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); // First we receive the initial block end try testing.expect(viewer.next(.{ .block_end = "" }) == null); @@ -228,6 +299,17 @@ test "initial flow" { try testing.expect(action == .command); try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); try testing.expectEqual(42, viewer.session_id); + // log.warn("{s}", .{action.command}); + } + + // Simulate our list-windows command + { + const action = viewer.next(.{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + }).?; + _ = action; } try testing.expectEqual(.exit, viewer.next(.exit).?); From c1d686534efc3db38db6d6dd29be86939f652073 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 13:20:54 -0800 Subject: [PATCH 027/605] terminal/tmux: list windows --- src/terminal/tmux/viewer.zig | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 60666b2aa..7ee97fa8c 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -52,6 +52,13 @@ pub const Viewer = struct { /// it is; just send it to tmux as-is. This will include the /// trailing newline so you can send it directly. command: []const u8, + + /// Windows changed. This may add, remove or change windows. The + /// caller is responsible for diffing the new window list against + /// the prior one. Remember that for a given Viewer, window IDs + /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) + /// never re-uses window IDs within a server process lifetime. + windows: []const Window, }; pub const Window = struct { @@ -141,7 +148,7 @@ pub const Viewer = struct { self.session_id = info.id; self.state = .list_windows; return .{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'", + "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }; }, @@ -209,13 +216,11 @@ pub const Viewer = struct { }); } - // TODO: Diff our prior windows - // Replace our window list self.windows.deinit(self.alloc); self.windows = windows; - return .exit; + return .{ .windows = self.windows.items }; } fn defunct(self: *Viewer) Action { @@ -309,7 +314,8 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , }).?; - _ = action; + try testing.expect(action == .windows); + try testing.expectEqual(1, action.windows.len); } try testing.expectEqual(.exit, viewer.next(.exit).?); From 3cbc232e31fd59f63a1eaea9df068c4d8df0153a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 07:15:53 -0800 Subject: [PATCH 028/605] terminal/tmux: return allocated list of actions --- src/terminal/tmux/viewer.zig | 197 +++++++++++++++++++++++++---------- 1 file changed, 142 insertions(+), 55 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7ee97fa8c..dc3fdbcfa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -42,6 +43,10 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The arena used for the prior action allocated state. This contains + /// the contents for the actions as well as the actions slice itself. + action_arena: ArenaAllocator.State, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -61,6 +66,11 @@ pub const Viewer = struct { windows: []const Window, }; + pub const Input = union(enum) { + /// Data from tmux was received that needs to be processed. + tmux: control.Notification, + }; + pub const Window = struct { id: usize, width: usize, @@ -81,32 +91,49 @@ pub const Viewer = struct { // set this to a real value. .session_id = 0, .windows = .empty, + .action_arena = .{}, }; } pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + self.action_arena.promote(self.alloc).deinit(); } - /// Send in the next tmux notification we got from the control mode - /// protocol. The return value is any action that needs to be taken - /// in reaction to this notification (could be none). - pub fn next(self: *Viewer, n: control.Notification) ?Action { - return switch (self.state) { - .startup_block => self.nextStartupBlock(n), - .startup_session => self.nextStartupSession(n), - .defunct => defunct: { - log.info("received notification in defunct state, ignoring", .{}); - break :defunct null; - }, - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + /// Send in an input event (such as a tmux protocol notification, + /// keyboard input for a pane, etc.) and process it. The returned + /// list is a set of actions to take as a result of the input prior + /// to the next input. This list may be empty. + pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + return switch (input) { + .tmux => try self.nextTmux(input.tmux), }; } - fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + fn nextTmux( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + return switch (self.state) { + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct &.{}; + }, + + .startup_block => try self.nextStartupBlock(n), + .startup_session => try self.nextStartupSession(n), + .idle => try self.nextIdle(n), + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => try self.nextCommand(n), + }; + } + + fn nextStartupBlock( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_block); switch (n) { @@ -117,7 +144,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return self.defunct(), + .exit => return try self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -126,69 +153,88 @@ pub const Viewer = struct { // queue the notification, then do notificatins. .block_end, .block_err => { self.state = .startup_session; - return null; + return &.{}; }, // I don't like catch-all else branches but startup is such // a special case of looking for very specific things that // are unlikely to expand. - else => return null, + else => return &.{}, } } - fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + fn nextStartupSession( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return self.defunct(), + .exit => return try self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return .{ .command = std.fmt.comptimePrint( + return try self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, - ) }; + ) }); }, - else => return null, + else => return &.{}, } } - fn nextCommand(self: *Viewer, n: control.Notification) ?Action { - assert(self.state != .startup_block); - assert(self.state != .startup_session); - assert(self.state != .defunct); + fn nextIdle( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + assert(self.state == .idle); switch (n) { .enter => unreachable, + .exit => return try self.defunct(), + else => return &.{}, + } + } - .exit => return self.defunct(), + fn nextCommand( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + switch (n) { + .enter => unreachable, + + .exit => return try self.defunct(), inline .block_end, .block_err, => |content, tag| switch (self.state) { - .startup_block, .startup_session, .defunct => unreachable, + .startup_block, + .startup_session, + .idle, + .defunct, + => unreachable, .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch self.defunct(); + if (comptime tag == .block_err) return try self.defunct(); + return self.receivedListWindows(content) catch return try self.defunct(); }, }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return null, + else => return &.{}, } } fn receivedListWindows( self: *Viewer, content: []const u8, - ) !Action { + ) ![]const Action { assert(self.state == .list_windows); // This stores our new window state from this list-windows output. @@ -220,18 +266,46 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - return .{ .windows = self.windows.items }; + // Go into the idle state + self.state = .idle; + + // TODO: Diff with prior window state, dispatch capture-pane + // requests to collect all of the screen contents, other terminal + // state, etc. + + return try self.singleAction(.{ .windows = self.windows.items }); } - fn defunct(self: *Viewer) Action { + /// Helper to return a single action. The input action must not use + /// any allocated memory from `action_arena` since this will reset + /// the arena. + fn singleAction( + self: *Viewer, + action: Action, + ) Allocator.Error![]const Action { + // Make our actual arena + var arena = self.action_arena.promote(self.alloc); + + // Need to be careful to update our internal state after + // doing allocations since the arena takes a copy of the state. + defer self.action_arena = arena.state; + + // Free everything. We could retain some state here if we wanted + // but I don't think its worth it. + _ = arena.reset(.free_all); + + // Make our single action slice. + const alloc = arena.allocator(); + return try alloc.dupe(Action, &.{action}); + } + + fn defunct(self: *Viewer) Allocator.Error![]const Action { self.state = .defunct; - // In the future we may want to deallocate a bunch of memory - // when we go defunct. - return .exit; + return try self.singleAction(.exit); } }; -const State = enum { +const State = union(enum) { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -246,8 +320,13 @@ const State = enum { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. + /// We're waiting on a list-windows response from tmux. This will + /// be used to resynchronize our entire window state. list_windows, + + /// Idle state, we're not actually doing anything right now except + /// waiting for more events from tmux that may change our behavior. + idle, }; /// Format strings used for commands in our viewer. @@ -284,8 +363,11 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, actions.len); + try testing.expectEqual(.exit, actions[0]); + const actions2 = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, actions2.len); } test "initial flow" { @@ -293,31 +375,36 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - try testing.expect(viewer.next(.{ .block_end = "" }) == null); + const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const action = viewer.next(.{ .session_changed = .{ + const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", - } }).?; - try testing.expect(action == .command); - try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + } } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .command); + try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); try testing.expectEqual(42, viewer.session_id); - // log.warn("{s}", .{action.command}); } // Simulate our list-windows command { - const action = viewer.next(.{ + const actions = try viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , - }).?; - try testing.expect(action == .windows); - try testing.expectEqual(1, action.windows.len); + } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .windows); + try testing.expectEqual(1, actions[0].windows.len); } - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const exit_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, exit_actions.len); + try testing.expectEqual(.exit, exit_actions[0]); + const final_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, final_actions.len); } From 52dbca3d26426937be2e13e2f216177d4e8b467b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:10:54 -0800 Subject: [PATCH 029/605] termio: hook up tmux viewer --- src/termio/stream_handler.zig | 77 +++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e25d635c9..be5cb6418 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -70,6 +70,9 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, + /// The tmux control mode viewer state. + tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -81,9 +84,18 @@ pub const StreamHandler = struct { pub const Stream = terminal.Stream(StreamHandler); + /// True if we have tmux control mode built in. + pub const tmux_enabled = terminal.options.tmux_control_mode; + pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + if (comptime tmux_enabled) tmux: { + const viewer = self.tmux_viewer orelse break :tmux; + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + } } /// This queues a render operation with the renderer thread. The render @@ -371,10 +383,69 @@ pub const StreamHandler = struct { .tmux => |tmux| tmux: { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. - if (comptime !terminal.options.tmux_control_mode) break :tmux; + if (comptime !tmux_enabled) break :tmux; + log.info("tmux control mode event cmd={}", .{tmux}); - // TODO: process it - log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + switch (tmux) { + .enter => { + // Setup our viewer state + assert(self.tmux_viewer == null); + const viewer = try self.alloc.create(terminal.tmux.Viewer); + errdefer self.alloc.destroy(viewer); + viewer.* = .init(self.alloc); + self.tmux_viewer = viewer; + break :tmux; + }, + + .exit => if (self.tmux_viewer) |viewer| { + // Free our viewer state + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + break :tmux; + }, + + else => {}, + } + + assert(tmux != .enter); + assert(tmux != .exit); + + const viewer = self.tmux_viewer orelse { + // This can only really happen if we failed to + // initialize the viewer on enter. + log.info( + "received tmux control mode command without viewer: {}", + .{tmux}, + ); + + break :tmux; + }; + + for (try viewer.next(.{ .tmux = tmux })) |action| { + log.info("tmux viewer action={}", .{action}); + switch (action) { + .exit => { + // We ignore this because we will fully exit when + // our DCS connection ends. We may want to handle + // this in the future to notify our GUI we're + // disconnected though. + }, + + .command => |command| { + assert(command.len > 0); + assert(command[command.len - 1] == '\n'); + self.messageWriter(try termio.Message.writeReq( + self.alloc, + command, + )); + }, + + .windows => { + // TODO + }, + } + } }, .xtgettcap => |*gettcap| { From b26c42f4a64d9fdb686c3678d08b4ce31b3f7fd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:28:00 -0800 Subject: [PATCH 030/605] terminal/tmux: better formatting for notifications and actions --- src/terminal/tmux/control.zig | 26 +++++++++++++++++++++++++- src/terminal/tmux/viewer.zig | 24 ++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 3624173dd..79ed530ec 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -531,7 +531,31 @@ pub const Notification = union(enum) { session_id: usize, name: []const u8, }, -}; + + pub fn format(self: Notification, writer: *std.Io.Writer) !void { + const T = Notification; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } + }; test "tmux begin/end empty" { const testing = std.testing; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index dc3fdbcfa..32da1b4e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -64,6 +64,30 @@ pub const Viewer = struct { /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) /// never re-uses window IDs within a server process lifetime. windows: []const Window, + + pub fn format(self: Action, writer: *std.Io.Writer) !void { + const T = Action; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } }; pub const Input = union(enum) { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index be5cb6418..8218315be 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -384,7 +384,7 @@ pub const StreamHandler = struct { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. if (comptime !tmux_enabled) break :tmux; - log.info("tmux control mode event cmd={}", .{tmux}); + log.info("tmux control mode event cmd={f}", .{tmux}); switch (tmux) { .enter => { @@ -415,7 +415,7 @@ pub const StreamHandler = struct { // This can only really happen if we failed to // initialize the viewer on enter. log.info( - "received tmux control mode command without viewer: {}", + "received tmux control mode command without viewer: {f}", .{tmux}, ); @@ -423,7 +423,7 @@ pub const StreamHandler = struct { }; for (try viewer.next(.{ .tmux = tmux })) |action| { - log.info("tmux viewer action={}", .{action}); + log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { // We ignore this because we will fully exit when From ec5a60a11993467f19ae99d5723304870f060cb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:25:59 -0800 Subject: [PATCH 031/605] terminal/tmux: make sure we always have space for one action --- src/terminal/tmux/viewer.zig | 90 ++++++++++++++++------------------- src/termio/stream_handler.zig | 2 +- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 32da1b4e4..275f93d5e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -47,6 +47,11 @@ pub const Viewer = struct { /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, + /// A single action pre-allocated that we use for single-action + /// returns (common). This ensures that we can never get allocation + /// errors on single-action returns, especially those such as `.exit`. + action_single: [1]Action, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -116,6 +121,7 @@ pub const Viewer = struct { .session_id = 0, .windows = .empty, .action_arena = .{}, + .action_single = undefined, }; } @@ -128,36 +134,39 @@ pub const Viewer = struct { /// keyboard input for a pane, etc.) and process it. The returned /// list is a set of actions to take as a result of the input prior /// to the next input. This list may be empty. - pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + pub fn next(self: *Viewer, input: Input) []const Action { + // Developer note: this function must never return an error. If + // an error occurs we must go into a defunct state or some other + // state to gracefully handle it. return switch (input) { - .tmux => try self.nextTmux(input.tmux), + .tmux => self.nextTmux(input.tmux), }; } fn nextTmux( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { return switch (self.state) { .defunct => defunct: { log.info("received notification in defunct state, ignoring", .{}); break :defunct &.{}; }, - .startup_block => try self.nextStartupBlock(n), - .startup_session => try self.nextStartupSession(n), - .idle => try self.nextIdle(n), + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .idle => self.nextIdle(n), // Once we're in the main states, there's a bunch of shared // logic so we centralize it. - .list_windows => try self.nextCommand(n), + .list_windows => self.nextCommand(n), }; } fn nextStartupBlock( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_block); switch (n) { @@ -168,7 +177,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return try self.defunct(), + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -190,18 +199,18 @@ pub const Viewer = struct { fn nextStartupSession( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return try self.singleAction(.{ .command = std.fmt.comptimePrint( + return self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }); @@ -214,12 +223,12 @@ pub const Viewer = struct { fn nextIdle( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .idle); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), else => return &.{}, } } @@ -227,11 +236,11 @@ pub const Viewer = struct { fn nextCommand( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, @@ -244,8 +253,8 @@ pub const Viewer = struct { .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return try self.defunct(); - return self.receivedListWindows(content) catch return try self.defunct(); + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch return self.defunct(); }, }, @@ -297,35 +306,20 @@ pub const Viewer = struct { // requests to collect all of the screen contents, other terminal // state, etc. - return try self.singleAction(.{ .windows = self.windows.items }); + return self.singleAction(.{ .windows = self.windows.items }); } - /// Helper to return a single action. The input action must not use - /// any allocated memory from `action_arena` since this will reset - /// the arena. - fn singleAction( - self: *Viewer, - action: Action, - ) Allocator.Error![]const Action { - // Make our actual arena - var arena = self.action_arena.promote(self.alloc); - - // Need to be careful to update our internal state after - // doing allocations since the arena takes a copy of the state. - defer self.action_arena = arena.state; - - // Free everything. We could retain some state here if we wanted - // but I don't think its worth it. - _ = arena.reset(.free_all); - + /// Helper to return a single action. The input action may use the arena + /// for allocated memory; this will not touch the arena. + fn singleAction(self: *Viewer, action: Action) []const Action { // Make our single action slice. - const alloc = arena.allocator(); - return try alloc.dupe(Action, &.{action}); + self.action_single[0] = action; + return &self.action_single; } - fn defunct(self: *Viewer) Allocator.Error![]const Action { + fn defunct(self: *Viewer) []const Action { self.state = .defunct; - return try self.singleAction(.exit); + return self.singleAction(.exit); } }; @@ -387,10 +381,10 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = try viewer.next(.{ .tmux = .exit }); + const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); try testing.expectEqual(.exit, actions[0]); - const actions2 = try viewer.next(.{ .tmux = .exit }); + const actions2 = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, actions2.len); } @@ -399,12 +393,12 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ + const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", } } }); @@ -416,7 +410,7 @@ test "initial flow" { // Simulate our list-windows command { - const actions = try viewer.next(.{ .tmux = .{ + const actions = viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , @@ -426,9 +420,9 @@ test "initial flow" { try testing.expectEqual(1, actions[0].windows.len); } - const exit_actions = try viewer.next(.{ .tmux = .exit }); + const exit_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, exit_actions.len); try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = try viewer.next(.{ .tmux = .exit }); + const final_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, final_actions.len); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8218315be..ba207ce7b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -422,7 +422,7 @@ pub const StreamHandler = struct { break :tmux; }; - for (try viewer.next(.{ .tmux = tmux })) |action| { + for (viewer.next(.{ .tmux = tmux })) |action| { log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { From 86cd4897012758a59d8068b796b449ff7ff37f16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:09:11 -0800 Subject: [PATCH 032/605] terminal/tmux: introduce command queue for viewer --- src/terminal/tmux/viewer.zig | 196 ++++++++++++++++++++++++++-------- src/termio/stream_handler.zig | 3 +- 2 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 275f93d5e..384ad609b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; const control = @import("control.zig"); const output = @import("output.zig"); @@ -19,6 +20,12 @@ const log = std.log.scoped(.terminal_tmux_viewer); // in case something breaks in the future we can consider it. We should // be able to easily unit test all variations seen in the real world. +/// The initial capacity of the command queue. We dynamically resize +/// as necessary so the initial value isn't that important, but if we +/// want to feel good about it we should make it large enough to support +/// our most realistic use cases without resizing. +const COMMAND_QUEUE_INITIAL = 8; + /// A viewer is a tmux control mode client that attempts to create /// a remote view of a tmux session, including providing the ability to send /// new input to the session. @@ -40,6 +47,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The list of commands we've sent that we want to send and wait + /// for a response for. We only send one command at a time just + /// to avoid any possible confusion around ordering. + command_queue: CommandQueue, + /// The windows in the current session. windows: std.ArrayList(Window), @@ -52,6 +64,8 @@ pub const Viewer = struct { /// errors on single-action returns, especially those such as `.exit`. action_single: [1]Action, + pub const CommandQueue = CircBuf(Command, undefined); + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -111,7 +125,11 @@ pub const Viewer = struct { /// /// The given allocator is used for all internal state. You must /// call deinit when you're done with the viewer to free it. - pub fn init(alloc: Allocator) Viewer { + pub fn init(alloc: Allocator) Allocator.Error!Viewer { + // Create our initial command queue + var command_queue: CommandQueue = try .init(alloc, COMMAND_QUEUE_INITIAL); + errdefer command_queue.deinit(alloc); + return .{ .alloc = alloc, .state = .startup_block, @@ -119,6 +137,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .command_queue = command_queue, .windows = .empty, .action_arena = .{}, .action_single = undefined, @@ -127,6 +146,11 @@ pub const Viewer = struct { pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + { + var it = self.command_queue.iterator(.forward); + while (it.next()) |command| command.deinit(self.alloc); + self.command_queue.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -155,11 +179,7 @@ pub const Viewer = struct { .startup_block => self.nextStartupBlock(n), .startup_session => self.nextStartupSession(n), - .idle => self.nextIdle(n), - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + .command_queue => self.nextCommand(n), }; } @@ -209,11 +229,11 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .list_windows; - return self.singleAction(.{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'\n", - .{comptime Format.list_windows.comptimeFormat()}, - ) }); + self.state = .command_queue; + return self.singleAction(self.queueCommand(.list_windows) catch { + log.warn("failed to queue command, becoming defunct", .{}); + return self.defunct(); + }); }, else => return &.{}, @@ -237,39 +257,85 @@ pub const Viewer = struct { self: *Viewer, n: control.Notification, ) []const Action { - switch (n) { - .enter => unreachable, + // We have to be in a command queue, but the command queue MAY + // be empty. If it is empty, then receivedCommandOutput will + // handle it by ignoring any command output. That's okay! + assert(self.state == .command_queue); - .exit => return self.defunct(), + return switch (n) { + .enter => unreachable, + .exit => self.defunct(), inline .block_end, .block_err, - => |content, tag| switch (self.state) { - .startup_block, - .startup_session, - .idle, - .defunct, - => unreachable, - - .list_windows => { - // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch return self.defunct(); - }, + => |content, tag| self.receivedCommandOutput( + content, + tag == .block_err, + ) catch err: { + log.warn("failed to process command output, becoming defunct", .{}); + break :err self.defunct(); }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return &.{}, + else => &.{}, + }; + } + + fn receivedCommandOutput( + self: *Viewer, + content: []const u8, + is_err: bool, + ) ![]const Action { + // If we have no pending commands, this is unexpected. + const command = self.command_queue.first() orelse { + log.info("unexpected block output err={}", .{is_err}); + return &.{}; + }; + self.command_queue.deleteOldest(1); + + // We always free any memory associated with the command + defer command.deinit(self.alloc); + + // We'll use our arena for the return value here so we can + // easily accumulate actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Build up our actions to start with the next command if + // we have one. + var actions: std.ArrayList(Action) = .empty; + if (self.command_queue.first()) |next_command| { + try actions.append( + arena_alloc, + .{ .command = next_command.string() }, + ); } + + // Process our command + switch (command.*) { + .user => {}, + .list_windows => try self.receivedListWindows( + arena_alloc, + &actions, + content, + ), + } + + // Our command processing should not change our state + assert(self.state == .command_queue); + + return actions.items; } fn receivedListWindows( self: *Viewer, + arena_alloc: Allocator, + actions: *std.ArrayList(Action), content: []const u8, - ) ![]const Action { - assert(self.state == .list_windows); - + ) !void { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -299,14 +365,27 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - // Go into the idle state - self.state = .idle; - // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. - return self.singleAction(.{ .windows = self.windows.items }); + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + } + + /// This queues the command at the end of the command queue + /// and returns an action representing the next command that + /// should be run (the head). + /// + /// The next command is not removed, because the expectation is + /// that the head of our command list is always sent to tmux. + fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + // Add our command + try self.command_queue.ensureUnusedCapacity(self.alloc, 1); + self.command_queue.appendAssumeCapacity(command); + + // Get our first command to send, guaranteed to exist since we + // just appended one. + return .{ .command = self.command_queue.first().?.string() }; } /// Helper to return a single action. The input action may use the arena @@ -323,7 +402,7 @@ pub const Viewer = struct { } }; -const State = union(enum) { +const State = enum { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -338,13 +417,46 @@ const State = union(enum) { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. This will - /// be used to resynchronize our entire window state. + /// We're sitting on the command queue waiting for command output + /// in the order provided in the `command_queue` field. This field + /// isn't part of the state because it can be queued at any state. + /// + /// Precondition: if self.command_queue.len > 0, then the first + /// command in the queue has already been sent to tmux (via a + /// `command` Action). The next output is assumed to be the result + /// of this command. + /// + /// To satisfy the above, any transitions INTO this state should + /// send a command Action for the first command in the queue. + command_queue, +}; + +const Command = union(enum) { + /// List all windows so we can sync our window state. list_windows, - /// Idle state, we're not actually doing anything right now except - /// waiting for more events from tmux that may change our behavior. - idle, + /// User command. This is a command provided by the user. Since + /// this is user provided, we can't be sure what it is. + user: []const u8, + + pub fn deinit(self: Command, alloc: Allocator) void { + return switch (self) { + .list_windows => {}, + .user => |v| alloc.free(v), + }; + } + + /// Returns the command to execute. The memory of the return + /// value is always safe as long as this command value is alive. + pub fn string(self: Command) []const u8 { + return switch (self) { + .list_windows => std.fmt.comptimePrint( + "list-windows -F '{s}'\n", + .{comptime Format.list_windows.comptimeFormat()}, + ), + .user => |v| v, + }; + } }; /// Format strings used for commands in our viewer. @@ -379,7 +491,7 @@ const Format = struct { }; test "immediate exit" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); @@ -389,7 +501,7 @@ test "immediate exit" { } test "initial flow" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); // First we receive the initial block end diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ba207ce7b..eabfd6a4b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -392,7 +392,8 @@ pub const StreamHandler = struct { assert(self.tmux_viewer == null); const viewer = try self.alloc.create(terminal.tmux.Viewer); errdefer self.alloc.destroy(viewer); - viewer.* = .init(self.alloc); + viewer.* = try .init(self.alloc); + errdefer viewer.deinit(); self.tmux_viewer = viewer; break :tmux; }, From ea09d257a1cd27b66236de474a2e26b05e843631 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 10:45:28 -0800 Subject: [PATCH 033/605] terminal/tmux: initialize panes --- src/terminal/tmux/viewer.zig | 143 ++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 384ad609b..7b0307a8f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Terminal = @import("../Terminal.zig"); +const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); const output = @import("output.zig"); @@ -55,6 +57,9 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The panes in the current session, mapped by pane ID. + panes: PanesMap, + /// The arena used for the prior action allocated state. This contains /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, @@ -65,6 +70,7 @@ pub const Viewer = struct { action_single: [1]Action, pub const CommandQueue = CircBuf(Command, undefined); + pub const PanesMap = std.AutoArrayHashMapUnmanaged(usize, Pane); pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -118,7 +124,20 @@ pub const Viewer = struct { id: usize, width: usize, height: usize, - // TODO: more fields, obviously! + layout_arena: ArenaAllocator.State, + layout: Layout, + + pub fn deinit(self: *Window, alloc: Allocator) void { + self.layout_arena.promote(alloc).deinit(); + } + }; + + pub const Pane = struct { + terminal: Terminal, + + pub fn deinit(self: *Pane, alloc: Allocator) void { + self.terminal.deinit(alloc); + } }; /// Initialize a new viewer. @@ -139,18 +158,27 @@ pub const Viewer = struct { .session_id = 0, .command_queue = command_queue, .windows = .empty, + .panes = .empty, .action_arena = .{}, .action_single = undefined, }; } pub fn deinit(self: *Viewer) void { - self.windows.deinit(self.alloc); + { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.deinit(self.alloc); + } { var it = self.command_queue.iterator(.forward); while (it.next()) |command| command.deinit(self.alloc); self.command_queue.deinit(self.alloc); } + { + var it = self.panes.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -354,22 +382,131 @@ pub const Viewer = struct { return err; }; + // Parse the layout + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const window_alloc = arena.allocator(); + const layout: Layout = Layout.parseWithChecksum( + window_alloc, + data.window_layout, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ data.window_id, data.window_layout }, + ); + return err; + }; + try windows.append(self.alloc, .{ .id = data.window_id, .width = data.window_width, .height = data.window_height, + .layout_arena = arena.state, + .layout = layout, }); } + // Setup our windows action so the caller can process GUI + // window changes. + try actions.append(arena_alloc, .{ .windows = windows.items }); + + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + panes.deinit(self.alloc); + } + for (windows.items) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + arena_alloc, + actions, + window.layout, + ); + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + // Replace our window list + for (self.windows.items) |*window| window.deinit(self.alloc); self.windows.deinit(self.alloc); self.windows = windows; + // Replace our panes + { + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + self.panes = panes; + } + // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. + } - try actions.append(arena_alloc, .{ .windows = self.windows.items }); + fn initLayout( + gpa_alloc: Allocator, + panes_old: *PanesMap, + panes_new: *PanesMap, + actions_alloc: Allocator, + actions: *std.ArrayList(Action), + layout: Layout, + ) !void { + switch (layout.content) { + // Nested layouts, continue going. + .horizontal, .vertical => |layouts| { + for (layouts) |l| { + try initLayout( + gpa_alloc, + panes_old, + panes_new, + actions_alloc, + actions, + l, + ); + } + }, + + // A leaf! Initialize. + .pane => |id| pane: { + const gop = try panes_new.getOrPut(gpa_alloc, id); + if (gop.found_existing) { + // We already have the pane setup. It should not exist + // in the old map because we remove that when we set + // it up. + assert(!panes_old.contains(id)); + break :pane; + } + errdefer _ = panes_new.swapRemove(gop.key_ptr.*); + + // We don't have it in our new map. If it exists in our old + // map then we copy it over and we're done. + if (panes_old.fetchSwapRemove(id)) |entry| { + gop.value_ptr.* = entry.value; + break :pane; + } + + // TODO: We need to gracefully handle overflow of our + // max cols/width here. In practice we shouldn't hit this + // so we cast but its not safe. + var t: Terminal = try .init(gpa_alloc, .{ + .cols = @intCast(layout.width), + .rows = @intCast(layout.height), + }); + errdefer t.deinit(gpa_alloc); + + gop.value_ptr.* = .{ + .terminal = t, + }; + }, + } } /// This queues the command at the end of the command queue From 766c306e0437a2f301c11cd9d8e7c1dcf969383b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 19:45:46 -0800 Subject: [PATCH 034/605] terminal/tmux: pane history --- src/terminal/tmux/viewer.zig | 79 +++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7b0307a8f..1c5007625 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,14 +315,27 @@ pub const Viewer = struct { content: []const u8, is_err: bool, ) ![]const Action { - // If we have no pending commands, this is unexpected. - const command = self.command_queue.first() orelse { + // Get the command we're expecting output for. We need to get the + // non-pointer value because we are deleting it from the circular + // buffer immediately. This shallow copy is all we need since + // all the memory in Command is owned by GPA. + const command: Command = if (self.command_queue.first()) |ptr| switch (ptr.*) { + // I truly can't explain this. A simple `ptr.*` copy will cause + // our memory to become undefined when deleteOldest is called + // below. I logged all the pointers and they don't match so I + // don't know how its being set to undefined. But a copy like + // this does work. + inline else => |v, tag| @unionInit( + Command, + @tagName(tag), + v, + ), + } else { + // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); return &.{}; }; self.command_queue.deleteOldest(1); - - // We always free any memory associated with the command defer command.deinit(self.alloc); // We'll use our arena for the return value here so we can @@ -336,20 +349,25 @@ pub const Viewer = struct { // we have one. var actions: std.ArrayList(Action) = .empty; if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); try actions.append( arena_alloc, - .{ .command = next_command.string() }, + .{ .command = builder.writer.buffered() }, ); } // Process our command - switch (command.*) { + switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), + .pane_history => { + // TODO + }, } // Our command processing should not change our state @@ -515,6 +533,10 @@ pub const Viewer = struct { /// /// The next command is not removed, because the expectation is /// that the head of our command list is always sent to tmux. + /// + /// Note: this modifies the `action_arena` since this will put + /// the command string into the arena. It does not clear the arena + /// so any previously allocated values remain valid. fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); @@ -522,7 +544,13 @@ pub const Viewer = struct { // Get our first command to send, guaranteed to exist since we // just appended one. - return .{ .command = self.command_queue.first().?.string() }; + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const next_command = self.command_queue.first().?; + next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; + return .{ .command = builder.writer.buffered() }; } /// Helper to return a single action. The input action may use the arena @@ -572,27 +600,48 @@ const Command = union(enum) { /// List all windows so we can sync our window state. list_windows, + /// Capture history for the given pane ID. + pane_history: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { - .list_windows => {}, + .list_windows, + .pane_history, + => {}, .user => |v| alloc.free(v), }; } - /// Returns the command to execute. The memory of the return - /// value is always safe as long as this command value is alive. - pub fn string(self: Command) []const u8 { - return switch (self) { - .list_windows => std.fmt.comptimePrint( + /// Format the command into the command that should be executed + /// by tmux. Trailing newlines are appended so this can be sent as-is + /// to tmux. + pub fn formatCommand( + self: Command, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (self) { + .list_windows => try writer.writeAll(std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, + )), + + .pane_history => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -S - = start at the top of history ("-") + // -E -1 = end at the last line of history (1 before the + // visible area is -1). + // -t %{d} = target a specific pane ID + "capture-pane -p -e -S - -E -1 -t %{d}", + .{id}, ), - .user => |v| v, - }; + + .user => |v| try writer.writeAll(v), + } } }; From f02a2d5eed7cf59f2eed24cd5b16f225129d9d32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 07:29:59 -0800 Subject: [PATCH 035/605] terminal/tmux: capture pane --- src/terminal/tmux/viewer.zig | 164 +++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 1c5007625..82aed6c2a 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -257,11 +257,17 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .command_queue; - return self.singleAction(self.queueCommand(.list_windows) catch { + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + return self.enterCommandQueue( + arena.allocator(), + .list_windows, + ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); - }); + }; }, else => return &.{}, @@ -348,14 +354,6 @@ pub const Viewer = struct { // Build up our actions to start with the next command if // we have one. var actions: std.ArrayList(Action) = .empty; - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } // Process our command switch (command) { @@ -370,6 +368,18 @@ pub const Viewer = struct { }, } + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. + if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); + try actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ); + } + // Our command processing should not change our state assert(self.state == .command_queue); @@ -382,6 +392,9 @@ pub const Viewer = struct { actions: *std.ArrayList(Action), content: []const u8, ) !void { + // If there is an error, reset our actions to what it was before. + errdefer actions.shrinkRetainingCapacity(actions.items.len); + // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -433,19 +446,50 @@ pub const Viewer = struct { // list. var panes: PanesMap = .empty; errdefer { + // Clear out all the new panes. var panes_it = panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } panes.deinit(self.alloc); } for (windows.items) |window| try initLayout( self.alloc, &self.panes, &panes, - arena_alloc, - actions, window.layout, ); + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = pane_id }, + }); + } + } + // No more errors after this point. We're about to replace all // our owned state with our temporary state, and our errdefers // above will double-free if there is an error. @@ -458,8 +502,15 @@ pub const Viewer = struct { // Replace our panes { - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. self.panes.deinit(self.alloc); self.panes = panes; } @@ -471,10 +522,8 @@ pub const Viewer = struct { fn initLayout( gpa_alloc: Allocator, - panes_old: *PanesMap, + panes_old: *const PanesMap, panes_new: *PanesMap, - actions_alloc: Allocator, - actions: *std.ArrayList(Action), layout: Layout, ) !void { switch (layout.content) { @@ -485,8 +534,6 @@ pub const Viewer = struct { gpa_alloc, panes_old, panes_new, - actions_alloc, - actions, l, ); } @@ -495,19 +542,13 @@ pub const Viewer = struct { // A leaf! Initialize. .pane => |id| pane: { const gop = try panes_new.getOrPut(gpa_alloc, id); - if (gop.found_existing) { - // We already have the pane setup. It should not exist - // in the old map because we remove that when we set - // it up. - assert(!panes_old.contains(id)); - break :pane; - } + if (gop.found_existing) break :pane; errdefer _ = panes_new.swapRemove(gop.key_ptr.*); - // We don't have it in our new map. If it exists in our old - // map then we copy it over and we're done. - if (panes_old.fetchSwapRemove(id)) |entry| { - gop.value_ptr.* = entry.value; + // If we already have this pane, it is already initialized + // so just copy it over. + if (panes_old.getEntry(id)) |entry| { + gop.value_ptr.* = entry.value_ptr.*; break :pane; } @@ -527,30 +568,45 @@ pub const Viewer = struct { } } - /// This queues the command at the end of the command queue - /// and returns an action representing the next command that - /// should be run (the head). - /// - /// The next command is not removed, because the expectation is - /// that the head of our command list is always sent to tmux. - /// - /// Note: this modifies the `action_arena` since this will put - /// the command string into the arena. It does not clear the arena - /// so any previously allocated values remain valid. - fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + /// Enters the command queue state from any other state, queueing + /// the command and returning an action to execute the first command. + fn enterCommandQueue( + self: *Viewer, + arena_alloc: Allocator, + command: Command, + ) Allocator.Error![]const Action { + assert(self.state != .command_queue); + + // Build our command string to send for the action. + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); self.command_queue.appendAssumeCapacity(command); - // Get our first command to send, guaranteed to exist since we - // just appended one. - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - const arena_alloc = arena.allocator(); - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const next_command = self.command_queue.first().?; - next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; - return .{ .command = builder.writer.buffered() }; + // Move into the command queue state + self.state = .command_queue; + + return self.singleAction(action); + } + + /// Queue multiple commands to execute. This doesn't add anything + /// to the actions queue or return actions or anything because the + /// command_queue state will automatically send the next command when + /// it receives output. + fn queueCommands( + self: *Viewer, + commands: []const Command, + ) Allocator.Error!void { + try self.command_queue.ensureUnusedCapacity( + self.alloc, + commands.len, + ); + for (commands) |command| { + self.command_queue.appendAssumeCapacity(command); + } } /// Helper to return a single action. The input action may use the arena @@ -636,7 +692,7 @@ const Command = union(enum) { // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}", + "capture-pane -p -e -S - -E -1 -t %{d}\n", .{id}, ), @@ -713,7 +769,7 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , } }); - try testing.expectEqual(1, actions.len); + try testing.expect(actions.len > 0); try testing.expect(actions[0] == .windows); try testing.expectEqual(1, actions[0].windows.len); } From e1e2791fb72d27c0383140dcc2bf2a10021bec45 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 09:48:17 -0800 Subject: [PATCH 036/605] terminal/tmux: pane_history replays it into terminal --- src/terminal/tmux/viewer.zig | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 82aed6c2a..e9d318e7f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -358,14 +358,17 @@ pub const Viewer = struct { // Process our command switch (command) { .user => {}, + .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), - .pane_history => { - // TODO - }, + + .pane_history => |id| try self.receivedPaneHistory( + id, + content, + ), } // After processing commands, we add our next command to @@ -514,10 +517,29 @@ pub const Viewer = struct { self.panes.deinit(self.alloc); self.panes = panes; } + } - // TODO: Diff with prior window state, dispatch capture-pane - // requests to collect all of the screen contents, other terminal - // state, etc. + fn receivedPaneHistory( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane history for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Get a VT stream from the terminal so we can send data as-is into + // it. This will populate the active area too so it won't be exactly + // correct but we'll get the active contents soon. + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane history for pane id={}: {}", .{ id, err }); + return err; + }; } fn initLayout( @@ -747,8 +769,10 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions0.len); + { + const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions.len); + } // Then we receive session-changed with the initial session { From 41bf54100524858f59a9cc2e63a3e86eafd8fa1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 10:17:03 -0800 Subject: [PATCH 037/605] terminal/tmux: test helper --- src/terminal/tmux/viewer.zig | 178 +++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 40 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index e9d318e7f..8d3194748 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -754,53 +754,151 @@ const Format = struct { } }; +const TestStep = struct { + input: Viewer.Input, + contains_tags: []const std.meta.Tag(Viewer.Action) = &.{}, + contains_command: []const u8 = "", + check: ?*const fn (viewer: *Viewer, []const Viewer.Action) anyerror!void = null, + check_command: ?*const fn (viewer: *Viewer, []const u8) anyerror!void = null, + + fn run(self: TestStep, viewer: *Viewer) !void { + const actions = viewer.next(self.input); + + // Common mistake, forgetting the newline on a command. + for (actions) |action| { + if (action == .command) { + try testing.expect(std.mem.endsWith(u8, action.command, "\n")); + } + } + + for (self.contains_tags) |tag| { + var found = false; + for (actions) |action| { + if (action == tag) { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.contains_command.len > 0) { + var found = false; + for (actions) |action| { + if (action == .command and + std.mem.startsWith(u8, action.command, self.contains_command)) + { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.check) |check_fn| { + try check_fn(viewer, actions); + } + + if (self.check_command) |check_fn| { + var found = false; + for (actions) |action| { + if (action == .command) { + found = true; + try check_fn(viewer, action.command); + } + } + try testing.expect(found); + } + } +}; + +/// A helper to run a series of test steps against a viewer and assert +/// that the expected actions are produced. +/// +/// I'm generally not a fan of these types of abstracted tests because +/// it makes diagnosing failures harder, but being able to construct +/// simulated tmux inputs and verify outputs is going to be extremely +/// important since the tmux control mode protocol is very complex and +/// fragile. +fn testViewer(viewer: *Viewer, steps: []const TestStep) !void { + for (steps, 0..) |step, i| { + step.run(viewer) catch |err| { + log.warn("testViewer step failed i={} step={}", .{ i, step }); + return err; + }; + } +} + test "immediate exit" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, actions.len); - try testing.expectEqual(.exit, actions[0]); - const actions2 = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, actions2.len); + + try testViewer(&viewer, &.{ + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + .{ + .input = .{ .tmux = .exit }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, + }); } test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - // First we receive the initial block end - { - const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions.len); - } - - // Then we receive session-changed with the initial session - { - const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ - .id = 42, - .name = "main", - } } }); - try testing.expectEqual(1, actions.len); - try testing.expect(actions[0] == .command); - try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); - try testing.expectEqual(42, viewer.session_id); - } - - // Simulate our list-windows command - { - const actions = viewer.next(.{ .tmux = .{ - .block_end = - \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] - , - } }); - try testing.expect(actions.len > 0); - try testing.expect(actions[0] == .windows); - try testing.expectEqual(1, actions[0].windows.len); - } - - const exit_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, exit_actions.len); - try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, final_actions.len); + try testViewer(&viewer, &.{ + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 42, + .name = "main", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(42, v.session_id); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\Hello, world! + \\ + , + } }, + // Moves on to the next pane + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); } From b7fe9a926da6e479ccd3d06fd13c49f4f68c07a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:19:47 -0800 Subject: [PATCH 038/605] terminal/tmux: capture visible area after history --- src/terminal/tmux/viewer.zig | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 8d3194748..28a2aaf1e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -369,6 +369,11 @@ pub const Viewer = struct { id, content, ), + + .pane_visible => |id| try self.receivedPaneVisible( + id, + content, + ), } // After processing commands, we add our next command to @@ -489,6 +494,7 @@ pub const Viewer = struct { if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ .{ .pane_history = pane_id }, + .{ .pane_visible = pane_id }, }); } } @@ -542,6 +548,31 @@ pub const Viewer = struct { }; } + fn receivedPaneVisible( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane visible for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Erase the active area and reset the cursor to the top-left + // before writing the visible content. + pane.terminal.eraseDisplay(.complete, false); + pane.terminal.setCursorPos(1, 1); + + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -681,6 +712,9 @@ const Command = union(enum) { /// Capture history for the given pane ID. pane_history: usize, + /// Capture visible area for the given pane ID. + pane_visible: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -689,6 +723,7 @@ const Command = union(enum) { return switch (self) { .list_windows, .pane_history, + .pane_visible, => {}, .user => |v| alloc.free(v), }; @@ -718,6 +753,15 @@ const Command = union(enum) { .{id}, ), + .pane_visible => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -t %{d} = target a specific pane ID + // (no -S/-E = capture visible area only) + "capture-pane -p -e -t %{d}\n", + .{id}, + ), + .user => |v| try writer.writeAll(v), } } @@ -888,7 +932,27 @@ test "initial flow" { \\ , } }, - // Moves on to the next pane + // Moves on to pane_visible for pane 0 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { From a3e01581bea9907c3d03d180c1eb57850b9d89c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:29:27 -0800 Subject: [PATCH 039/605] terminal/tmux: history capture clears active area --- src/terminal/tmux/viewer.zig | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 28a2aaf1e..aa9c91a03 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Screen = @import("../Screen.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -536,16 +537,34 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into // it. This will populate the active area too so it won't be exactly // correct but we'll get the active contents soon. - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane history for pane id={}: {}", .{ id, err }); return err; }; + + // Populate the active area to be empty since this is only history. + // We'll fill it with blanks and move the cursor to the top-left. + t.carriageReturn(); + for (0..t.rows) |_| try t.index(); + t.setCursorPos(1, 1); + + // Our active area should be empty + if (comptime std.debug.runtime_safety) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + screen.dumpString(&discarding.writer, .{ + .tl = screen.pages.getTopLeft(.active), + .unwrap = false, + }) catch unreachable; + assert(discarding.count == 0); + } } fn receivedPaneVisible( @@ -929,7 +948,6 @@ test "initial flow" { .input = .{ .tmux = .{ .block_end = \\Hello, world! - \\ , } }, // Moves on to pane_visible for pane 0 @@ -939,6 +957,28 @@ test "initial flow" { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); } }).check, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello, world!", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + } + }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, From 50ac848672d3752e67af125101f9bccd75748f8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 12:53:18 -0800 Subject: [PATCH 040/605] terminal/tmux: capture both primary/alt screen --- src/terminal/tmux/viewer.zig | 116 ++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index aa9c91a03..5df5b83bb 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -5,6 +5,7 @@ const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const Screen = @import("../Screen.zig"); +const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -366,13 +367,15 @@ pub const Viewer = struct { content, ), - .pane_history => |id| try self.receivedPaneHistory( - id, + .pane_history => |cap| try self.receivedPaneHistory( + cap.screen_key, + cap.id, content, ), - .pane_visible => |id| try self.receivedPaneVisible( - id, + .pane_visible => |cap| try self.receivedPaneVisible( + cap.screen_key, + cap.id, content, ), } @@ -494,8 +497,10 @@ pub const Viewer = struct { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ - .{ .pane_history = pane_id }, - .{ .pane_visible = pane_id }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } } @@ -528,6 +533,7 @@ pub const Viewer = struct { fn receivedPaneHistory( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -538,6 +544,7 @@ pub const Viewer = struct { }; const pane: *Pane = entry.value_ptr; const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into @@ -569,6 +576,7 @@ pub const Viewer = struct { fn receivedPaneVisible( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -578,13 +586,15 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); // Erase the active area and reset the cursor to the top-left // before writing the visible content. - pane.terminal.eraseDisplay(.complete, false); - pane.terminal.setCursorPos(1, 1); + t.eraseDisplay(.complete, false); + t.setCursorPos(1, 1); - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); @@ -729,15 +739,20 @@ const Command = union(enum) { list_windows, /// Capture history for the given pane ID. - pane_history: usize, + pane_history: CapturePane, /// Capture visible area for the given pane ID. - pane_visible: usize, + pane_visible: CapturePane, /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, + const CapturePane = struct { + id: usize, + screen_key: ScreenSet.Key, + }; + pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { .list_windows, @@ -761,24 +776,34 @@ const Command = union(enum) { .{comptime Format.list_windows.comptimeFormat()}, )), - .pane_history => |id| try writer.print( + .pane_history => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -S - = start at the top of history ("-") // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-S - -E -1 -t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), - .pane_visible => |id| try writer.print( + .pane_visible => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -t %{d} = target a specific pane ID // (no -S/-E = capture visible area only) - "capture-pane -p -e -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), .user => |v| try writer.writeAll(v), @@ -938,9 +963,11 @@ test "initial flow" { } }, .contains_tags = &.{ .windows, .command }, .contains_command = "capture-pane", + // pane_history for pane 0 (primary) .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, @@ -950,11 +977,12 @@ test "initial flow" { \\Hello, world! , } }, - // Moves on to pane_visible for pane 0 + // Moves on to pane_visible for pane 0 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, .check = (struct { @@ -982,21 +1010,67 @@ test "initial flow" { }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_history for pane 1 + // Moves on to pane_history for pane 0 (alternate) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { - try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_visible for pane 1 + // Moves on to pane_visible for pane 0 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, From 938e419e042bfd9322b5180e6ac54c122f558a36 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:11:58 -0800 Subject: [PATCH 041/605] terminal/tmux: handle output events --- src/terminal/tmux/viewer.zig | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5df5b83bb..f6cf6292b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -13,6 +13,12 @@ const output = @import("output.zig"); const log = std.log.scoped(.terminal_tmux_viewer); +// TODO: A list of TODOs as I think about them. +// - We need to make startup more robust so session and block can happen +// out of order. +// - We need to ignore `output` for panes that aren't yet initialized +// (until capture-panes are complete). + // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications // and assurances about what is sent when are based on reading the tmux @@ -312,6 +318,20 @@ pub const Viewer = struct { break :err self.defunct(); }, + .output => |out| output: { + self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); + }; + + break :output &.{}; + }, + // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. else => &.{}, @@ -602,6 +622,26 @@ pub const Viewer = struct { }; } + fn receivedOutput( + self: *Viewer, + id: usize, + data: []const u8, + ) !void { + const entry = self.panes.getEntry(id) orelse { + log.info("received output for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(data) catch |err| { + log.info("failed to process output for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -1074,6 +1114,33 @@ test "initial flow" { } }).check, }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, .{ .input = .{ .tmux = .exit }, .contains_tags = &.{.exit}, From 64ef640127c7a48172a27990f240a8c068b0ea70 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:52:53 -0800 Subject: [PATCH 042/605] terminal/tmux: exhaustive switch for command --- src/terminal/tmux/viewer.zig | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index f6cf6292b..9c6fa1b1f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -18,6 +18,8 @@ const log = std.log.scoped(.terminal_tmux_viewer); // out of order. // - We need to ignore `output` for panes that aren't yet initialized // (until capture-panes are complete). +// - We should note what the active window pane is on the tmux side; +// we can use this at least for initial focus. // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications @@ -332,9 +334,30 @@ pub const Viewer = struct { break :output &.{}; }, - // TODO: Use exhaustive matching here, determine if we need - // to handle the other cases. - else => &.{}, + // TODO: There's real logic to do for these. + .session_changed, + .layout_change, + .window_add, + => &.{}, + + // The active pane changed. We don't care about this because + // we handle our own focus. + .window_pane_changed => &.{}, + + // We ignore this one. It means a session was created or + // destroyed. If it was our own session we will get an exit + // notification very soon. If it is another session we don't + // care. + .sessions_changed => &.{}, + + // We don't use window names for anything, currently. + .window_renamed => &.{}, + + // This is for other clients, which we don't do anything about. + // For us, we'll get `exit` or `session_changed`, respectively. + .client_detached, + .client_session_changed, + => &.{}, }; } From 071070faa3a5d9d56e4f802218cd6e8a31075670 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 14:11:25 -0800 Subject: [PATCH 043/605] terminal/tmux: handle session_changed inside command loop --- src/terminal/tmux/viewer.zig | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 9c6fa1b1f..3b401f44e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,9 +315,9 @@ pub const Viewer = struct { => |content, tag| self.receivedCommandOutput( content, tag == .block_err, - ) catch err: { + ) catch { log.warn("failed to process command output, becoming defunct", .{}); - break :err self.defunct(); + return self.defunct(); }, .output => |out| output: { @@ -334,8 +334,14 @@ pub const Viewer = struct { break :output &.{}; }, + // Session changed means we switched to a different tmux session. + // We need to reset our state and start fresh with list-windows. + .session_changed => |info| self.sessionChanged(info.id) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .session_changed, .layout_change, .window_add, => &.{}, @@ -361,6 +367,47 @@ pub const Viewer = struct { }; } + /// When a session changes, we have to basically reset our whole state. + /// To do this, we emit an empty windows event (so callers can clear all + /// windows), reset ourself, and start all over. + fn sessionChanged( + self: *Viewer, + session_id: usize, + ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + // Build up a new viewer. Its the easiest way to reset ourselves. + var replacement: Viewer = try .init(self.alloc); + errdefer replacement.deinit(); + + // Build actions: empty windows notification + list-windows command + var arena = replacement.action_arena.promote(replacement.alloc); + const arena_alloc = arena.allocator(); + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = &.{} }); + + // Setup our command queue + try actions.appendSlice( + arena_alloc, + try replacement.enterCommandQueue( + arena_alloc, + .list_windows, + ), + ); + + // Save arena state back before swap + replacement.action_arena = arena.state; + + // Swap our self, no more error handling after this. + errdefer comptime unreachable; + self.deinit(); + self.* = replacement; + + // Set our session ID and jump directly to the list + self.session_id = session_id; + + assert(self.state == .command_queue); + return actions.items; + } + fn receivedCommandOutput( self: *Viewer, content: []const u8, @@ -1000,6 +1047,89 @@ test "immediate exit" { }); } +test "session changed resets state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "first", + } } }, + .contains_command = "list-windows", + }, + // Receive window layout with two panes (same format as "initial flow" test) + .{ + .input = .{ .tmux = .{ + .block_end = + \\$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + // Now session changes - should reset everything + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 2, + .name = "second", + } } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Session ID should be updated + try testing.expectEqual(2, v.session_id); + // Windows should be cleared (empty windows action sent) + var found_empty_windows = false; + for (actions) |action| { + if (action == .windows and action.windows.len == 0) { + found_empty_windows = true; + } + } + try testing.expect(found_empty_windows); + // Old windows should be cleared + try testing.expectEqual(0, v.windows.items.len); + // Old panes should be cleared + try testing.expectEqual(0, v.panes.count()); + } + }).check, + }, + // Receive new window layout for new session (same layout, different session/window) + // Uses same pane IDs 0,1 - they should be re-created since old panes were cleared + .{ + .input = .{ .tmux = .{ + .block_end = + \\$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.windows.items[0].id); + // Panes 0 and 1 should be created (fresh, since old ones were cleared) + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); From 5df95ba210b40ef55a61d0401816b8d1c3099bd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:07:05 +0000 Subject: [PATCH 044/605] build(deps): bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.11 to 8.0.0. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/22a9089034f40e5a961c8808d113e2c98fb63676...98357b18bf14b5342f975ff684046ec3b2a07725) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/update-colorschemes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index ca65c2a21..bceb8aef1 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -62,7 +62,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: title: Update iTerm2 colorschemes base: main From 1a2b3c165ac049ded7c893f23ea5ee1205bd35d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 15:31:44 -0800 Subject: [PATCH 045/605] terminal/tmux: layoutChanged handling --- src/terminal/tmux/viewer.zig | 436 ++++++++++++++++++++++++++++------- 1 file changed, 356 insertions(+), 80 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 3b401f44e..b8579d1d5 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -341,10 +341,20 @@ pub const Viewer = struct { return self.defunct(); }, + // Layout changed of a single window. + .layout_change => |info| self.layoutChanged( + info.window_id, + info.layout, + ) catch { + // Note: in the future, we can probably handle a failure + // here with a fallback to remove this one window, list + // windows again, and try again. + log.warn("failed to handle layout change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .layout_change, - .window_add, - => &.{}, + .window_add => &.{}, // The active pane changed. We don't care about this because // we handle our own focus. @@ -367,6 +377,164 @@ pub const Viewer = struct { }; } + /// When the layout changes for a single window, a pane may be added + /// or removed that we've never seen, in addition to the layout itself + /// physically changing. + /// + /// To handle this, its similar to list-windows except we expect the + /// window to already exist. We update the layout, do the initLayout + /// call for any diffs, setup commands to capture any new panes, + /// prune any removed panes. + fn layoutChanged( + self: *Viewer, + window_id: usize, + layout_str: []const u8, + ) ![]const Action { + // Find the window this layout change is for. + const window: *Window = window: for (self.windows.items) |*w| { + if (w.id == window_id) break :window w; + } else { + log.info("layout change for unknown window id={}", .{window_id}); + return &.{}; + }; + + // Clear our prior window arena and setup our layout + window.layout = layout: { + var arena = window.layout_arena.promote(self.alloc); + defer window.layout_arena = arena.state; + _ = arena.reset(.retain_capacity); + break :layout Layout.parseWithChecksum( + arena.allocator(), + layout_str, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ window_id, layout_str }, + ); + return err; + }; + }; + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Reset our arena so we can build up actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Our initial action is to definitely let the caller know that + // some windows changed. + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + + // Sync up our panes + try self.syncLayouts(self.windows.items); + + // If our command queue was empty and now its not we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty and !self.command_queue.empty()) { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + try actions.append(arena_alloc, action); + } + + return actions.items; + } + + fn syncLayouts( + self: *Viewer, + windows: []const Window, + ) !void { + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + // Clear out all the new panes. + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } + panes.deinit(self.alloc); + } + for (windows) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + window.layout, + ); + + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Ensure we can add the windows + try self.windows.ensureTotalCapacity(self.alloc, windows.len); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, + }); + } + } + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + + // Replace our window list if it changed. We assume it didn't + // change if our pointer is pointing to the same data. + if (windows.ptr != self.windows.items.ptr) { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.clearRetainingCapacity(); + self.windows.appendSliceAssumeCapacity(windows); + } + + // Replace our panes + { + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. + self.panes.deinit(self.alloc); + self.panes = panes; + } + } + /// When a session changes, we have to basically reset our whole state. /// To do this, we emit an empty windows event (so callers can clear all /// windows), reset ourself, and start all over. @@ -499,7 +667,7 @@ pub const Viewer = struct { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; - errdefer windows.deinit(self.alloc); + defer windows.deinit(self.alloc); // Parse all our windows var it = std.mem.splitScalar(u8, content, '\n'); @@ -543,82 +711,8 @@ pub const Viewer = struct { // window changes. try actions.append(arena_alloc, .{ .windows = windows.items }); - // Go through the window layout and setup all our panes. We move - // this into a new panes map so that we can easily prune our old - // list. - var panes: PanesMap = .empty; - errdefer { - // Clear out all the new panes. - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - if (!self.panes.contains(kv.key_ptr.*)) { - kv.value_ptr.deinit(self.alloc); - } - } - panes.deinit(self.alloc); - } - for (windows.items) |window| try initLayout( - self.alloc, - &self.panes, - &panes, - window.layout, - ); - - // Build up the list of removed panes. - var removed: std.ArrayList(usize) = removed: { - var removed: std.ArrayList(usize) = .empty; - errdefer removed.deinit(self.alloc); - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| { - if (panes.contains(kv.key_ptr.*)) continue; - try removed.append(self.alloc, kv.key_ptr.*); - } - - break :removed removed; - }; - defer removed.deinit(self.alloc); - - // Get our list of added panes and setup our command queue - // to populate them. - // TODO: errdefer cleanup - { - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - const pane_id: usize = kv.key_ptr.*; - if (self.panes.contains(pane_id)) continue; - try self.queueCommands(&.{ - .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, - }); - } - } - - // No more errors after this point. We're about to replace all - // our owned state with our temporary state, and our errdefers - // above will double-free if there is an error. - errdefer comptime unreachable; - - // Replace our window list - for (self.windows.items) |*window| window.deinit(self.alloc); - self.windows.deinit(self.alloc); - self.windows = windows; - - // Replace our panes - { - // First remove our old panes - for (removed.items) |id| if (self.panes.fetchSwapRemove( - id, - )) |entry_const| { - var entry = entry_const; - entry.value.deinit(self.alloc); - }; - // We can now deinit self.panes because the existing - // entries are preserved. - self.panes.deinit(self.alloc); - self.panes = panes; - } + // Sync up our layouts. This will populate unknown panes, prune, etc. + try self.syncLayouts(windows.items); } fn receivedPaneHistory( @@ -1300,3 +1394,185 @@ test "initial flow" { }, }); } + +test "layout change" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.panes.count()); + try testing.expect(v.panes.contains(0)); + } + }).check, + }, + // Complete all capture-pane commands for pane 0 (primary and alternate) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Now send a layout_change that splits into two panes + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Should still have 1 window + try testing.expectEqual(1, v.windows.items.len); + // Should now have 2 panes (0 and 2) + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(2)); + // Commands should be queued for the new pane + try testing.expectEqual(4, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change does not return command when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send a layout_change that splits into two panes. + // This should NOT return a command action since queue was not empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + // Should not contain a command action + for (actions) |action| { + try testing.expect(action != .command); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change returns command when queue was empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send a layout_change that splits into two panes. + // This should return a command action since we're queuing commands + // for the new pane and the queue was empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 582ea5d84bab67a56f061dce22b46e56f223d1e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 17:15:23 -0800 Subject: [PATCH 046/605] terminal/tmux: window add --- src/terminal/tmux/viewer.zig | 148 ++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index b8579d1d5..c3860c6e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -353,8 +353,11 @@ pub const Viewer = struct { return self.defunct(); }, - // TODO: There's real logic to do for these. - .window_add => &.{}, + // A window was added to this session. + .window_add => |info| self.windowAdd(info.id) catch { + log.warn("failed to handle window add, becoming defunct", .{}); + return self.defunct(); + }, // The active pane changed. We don't care about this because // we handle our own focus. @@ -447,6 +450,40 @@ pub const Viewer = struct { return actions.items; } + /// When a window is added to the session, we need to refresh our window + /// list to get the new window's information. + fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + _ = window_id; // We refresh all windows via list-windows + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Queue list-windows to get the updated window list + try self.queueCommands(&.{.list_windows}); + + // If our command queue was empty and now it's not, we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty) { + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, action); + return actions.items; + } + + return &.{}; + } + fn syncLayouts( self: *Viewer, windows: []const Window, @@ -1576,3 +1613,110 @@ test "layout_change returns command when queue was empty" { }, }); } + +test "window_add queues list_windows when queue empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send window_add - should trigger list-windows command + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Command queue should have list_windows + try testing.expect(!v.command_queue.empty()); + try testing.expectEqual(1, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "window_add queues list_windows when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Queue should have capture-pane commands + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send window_add - should queue list-windows but NOT return command action + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Should not contain a command action since queue was not empty + for (actions) |action| { + try testing.expect(action != .command); + } + // But list_windows should be in the queue + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 4c30c5aa765c1c76c5c4c1b1285b66af61f1840a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:19:20 -0800 Subject: [PATCH 047/605] terminal/tmux: cleanup command queue logic --- src/terminal/tmux/viewer.zig | 220 +++++++++++++++++------------------ 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index c3860c6e4..306bcd69d 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -306,43 +306,73 @@ pub const Viewer = struct { // handle it by ignoring any command output. That's okay! assert(self.state == .command_queue); - return switch (n) { + // Clear our prior arena so it is ready to be used for any + // actions immediately. + { + var arena = self.action_arena.promote(self.alloc); + _ = arena.reset(.free_all); + self.action_arena = arena.state; + } + + // Setup our empty actions list that commands can populate. + var actions: std.ArrayList(Action) = .empty; + + // Track whether the in-flight command slot is available. Starts true + // if queue is empty (no command in flight). Set to true when a command + // completes (block_end/block_err) or the queue is reset (session_changed). + var command_consumed = self.command_queue.empty(); + + switch (n) { .enter => unreachable, - .exit => self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, - => |content, tag| self.receivedCommandOutput( - content, - tag == .block_err, - ) catch { - log.warn("failed to process command output, becoming defunct", .{}); - return self.defunct(); - }, - - .output => |out| output: { - self.receivedOutput( - out.pane_id, - out.data, - ) catch |err| { - log.warn( - "failed to process output for pane id={}: {}", - .{ out.pane_id, err }, - ); + => |content, tag| { + self.receivedCommandOutput( + &actions, + content, + tag == .block_err, + ) catch { + log.warn("failed to process command output, becoming defunct", .{}); + return self.defunct(); }; - break :output &.{}; + // Command is consumed since a block end/err is the output + // from a command. + command_consumed = true; + }, + + .output => |out| self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); }, // Session changed means we switched to a different tmux session. // We need to reset our state and start fresh with list-windows. - .session_changed => |info| self.sessionChanged(info.id) catch { - log.warn("failed to handle session change, becoming defunct", .{}); - return self.defunct(); + // This completely replaces the viewer, so treat it like a fresh start. + .session_changed => |info| { + self.sessionChanged( + &actions, + info.id, + ) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }; + + // Command is consumed because sessionChanged resets + // our entire viewer. + command_consumed = true; }, // Layout changed of a single window. .layout_change => |info| self.layoutChanged( + &actions, info.window_id, info.layout, ) catch { @@ -361,23 +391,53 @@ pub const Viewer = struct { // The active pane changed. We don't care about this because // we handle our own focus. - .window_pane_changed => &.{}, + .window_pane_changed => {}, // We ignore this one. It means a session was created or // destroyed. If it was our own session we will get an exit // notification very soon. If it is another session we don't // care. - .sessions_changed => &.{}, + .sessions_changed => {}, // We don't use window names for anything, currently. - .window_renamed => &.{}, + .window_renamed => {}, // This is for other clients, which we don't do anything about. // For us, we'll get `exit` or `session_changed`, respectively. .client_detached, .client_session_changed, - => &.{}, - }; + => {}, + } + + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. We only emit a + // command if a prior command was consumed (or never existed). + if (self.state == .command_queue and command_consumed) { + if (self.command_queue.first()) |next_command| { + // We should not have any commands, because our nextCommand + // always queues them. + if (comptime std.debug.runtime_safety) { + for (actions.items) |action| { + if (action == .command) assert(false); + } + } + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + next_command.formatCommand(&builder.writer) catch + return self.defunct(); + actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ) catch return self.defunct(); + } + } + + return actions.items; } /// When the layout changes for a single window, a pane may be added @@ -390,15 +450,16 @@ pub const Viewer = struct { /// prune any removed panes. fn layoutChanged( self: *Viewer, + actions: *std.ArrayList(Action), window_id: usize, layout_str: []const u8, - ) ![]const Action { + ) !void { // Find the window this layout change is for. const window: *Window = window: for (self.windows.items) |*w| { if (w.id == window_id) break :window w; } else { log.info("layout change for unknown window id={}", .{window_id}); - return &.{}; + return; }; // Clear our prior window arena and setup our layout @@ -418,70 +479,29 @@ pub const Viewer = struct { }; }; - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Reset our arena so we can build up actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); // Our initial action is to definitely let the caller know that // some windows changed. - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = self.windows.items }); // Sync up our panes try self.syncLayouts(self.windows.items); - - // If our command queue was empty and now its not we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty and !self.command_queue.empty()) { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - try actions.append(arena_alloc, action); - } - - return actions.items; } /// When a window is added to the session, we need to refresh our window /// list to get the new window's information. - fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + fn windowAdd( + self: *Viewer, + window_id: usize, + ) !void { _ = window_id; // We refresh all windows via list-windows - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Queue list-windows to get the updated window list try self.queueCommands(&.{.list_windows}); - - // If our command queue was empty and now it's not, we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty) { - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - _ = arena.reset(.free_all); - const arena_alloc = arena.allocator(); - - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - - var actions: std.ArrayList(Action) = .empty; - try actions.append(arena_alloc, action); - return actions.items; - } - - return &.{}; } fn syncLayouts( @@ -577,26 +597,26 @@ pub const Viewer = struct { /// windows), reset ourself, and start all over. fn sessionChanged( self: *Viewer, + actions: *std.ArrayList(Action), session_id: usize, - ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + ) (Allocator.Error || std.Io.Writer.Error)!void { // Build up a new viewer. Its the easiest way to reset ourselves. var replacement: Viewer = try .init(self.alloc); errdefer replacement.deinit(); + // Our actions must start out empty so we don't mix arenas + assert(actions.items.len == 0); + errdefer actions.* = .empty; + // Build actions: empty windows notification + list-windows command var arena = replacement.action_arena.promote(replacement.alloc); const arena_alloc = arena.allocator(); - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = &.{} }); - // Setup our command queue - try actions.appendSlice( - arena_alloc, - try replacement.enterCommandQueue( - arena_alloc, - .list_windows, - ), - ); + // Setup our command queue and put ourselves in the command queue + // state. + try replacement.queueCommands(&.{.list_windows}); + replacement.state = .command_queue; // Save arena state back before swap replacement.action_arena = arena.state; @@ -610,14 +630,14 @@ pub const Viewer = struct { self.session_id = session_id; assert(self.state == .command_queue); - return actions.items; } fn receivedCommandOutput( self: *Viewer, + actions: *std.ArrayList(Action), content: []const u8, is_err: bool, - ) ![]const Action { + ) !void { // Get the command we're expecting output for. We need to get the // non-pointer value because we are deleting it from the circular // buffer immediately. This shallow copy is all we need since @@ -636,7 +656,7 @@ pub const Viewer = struct { } else { // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); - return &.{}; + return; }; self.command_queue.deleteOldest(1); defer command.deinit(self.alloc); @@ -645,20 +665,15 @@ pub const Viewer = struct { // easily accumulate actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); - // Build up our actions to start with the next command if - // we have one. - var actions: std.ArrayList(Action) = .empty; - // Process our command switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, - &actions, + actions, content, ), @@ -674,23 +689,6 @@ pub const Viewer = struct { content, ), } - - // After processing commands, we add our next command to - // execute if we have one. We do this last because command - // processing may itself queue more commands. - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } - - // Our command processing should not change our state - assert(self.state == .command_queue); - - return actions.items; } fn receivedListWindows( From bf46c4ebe74d0e668762e84e690e86ca1389e486 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:49:03 -0800 Subject: [PATCH 048/605] terminal/tmux: many more output formats --- src/terminal/tmux/output.zig | 318 ++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 6 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index cff1a982d..02dca23e6 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -95,16 +95,107 @@ pub fn FormatStruct(comptime vars: []const Variable) type { /// a subset of them here that are relevant to the use case of implementing /// control mode for terminal emulators. pub const Variable = enum { + /// 1 if pane is in alternate screen. + alternate_on, + /// Saved cursor X in alternate screen. + alternate_saved_x, + /// Saved cursor Y in alternate screen. + alternate_saved_y, + /// 1 if bracketed paste mode is enabled. + bracketed_paste, + /// 1 if the cursor is blinking. + cursor_blinking, + /// Cursor colour in pane. Possible formats: + /// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, + /// `cyan`, `white`, `default`, `terminal`, or bright variants. + /// - 256 colors: `colour` where N is 0-255 (e.g., `colour100`). + /// - RGB hex: `#RRGGBB` (e.g., `#ff0000`). + /// - Empty string if unset. + cursor_colour, + /// Pane cursor flag. + cursor_flag, + /// Cursor shape in pane. Possible values: `block`, `underline`, `bar`, + /// or `default`. + cursor_shape, + /// Cursor X position in pane. + cursor_x, + /// Cursor Y position in pane. + cursor_y, + /// 1 if focus reporting is enabled. + focus_flag, + /// Pane insert flag. + insert_flag, + /// Pane keypad cursor flag. + keypad_cursor_flag, + /// Pane keypad flag. + keypad_flag, + /// Pane mouse all flag. + mouse_all_flag, + /// Pane mouse any flag. + mouse_any_flag, + /// Pane mouse button flag. + mouse_button_flag, + /// Pane mouse SGR flag. + mouse_sgr_flag, + /// Pane mouse standard flag. + mouse_standard_flag, + /// Pane mouse UTF-8 flag. + mouse_utf8_flag, + /// Pane origin flag. + origin_flag, + /// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`). + pane_id, + /// Pane tab positions as a comma-separated list of 0-indexed column + /// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set. + pane_tabs, + /// Bottom of scroll region in pane. + scroll_region_lower, + /// Top of scroll region in pane. + scroll_region_upper, + /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, + /// Width of window. window_width, + /// Height of window. window_height, + /// Window layout description, ignoring zoomed window panes. Format is + /// `,` where checksum is a 4-digit hex CRC16 and layout + /// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal + /// splits and `[...]` for vertical splits. window_layout, + /// Pane wrap flag. + wrap_flag, /// Parse the given string value into the appropriate resulting /// type for this variable. pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { return switch (self) { + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => std.mem.eql(u8, value, "1"), + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + => try std.fmt.parseInt(usize, value, 10), .session_id => if (value.len >= 2 and value[0] == '$') try std.fmt.parseInt(usize, value[1..], 10) else @@ -113,24 +204,105 @@ pub const Variable = enum { try std.fmt.parseInt(usize, value[1..], 10) else return error.FormatError, + .pane_id => if (value.len >= 2 and value[0] == '%') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, .window_width => try std.fmt.parseInt(usize, value, 10), .window_height => try std.fmt.parseInt(usize, value, 10), - .window_layout => value, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => value, }; } /// The type of the parsed value for this variable type. pub fn Type(comptime self: Variable) type { return switch (self) { - .session_id => usize, - .window_id => usize, - .window_width => usize, - .window_height => usize, - .window_layout => []const u8, + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => bool, + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + .session_id, + .window_id, + .pane_id, + .window_width, + .window_height, + => usize, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => []const u8, }; } }; +test "parse alternate_on" { + try testing.expectEqual(true, try Variable.parse(.alternate_on, "1")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "0")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "true")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "yes")); +} + +test "parse alternate_saved_x" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_x, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_x, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_x, "abc")); +} + +test "parse alternate_saved_y" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_y, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_y, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_y, "abc")); +} + +test "parse cursor_x" { + try testing.expectEqual(0, try Variable.parse(.cursor_x, "0")); + try testing.expectEqual(79, try Variable.parse(.cursor_x, "79")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_x, "abc")); +} + +test "parse cursor_y" { + try testing.expectEqual(0, try Variable.parse(.cursor_y, "0")); + try testing.expectEqual(23, try Variable.parse(.cursor_y, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_y, "abc")); +} + +test "parse scroll_region_upper" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_upper, "0")); + try testing.expectEqual(5, try Variable.parse(.scroll_region_upper, "5")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_upper, "abc")); +} + +test "parse scroll_region_lower" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_lower, "0")); + try testing.expectEqual(23, try Variable.parse(.scroll_region_lower, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_lower, "abc")); +} + test "parse session id" { try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); @@ -176,6 +348,140 @@ test "parse window layout" { try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); } +test "parse cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "true")); +} + +test "parse insert_flag" { + try testing.expectEqual(true, try Variable.parse(.insert_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "true")); +} + +test "parse keypad_cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "true")); +} + +test "parse keypad_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "true")); +} + +test "parse mouse_any_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_any_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "true")); +} + +test "parse mouse_button_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_button_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "true")); +} + +test "parse mouse_sgr_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_sgr_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "true")); +} + +test "parse mouse_standard_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_standard_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "true")); +} + +test "parse mouse_utf8_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_utf8_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "true")); +} + +test "parse wrap_flag" { + try testing.expectEqual(true, try Variable.parse(.wrap_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "true")); +} + +test "parse bracketed_paste" { + try testing.expectEqual(true, try Variable.parse(.bracketed_paste, "1")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "0")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "true")); +} + +test "parse cursor_blinking" { + try testing.expectEqual(true, try Variable.parse(.cursor_blinking, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "true")); +} + +test "parse focus_flag" { + try testing.expectEqual(true, try Variable.parse(.focus_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "true")); +} + +test "parse mouse_all_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_all_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "true")); +} + +test "parse origin_flag" { + try testing.expectEqual(true, try Variable.parse(.origin_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "true")); +} + +test "parse pane_id" { + try testing.expectEqual(42, try Variable.parse(.pane_id, "%42")); + try testing.expectEqual(0, try Variable.parse(.pane_id, "%0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "%")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.pane_id, "%abc")); +} + +test "parse cursor_colour" { + try testing.expectEqualStrings("red", try Variable.parse(.cursor_colour, "red")); + try testing.expectEqualStrings("#ff0000", try Variable.parse(.cursor_colour, "#ff0000")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_colour, "")); +} + +test "parse cursor_shape" { + try testing.expectEqualStrings("block", try Variable.parse(.cursor_shape, "block")); + try testing.expectEqualStrings("underline", try Variable.parse(.cursor_shape, "underline")); + try testing.expectEqualStrings("bar", try Variable.parse(.cursor_shape, "bar")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_shape, "")); +} + +test "parse pane_tabs" { + try testing.expectEqualStrings("0,8,16,24", try Variable.parse(.pane_tabs, "0,8,16,24")); + try testing.expectEqualStrings("", try Variable.parse(.pane_tabs, "")); + try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); From 572c06f67def29f1b1f344a7ffe914078967a1d6 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 4 Dec 2025 10:09:41 -0500 Subject: [PATCH 049/605] font/coretext: Use positions to fix x/y offsets --- pkg/macos/text/run.zig | 13 ++++++ src/font/shaper/coretext.zig | 80 ++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig index 2895bfe34..a34cd5307 100644 --- a/pkg/macos/text/run.zig +++ b/pkg/macos/text/run.zig @@ -106,6 +106,19 @@ pub const Run = opaque { pub fn getStatus(self: *Run) Status { return @bitCast(c.CTRunGetStatus(@ptrCast(self))); } + + pub fn getAttributes(self: *Run) *foundation.Dictionary { + return @ptrCast(@constCast(c.CTRunGetAttributes(@ptrCast(self)))); + } + + pub fn getFont(self: *Run) ?*text.Font { + const attrs = self.getAttributes(); + const font_ptr = attrs.getValue(*const anyopaque, c.kCTFontAttributeName); + if (font_ptr) |ptr| { + return @ptrCast(@constCast(ptr)); + } + return null; + } }; /// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 97cb5cd89..498b45799 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -377,11 +377,21 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current offsets within a single cell. + // This keeps track of the current offsets within a run. + var run_offset: struct { + x: f64 = 0, + y: f64 = 0, + } = .{}; + + // This keeps track of the current offsets within a cell. var cell_offset: struct { cluster: u32 = 0, x: f64 = 0, y: f64 = 0, + + // For debugging positions, turn this on: + start_index: usize = 0, + end_index: usize = 0, } = .{}; // Clear our cell buf and make sure we have enough room for the whole @@ -411,15 +421,18 @@ pub const Shaper = struct { // Get our glyphs and positions const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc); const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc); + const positions = ctrun.getPositionsPtr() orelse try ctrun.getPositions(alloc); const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc); assert(glyphs.len == advances.len); + assert(glyphs.len == positions.len); assert(glyphs.len == indices.len); for ( glyphs, advances, + positions, indices, - ) |glyph, advance, index| { + ) |glyph, advance, position, index| { // Our cluster is also our cell X position. If the cluster changes // then we need to reset our current cell offsets. const cluster = state.codepoints.items[index].cluster; @@ -431,20 +444,71 @@ pub const Shaper = struct { // wait for that. if (cell_offset.cluster > cluster) break :pad; - cell_offset = .{ .cluster = cluster }; + cell_offset = .{ + .cluster = cluster, + .x = run_offset.x, + .y = run_offset.y, + + // For debugging positions, turn this on: + .start_index = index, + .end_index = index, + }; + } else { + if (index < cell_offset.start_index) { + cell_offset.start_index = index; + } + if (index > cell_offset.end_index) { + cell_offset.end_index = index; + } + } + + const x_offset = position.x - cell_offset.x; + const y_offset = position.y - cell_offset.y; + + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y - cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + var allocating = std.Io.Writer.Allocating.init(alloc); + const writer = &allocating.writer; + const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + const formatted_cps = try allocating.toOwnedSlice(); + + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); } self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), - .x_offset = @intFromFloat(@round(cell_offset.x)), - .y_offset = @intFromFloat(@round(cell_offset.y)), + .x_offset = @intFromFloat(@round(x_offset)), + .y_offset = @intFromFloat(@round(y_offset)), .glyph_index = glyph, }); - // Add our advances to keep track of our current cell offsets. + // Add our advances to keep track of our run offsets. // Advances apply to the NEXT cell. - cell_offset.x += advance.width; - cell_offset.y += advance.height; + run_offset.x += advance.width; + run_offset.y += advance.height; } } From 58000f5821040060fe8c07c97073bff80886ebd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 09:28:52 -0800 Subject: [PATCH 050/605] terminal/tmux: build up pane states --- src/terminal/tmux/viewer.zig | 372 ++++++++++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 306bcd69d..5384e293f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,7 +3,9 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const size = @import("../size.zig"); const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const CursorStyle = @import("../cursor.zig").Style; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); @@ -551,9 +553,11 @@ pub const Viewer = struct { // TODO: errdefer cleanup { var panes_it = panes.iterator(); + var added: bool = false; while (panes_it.next()) |kv| { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; + added = true; try self.queueCommands(&.{ .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, @@ -561,6 +565,10 @@ pub const Viewer = struct { .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } + + // If we added any panes, then we also want to resync the pane + // state (terminal modes and cursor positions and so on). + if (added) try self.queueCommands(&.{.pane_state}); } // No more errors after this point. We're about to replace all @@ -671,6 +679,8 @@ pub const Viewer = struct { switch (command) { .user => {}, + .pane_state => try self.receivedPaneState(content), + .list_windows => try self.receivedListWindows( arena_alloc, actions, @@ -750,6 +760,137 @@ pub const Viewer = struct { try self.syncLayouts(windows.items); } + fn receivedPaneState( + self: *Viewer, + content: []const u8, + ) !void { + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + + const data = output.parseFormatStruct( + Format.list_panes.Struct(), + line, + Format.list_panes.delim, + ) catch |err| { + log.info("failed to parse list-panes line: {s}", .{line}); + return err; + }; + + // Get the pane for this ID + const entry = self.panes.getEntry(data.pane_id) orelse { + log.info("received pane state for untracked pane id={}", .{data.pane_id}); + continue; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + // Determine which screen to use based on alternate_on + const screen_key: ScreenSet.Key = if (data.alternate_on) .alternate else .primary; + + // Set cursor position on the appropriate screen (tmux uses 0-based) + if (t.screens.get(screen_key)) |screen| { + cursor: { + const cursor_x = std.math.cast( + size.CellCountInt, + data.cursor_x, + ) orelse break :cursor; + const cursor_y = std.math.cast( + size.CellCountInt, + data.cursor_y, + ) orelse break :cursor; + if (cursor_x >= screen.pages.cols or + cursor_y >= screen.pages.rows) break :cursor; + screen.cursorAbsolute(cursor_x, cursor_y); + } + + // Set cursor shape on this screen + if (data.cursor_shape.len > 0) { + if (std.mem.eql(u8, data.cursor_shape, "block")) { + screen.cursor.cursor_style = .block; + } else if (std.mem.eql(u8, data.cursor_shape, "underline")) { + screen.cursor.cursor_style = .underline; + } else if (std.mem.eql(u8, data.cursor_shape, "bar")) { + screen.cursor.cursor_style = .bar; + } + } + // "default" or unknown: leave as-is + } + + // Set alternate screen saved cursor position + if (t.screens.get(.alternate)) |alt_screen| cursor: { + const alt_x = std.math.cast( + size.CellCountInt, + data.alternate_saved_x, + ) orelse break :cursor; + const alt_y = std.math.cast( + size.CellCountInt, + data.alternate_saved_y, + ) orelse break :cursor; + + // If our coordinates are outside our screen we ignore it. + // tmux actually sends MAX_INT for when there isn't a set + // cursor position, so this isn't theoretical. + if (alt_x >= alt_screen.pages.cols or + alt_y >= alt_screen.pages.rows) break :cursor; + + alt_screen.cursorAbsolute(alt_x, alt_y); + } + + // Set cursor visibility + t.modes.set(.cursor_visible, data.cursor_flag); + + // Set cursor blinking + t.modes.set(.cursor_blinking, data.cursor_blinking); + + // Terminal modes + t.modes.set(.insert, data.insert_flag); + t.modes.set(.wraparound, data.wrap_flag); + t.modes.set(.keypad_keys, data.keypad_flag); + t.modes.set(.cursor_keys, data.keypad_cursor_flag); + t.modes.set(.origin, data.origin_flag); + + // Mouse modes + t.modes.set(.mouse_event_any, data.mouse_all_flag); + t.modes.set(.mouse_event_button, data.mouse_any_flag); + t.modes.set(.mouse_event_normal, data.mouse_button_flag); + t.modes.set(.mouse_event_x10, data.mouse_standard_flag); + t.modes.set(.mouse_format_utf8, data.mouse_utf8_flag); + t.modes.set(.mouse_format_sgr, data.mouse_sgr_flag); + + // Focus and bracketed paste + t.modes.set(.focus_event, data.focus_flag); + t.modes.set(.bracketed_paste, data.bracketed_paste); + + // Scroll region (tmux uses 0-based values) + scroll: { + const scroll_top = std.math.cast( + size.CellCountInt, + data.scroll_region_upper, + ) orelse break :scroll; + const scroll_bottom = std.math.cast( + size.CellCountInt, + data.scroll_region_lower, + ) orelse break :scroll; + t.scrolling_region.top = scroll_top; + t.scrolling_region.bottom = scroll_bottom; + } + + // Tab stops - parse comma-separated list and set + t.tabstops.reset(0); // Clear all tabstops first + if (data.pane_tabs.len > 0) { + var tabs_it = std.mem.splitScalar(u8, data.pane_tabs, ','); + while (tabs_it.next()) |tab_str| { + const col = std.fmt.parseInt(usize, tab_str, 10) catch continue; + const col_cell = std.math.cast(size.CellCountInt, col) orelse continue; + if (col_cell >= t.cols) continue; + t.tabstops.set(col_cell); + } + } + } + } + fn receivedPaneHistory( self: *Viewer, screen_key: ScreenSet.Key, @@ -983,6 +1124,10 @@ const Command = union(enum) { /// Capture visible area for the given pane ID. pane_visible: CapturePane, + /// Capture the pane terminal state as best we can. The pane ID(s) + /// are part of the output so we can map it back to our panes. + pane_state, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -997,6 +1142,7 @@ const Command = union(enum) { .list_windows, .pane_history, .pane_visible, + .pane_state, => {}, .user => |v| alloc.free(v), }; @@ -1045,6 +1191,11 @@ const Command = union(enum) { }, ), + .pane_state => try writer.writeAll(std.fmt.comptimePrint( + "list-panes -F '{s}'\n", + .{comptime Format.list_panes.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1059,6 +1210,45 @@ const Format = struct { /// guaranteed to not appear in any of the variable outputs. delim: u8, + const list_panes: Format = .{ + .delim = ';', + .vars = &.{ + .pane_id, + // Cursor position & appearance + .cursor_x, + .cursor_y, + .cursor_flag, + .cursor_shape, + .cursor_colour, + .cursor_blinking, + // Alternate screen + .alternate_on, + .alternate_saved_x, + .alternate_saved_y, + // Terminal modes + .insert_flag, + .wrap_flag, + .keypad_flag, + .keypad_cursor_flag, + .origin_flag, + // Mouse modes + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .mouse_sgr_flag, + // Focus & special features + .focus_flag, + .bracketed_paste, + // Scroll region + .scroll_region_upper, + .scroll_region_lower, + // Tab stops + .pane_tabs, + }, + }; + const list_windows: Format = .{ .delim = ' ', .vars = &.{ @@ -1461,6 +1651,8 @@ test "layout change" { }).check, }, // Complete all capture-pane commands for pane 0 (primary and alternate) + // plus pane_state + .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, @@ -1482,8 +1674,8 @@ test "layout change" { try testing.expectEqual(2, v.panes.count()); try testing.expect(v.panes.contains(0)); try testing.expect(v.panes.contains(2)); - // Commands should be queued for the new pane - try testing.expectEqual(4, v.command_queue.len()); + // Commands should be queued for the new pane (4 capture-pane + 1 pane_state) + try testing.expectEqual(5, v.command_queue.len()); } }).check, }, @@ -1718,3 +1910,179 @@ test "window_add queues list_windows when queue not empty" { }, }); } + +test "two pane flow with pane state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial block_end from attach + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Session changed notification + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 0, + .name = "0", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, v.session_id); + } + }).check, + }, + // list-windows output with 2 panes in a vertical split + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + const window = v.windows.items[0]; + try testing.expectEqual(0, window.id); + try testing.expectEqual(165, window.width); + try testing.expectEqual(79, window.height); + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(4)); + } + }).check, + }, + // capture-pane pane 0 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + \\prompt % + , + } }, + }, + // capture-pane pane 0 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + // History has 2 lines with "prompt %" (padded to screen width) + try testing.expect(std.mem.containsAtLeast(u8, str, 2, "prompt %")); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + } + }).check, + }, + // capture-pane pane 0 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 0 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + }, + // capture-pane pane 4 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + // Active screen starts with "prompt %" at beginning + try testing.expect(std.mem.startsWith(u8, str, "prompt %")); + } + } + }).check, + }, + // capture-pane pane 4 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // list-panes output with terminal state + .{ + .input = .{ .tmux = .{ + .block_end = + \\%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + \\%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Pane 0: cursor at (42, 0), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(42, screen.cursor.x); + try testing.expectEqual(0, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + // Pane 4: cursor at (10, 5), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(10, screen.cursor.x); + try testing.expectEqual(5, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 29bb18d8cd20ea092d4023a89563bfc9f0f90fbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:33:56 -0800 Subject: [PATCH 051/605] terminal/tmux: grab tmux version on startup --- src/terminal/tmux/output.zig | 11 ++++ src/terminal/tmux/viewer.zig | 120 ++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index 02dca23e6..6b8073e44 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -154,6 +154,8 @@ pub const Variable = enum { scroll_region_upper, /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Server version (e.g., `3.5a`). + version, /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, /// Width of window. @@ -213,6 +215,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => value, }; @@ -253,6 +256,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => []const u8, }; @@ -482,6 +486,13 @@ test "parse pane_tabs" { try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); } +test "parse version" { + try testing.expectEqualStrings("3.5a", try Variable.parse(.version, "3.5a")); + try testing.expectEqualStrings("3.5", try Variable.parse(.version, "3.5")); + try testing.expectEqualStrings("next-3.5", try Variable.parse(.version, "next-3.5")); + try testing.expectEqualStrings("", try Variable.parse(.version, "")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5384e293f..002f85c5f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -61,6 +61,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The tmux server version string (e.g., "3.5a"). We capture this + /// on startup because it will allow us to change behavior between + /// versions as necessary. + tmux_version: []const u8, + /// The list of commands we've sent that we want to send and wait /// for a response for. We only send one command at a time just /// to avoid any possible confusion around ordering. @@ -168,6 +173,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .tmux_version = "", .command_queue = command_queue, .windows = .empty, .panes = .empty, @@ -191,6 +197,9 @@ pub const Viewer = struct { while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); self.panes.deinit(self.alloc); } + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } self.action_arena.promote(self.alloc).deinit(); } @@ -273,9 +282,10 @@ pub const Viewer = struct { var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; _ = arena.reset(.free_all); + return self.enterCommandQueue( arena.allocator(), - .list_windows, + &.{ .tmux_version, .list_windows }, ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); @@ -626,6 +636,9 @@ pub const Viewer = struct { try replacement.queueCommands(&.{.list_windows}); replacement.state = .command_queue; + // Transfer preserved version to replacement + replacement.tmux_version = try replacement.alloc.dupe(u8, self.tmux_version); + // Save arena state back before swap replacement.action_arena = arena.state; @@ -698,9 +711,33 @@ pub const Viewer = struct { cap.id, content, ), + + .tmux_version => try self.receivedTmuxVersion(content), } } + fn receivedTmuxVersion( + self: *Viewer, + content: []const u8, + ) !void { + const line = std.mem.trim(u8, content, " \t\r\n"); + if (line.len == 0) return; + + const data = output.parseFormatStruct( + Format.tmux_version.Struct(), + line, + Format.tmux_version.delim, + ) catch |err| { + log.info("failed to parse tmux version: {s}", .{line}); + return err; + }; + + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } + self.tmux_version = try self.alloc.dupe(u8, data.version); + } + fn receivedListWindows( self: *Viewer, arena_alloc: Allocator, @@ -1031,22 +1068,23 @@ pub const Viewer = struct { } /// Enters the command queue state from any other state, queueing - /// the command and returning an action to execute the first command. + /// the commands and returning an action to execute the first command. fn enterCommandQueue( self: *Viewer, arena_alloc: Allocator, - command: Command, + commands: []const Command, ) Allocator.Error![]const Action { assert(self.state != .command_queue); + assert(commands.len > 0); // Build our command string to send for the action. var builder: std.Io.Writer.Allocating = .init(arena_alloc); - command.formatCommand(&builder.writer) catch return error.OutOfMemory; + commands[0].formatCommand(&builder.writer) catch return error.OutOfMemory; const action: Action = .{ .command = builder.writer.buffered() }; - // Add our command - try self.command_queue.ensureUnusedCapacity(self.alloc, 1); - self.command_queue.appendAssumeCapacity(command); + // Add our commands + try self.command_queue.ensureUnusedCapacity(self.alloc, commands.len); + for (commands) |cmd| self.command_queue.appendAssumeCapacity(cmd); // Move into the command queue state self.state = .command_queue; @@ -1128,6 +1166,9 @@ const Command = union(enum) { /// are part of the output so we can map it back to our panes. pane_state, + /// Get the tmux server version. + tmux_version, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -1143,6 +1184,7 @@ const Command = union(enum) { .pane_history, .pane_visible, .pane_state, + .tmux_version, => {}, .user => |v| alloc.free(v), }; @@ -1196,6 +1238,11 @@ const Command = union(enum) { .{comptime Format.list_panes.comptimeFormat()}, )), + .tmux_version => try writer.writeAll(std.fmt.comptimePrint( + "display-message -p '{s}'\n", + .{comptime Format.tmux_version.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1260,6 +1307,11 @@ const Format = struct { }, }; + const tmux_version: Format = .{ + .delim = ' ', + .vars = &.{.version}, + }; + /// The format string, available at comptime. pub fn comptimeFormat(comptime self: Format) []const u8 { return output.comptimeFormat(self.vars, self.delim); @@ -1378,6 +1430,11 @@ test "session changed resets state" { .id = 1, .name = "first", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive window layout with two panes (same format as "initial flow" test) @@ -1393,10 +1450,11 @@ test "session changed resets state" { try testing.expectEqual(1, v.session_id); try testing.expectEqual(1, v.windows.items.len); try testing.expectEqual(2, v.panes.count()); + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, - // Now session changes - should reset everything + // Now session changes - should reset everything but keep version .{ .input = .{ .tmux = .{ .session_changed = .{ .id = 2, @@ -1420,6 +1478,8 @@ test "session changed resets state" { try testing.expectEqual(0, v.windows.items.len); // Old panes should be cleared try testing.expectEqual(0, v.panes.count()); + // Version should still be preserved + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, @@ -1460,13 +1520,23 @@ test "initial flow" { .id = 42, .name = "main", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(42, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, .{ .input = .{ .tmux = .{ .block_end = @@ -1632,6 +1702,11 @@ test "layout change" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1698,6 +1773,11 @@ test "layout_change does not return command when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1754,6 +1834,11 @@ test "layout_change returns command when queue was empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1816,6 +1901,11 @@ test "window_add queues list_windows when queue empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1872,6 +1962,11 @@ test "window_add queues list_windows when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1924,13 +2019,18 @@ test "two pane flow with pane state" { .id = 0, .name = "0", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(0, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, // list-windows output with 2 panes in a vertical split .{ .input = .{ .tmux = .{ From b3e7c922630398457a301c3fcd2921bdc282e24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:34:35 -0800 Subject: [PATCH 052/605] fmt --- src/terminal/tmux/control.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 79ed530ec..dbc64b340 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -555,7 +555,7 @@ pub const Notification = union(enum) { try writer.writeAll(" }"); } } - }; +}; test "tmux begin/end empty" { const testing = std.testing; From 37f467c023e901c9125e940fdc379d1bf36c1d06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:37:48 -0800 Subject: [PATCH 053/605] terminal/tmux: docs --- src/terminal/tmux/viewer.zig | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 002f85c5f..0fcaaf207 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -51,6 +51,110 @@ const COMMAND_QUEUE_INITIAL = 8; /// /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. +/// +/// ## Viewer Lifecycle +/// +/// The viewer progresses through several states from initial connection +/// to steady-state operation. Here is the full flow: +/// +/// ``` +/// ┌─────────────────────────────────────────────┐ +/// │ TMUX CONTROL MODE START │ +/// │ (DCS 1000p received by host) │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_block │ +/// │ │ +/// │ Wait for initial %begin/%end block from │ +/// │ tmux. This is the response to the initial │ +/// │ command (e.g., "attach -t 0"). │ +/// └─────────────────┬───────────────────────────┘ +/// │ %end / %error +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_session │ +/// │ │ +/// │ Wait for %session-changed notification │ +/// │ to get the initial session ID. │ +/// └─────────────────┬───────────────────────────┘ +/// │ %session-changed +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ command_queue │ +/// │ │ +/// │ Main operating state. Process commands │ +/// │ sequentially and handle notifications. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ┌───────────────────────────┼───────────────────────────┐ +/// │ │ │ +/// ▼ ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ +/// │ tmux_version │ │ list_windows │ │ %output / %layout- │ +/// │ │ │ │ │ change / etc. │ +/// │ Query tmux version for │ │ Get all windows in the │ │ │ +/// │ compatibility checks. │ │ current session. │ │ Handle live updates │ +/// └──────────────────────────┘ └────────────┬─────────────┘ │ from tmux server. │ +/// │ └────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ syncLayouts │ +/// │ │ +/// │ For each window, parse layout and sync │ +/// │ panes. New panes trigger capture commands. │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ┌───────────────────────────┴───────────────────────────┐ +/// │ For each new pane: │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (primary screen) │ │ (primary screen) │ +/// │ │ │ │ +/// │ Capture scrollback │ │ Capture visible area │ +/// │ history into terminal. │ │ into terminal. │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (alternate screen) │ │ (alternate screen) │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// └───────────────────────────┬───────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ pane_state │ +/// │ │ +/// │ Query cursor position, cursor style, │ +/// │ and alternate screen mode for all panes. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ READY FOR OPERATION │ +/// │ │ +/// │ Panes are populated with content. The │ +/// │ viewer handles %output for live updates, │ +/// │ %layout-change for pane changes, and │ +/// │ %session-changed for session switches. │ +/// └─────────────────────────────────────────────┘ +/// ``` +/// +/// ## Error Handling +/// +/// At any point, if an unrecoverable error occurs or tmux sends `%exit`, +/// the viewer transitions to the `defunct` state and emits an `.exit` action. +/// +/// ## Session Changes +/// +/// When `%session-changed` is received during `command_queue` state, the +/// viewer resets itself completely: clears all windows/panes, emits an +/// empty windows action, and restarts the `list_windows` flow for the new +/// session. +/// pub const Viewer = struct { /// Allocator used for all internal state. alloc: Allocator, From 05c704b2471ca43e8c3fa3616121824f5c37c65b Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 8 Dec 2025 10:50:46 -0600 Subject: [PATCH 054/605] build: skip git version detection when used as dependency Detect if ghostty is being built as a dependency by comparing the build root with ghostty's source directory. When used as a dependency, skip git detection entirely and use the version from build.zig.zon. This fixes build failures when downstream projects have git tags that don't match ghostty's version format. Previously, ghostty would read the downstream project's git tags and panic at Config.zig:246 with "tagged releases must be in vX.Y.Z format matching build.zig". --- build.zig | 6 ++++++ src/build/Config.zig | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/build.zig b/build.zig index 5fd611b6c..472c3957a 100644 --- a/build.zig +++ b/build.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); + const appVersion = @import("build.zig.zon").version; const minimumZigVersion = @import("build.zig.zon").minimum_zig_version; @@ -317,3 +318,8 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } + +/// Marker used by Config.zig to detect if ghostty is the build root. +/// This avoids running logic such as Git tag checking when Ghostty +/// is used as a dependency. +pub const _ghostty_build_root = true; diff --git a/src/build/Config.zig b/src/build/Config.zig index e88213d71..981cd7de5 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -218,6 +218,22 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { try std.SemanticVersion.parse(v) else version: { const app_version = try std.SemanticVersion.parse(appVersion); + + // Detect if ghostty is being built as a dependency by checking if the + // build root has our marker. When used as a dependency, we skip git + // detection entirely to avoid reading the downstream project's git state. + const is_dependency = !@hasDecl( + @import("root"), + "_ghostty_build_root", + ); + if (is_dependency) { + break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; + } + // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { // If Git isn't available we just make an unknown dev version. From 7642b8bec4294ecdaf9184fd69ed761a7e2aa422 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 13:13:35 -0800 Subject: [PATCH 055/605] build: highway system integration should default to false --- src/build/SharedDeps.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index e530e4885..5e2cd40b9 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,7 +719,7 @@ pub fn addSimd( } // Highway - if (b.systemIntegrationOption("highway", .{})) { + if (b.systemIntegrationOption("highway", .{ .default = false })) { m.linkSystemLibrary("libhwy", dynamic_link_opts); } else { if (b.lazyDependency("highway", .{ From 93d77ae43672dd1c8017a63cf93516b9157054fe Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 16 Nov 2025 02:24:10 -0800 Subject: [PATCH 056/605] Always use overlay scroller, flash when mouse moved --- macos/Sources/Ghostty/SurfaceScrollView.swift | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 4e81eda14..157136136 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -34,10 +34,15 @@ class SurfaceScrollView: NSView { scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true + // Always use the overlay style. See mouseMoved for how we make + // it usable without a scroll wheel or gestures. + scrollView.scrollerStyle = .overlay // hide default background to show blur effect properly scrollView.drawsBackground = false - // don't let the content view clip it's subviews, to enable the + // don't let the content view clip its subviews, to enable the // surface to draw the background behind non-overlay scrollers + // (we currently only use overlay scrollers, but might as well + // configure the views correctly in case we change our mind) scrollView.contentView.clipsToBounds = false // The document view is what the scrollview is actually going @@ -107,7 +112,10 @@ class SurfaceScrollView: NSView { observers.append(NotificationCenter.default.addObserver( forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, - queue: .main + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil ) { [weak self] _ in self?.handleScrollerStyleChange() }) @@ -176,10 +184,10 @@ class SurfaceScrollView: NSView { private func synchronizeAppearance() { let scrollbarConfig = surfaceView.derivedConfig.scrollbar scrollView.hasVerticalScroller = scrollbarConfig != .never - scrollView.verticalScroller?.controlSize = .small let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor // Make sure the scroller’s appearance matches the surface's background color. scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua) + updateTrackingAreas() } /// Positions the surface view to fill the currently visible rectangle. @@ -240,6 +248,7 @@ class SurfaceScrollView: NSView { /// Handles scrollbar style changes private func handleScrollerStyleChange() { + scrollView.scrollerStyle = .overlay synchronizeCoreSurface() } @@ -350,4 +359,32 @@ class SurfaceScrollView: NSView { } return contentHeight } + + // MARK: Mouse events + + override func mouseMoved(with: NSEvent) { + // When the OS preferred style is .legacy, the user should be able to + // click and drag the scroller without using scroll wheels or gestures, + // so we flash it when the mouse is moved over the scrollbar area. + guard NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + super.updateTrackingAreas() + + // Our tracking area is the scroller frame + guard let scroller = scrollView.verticalScroller else { return } + addTrackingArea(NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil)) + } } From c0951ce6d8887ea81e29b3485dc828d3f9191601 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Sat, 6 Dec 2025 20:44:30 +0000 Subject: [PATCH 057/605] macOS: fix tab context menu opens on macOS 26 with titlebar tabs --- .../TitlebarTabsTahoeTerminalWindow.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 7ce138c2a..802e98dc1 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,6 +8,9 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() + + /// Tb bar view for event routing + private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. @@ -67,6 +70,30 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } + + override func sendEvent(_ event: NSEvent) { + guard let tabBarView, viewModel.hasTabBar else { + super.sendEvent(event) + return + } + + let isRightClick = + event.type == .rightMouseDown || + (event.type == .otherMouseDown && event.buttonNumber == 2) + + guard isRightClick else { + super.sendEvent(event) + return + } + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + + if tabBarView.bounds.contains(locationInTabBar) { + tabBarView.rightMouseDown(with: event) + } else { + super.sendEvent(event) + } + } + // 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) { @@ -148,6 +175,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool let tabBar = findTabBar() else { return } + self.tabBarView = tabBar + // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in self?.viewModel.hasTabBar = true @@ -206,6 +235,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Remove the observer so we can call setup again. self.tabBarObserver = nil + self.tabBarView = nil // Wait a tick to let the new tab bars appear and then set them up. DispatchQueue.main.async { @@ -223,6 +253,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Clear our observations self.tabBarObserver = nil + self.tabBarView = nil } // MARK: NSToolbarDelegate From 969bcbe8e308a72aa96a5a8d47c53ffc6708bcb7 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 7 Dec 2025 09:02:03 +0100 Subject: [PATCH 058/605] Update macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift --- .../Window Styles/TitlebarTabsTahoeTerminalWindow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 802e98dc1..a58b8ba91 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -9,7 +9,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// The view model for SwiftUI views private var viewModel = ViewModel() - /// Tb bar view for event routing + /// Tab bar view for event routing private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout From 76c2de6088581c7d634679b67bec8d9f1b90576c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:09:26 -0800 Subject: [PATCH 059/605] macos: remove the tabBarView variable we can search it --- .../TitlebarTabsTahoeTerminalWindow.swift | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a58b8ba91..5d910d2e0 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,9 +8,6 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() - - /// Tab bar view for event routing - private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. @@ -71,27 +68,35 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } + /// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work + /// naturally due to whatever mess we made. override func sendEvent(_ event: NSEvent) { - guard let tabBarView, viewModel.hasTabBar else { + guard viewModel.hasTabBar else { super.sendEvent(event) return } let isRightClick = event.type == .rightMouseDown || - (event.type == .otherMouseDown && event.buttonNumber == 2) - + (event.type == .otherMouseDown && event.buttonNumber == 2) || + (event.type == .leftMouseDown && event.modifierFlags.contains(.control)) guard isRightClick else { super.sendEvent(event) return } - let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) - - if tabBarView.bounds.contains(locationInTabBar) { - tabBarView.rightMouseDown(with: event) - } else { + + guard let tabBarView = findTabBar() else { super.sendEvent(event) + return } + + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + guard tabBarView.bounds.contains(locationInTabBar) else { + super.sendEvent(event) + return + } + + tabBarView.rightMouseDown(with: event) } // This is called by macOS for native tabbing in order to add the tab bar. We hook into @@ -175,8 +180,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool let tabBar = findTabBar() else { return } - self.tabBarView = tabBar - // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in self?.viewModel.hasTabBar = true @@ -235,7 +238,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Remove the observer so we can call setup again. self.tabBarObserver = nil - self.tabBarView = nil // Wait a tick to let the new tab bars appear and then set them up. DispatchQueue.main.async { @@ -253,7 +255,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Clear our observations self.tabBarObserver = nil - self.tabBarView = nil } // MARK: NSToolbarDelegate From 625d7274bf0bcebf17b5cd4ffa853165269489a6 Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 20:15:53 +0200 Subject: [PATCH 060/605] Add close tabs on the right action --- include/ghostty.h | 1 + .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- .../Window Styles/TerminalWindow.swift | 60 ++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 7 ++ macos/Sources/Ghostty/Package.swift | 3 + pkg/apple-sdk/build.zig | 27 ++++++ src/Surface.zig | 1 + src/apprt/action.zig | 2 + src/apprt/gtk/class/tab.zig | 1 + src/apprt/gtk/ui/1.5/window.blp | 30 ++++++ src/input/Binding.zig | 6 +- src/input/command.zig | 5 + 12 files changed, 231 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6cafe8773..cb8646560 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5cc2c67f1..1083fb405 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -104,6 +104,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr selector: #selector(onCloseOtherTabs), name: .ghosttyCloseOtherTabs, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTabsOnTheRight), + name: .ghosttyCloseTabsOnTheRight, + object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), @@ -627,6 +632,48 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + private func closeTabsOnTheRightImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + if let undoManager { + undoManager.beginUndoGrouping() + } + defer { + undoManager?.endUndoGrouping() + } + + for (_, candidate) in tabsToClose { + if let controller = candidate.windowController as? TerminalController { + controller.closeTabImmediately(registerRedo: false) + } + } + + if let undoManager { + undoManager.setActionName("Close Tabs on the Right") + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + DispatchQueue.main.async { + target.window?.makeKeyAndOrderFront(nil) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.closeTabsOnTheRightImmediately() + } + } + } + } + /// 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() { @@ -1078,24 +1125,24 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } - + // Check if we have to confirm close. guard tabGroup.windows.contains(where: { window in // Ignore ourself if window == self.window { return false } - + // Ignore non-terminals guard let controller = window.windowController as? TerminalController else { return false } - + // Check if any surfaces require confirmation return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) else { self.closeOtherTabsImmediately() return } - + confirmClose( messageText: "Close Other Tabs?", informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed." @@ -1104,6 +1151,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + @IBAction func closeTabsOnTheRight(_ sender: Any?) { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + let needsConfirm = tabsToClose.contains { (_, candidate) in + guard let controller = candidate.windowController as? TerminalController else { + return false + } + + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) + } + + if !needsConfirm { + self.closeTabsOnTheRightImmediately() + return + } + + confirmClose( + messageText: "Close Tabs on the Right?", + informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabsOnTheRightImmediately() + } + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1305,6 +1381,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeOtherTabs(self) } + @objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + closeTabsOnTheRight(self) + } + @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } @@ -1367,6 +1449,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr extension TerminalController { override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { + case #selector(closeTabsOnTheRight): + guard let window, let tabGroup = window.tabGroup else { return false } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } + return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2208d99cf..cbbbf99f7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -26,6 +26,8 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + private var tabMenuObserver: NSObjectProtocol? = nil /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -53,6 +55,15 @@ class TerminalWindow: NSWindow { override func awakeFromNib() { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + + tabMenuObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let menu = note.object as? NSMenu else { return } + self.configureTabContextMenuIfNeeded(menu) + } // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then @@ -202,6 +213,8 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -277,6 +290,47 @@ class TerminalWindow: NSWindow { } } + private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + menu.removeItem(existing) + } + guard let terminalController else { return } + + let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = terminalController + item.isEnabled = true + + let closeOtherIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("other") && name.contains("tab") + }) + + let closeThisIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("tab") + }) + + if let idx = closeOtherIndex { + menu.insertItem(item, at: idx + 1) + } else if let idx = closeThisIndex { + menu.insertItem(item, at: idx + 1) + } else { + menu.addItem(item) + } + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } + return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + } + + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -517,6 +571,12 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + deinit { + if let observer = tabMenuObserver { + NotificationCenter.default.removeObserver(observer) + } + } // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 39ebbb51f..f6452e54e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -861,6 +861,13 @@ extension Ghostty { ) return + case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: + NotificationCenter.default.post( + name: .ghosttyCloseTabsOnTheRight, + object: surfaceView + ) + return + default: assertionFailure() } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 7ee815caa..4b3eb60aa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -380,6 +380,9 @@ extension Notification.Name { /// Close other tabs static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs") + /// Close tabs to the right of the focused tab + static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight") + /// Close window static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow") diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index c573c3910..32cb726fd 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,6 +30,7 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, + cxx_include: []const u8, }) = .{}; }; @@ -82,11 +83,36 @@ pub fn addPaths( }); }; + const cxx_include_path = cxx: { + const preferred = try std.fs.path.join(b.allocator, &.{ + libc.sys_include_dir.?, + "c++", + "v1", + }); + if (std.fs.accessAbsolute(preferred, .{})) |_| { + break :cxx preferred; + } else |_| {} + + const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; + const fallback = try std.fs.path.join(b.allocator, &.{ + sdk_root, + "include", + "c++", + "v1", + }); + if (std.fs.accessAbsolute(fallback, .{})) |_| { + break :cxx fallback; + } else |_| {} + + break :cxx preferred; + }; + gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, + .cxx_include = cxx_include_path, }; } @@ -107,5 +133,6 @@ pub fn addPaths( // 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.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/Surface.zig b/src/Surface.zig index 653178bdc..9e7ad0b97 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool switch (v) { .this => .this, .other => .other, + .right => .right, }, ), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 00bf8685a..365f525f8 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -767,6 +767,8 @@ pub const CloseTabMode = enum(c_int) { this, /// Close all other tabs. other, + /// Close all tabs to the right of the current tab. + right, }; pub const CommandFinished = struct { diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c8b5607a6..fb3b8b0ef 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -347,6 +347,7 @@ pub const Tab = extern struct { switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), } } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb..de06b04da 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,6 +162,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); + menu-model: tab_context_menu; shortcuts: none; } } @@ -192,6 +193,35 @@ menu split_menu { } } +menu tab_context_menu { + section { + item { + label: _("New Tab"); + action: "win.new-tab"; + } + } + + section { + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + + item { + label: _("Close Other Tabs"); + action: "tab.close"; + target: "other"; + } + + item { + label: _("Close Tabs on the Right"); + action: "tab.close"; + target: "right"; + } + } +} + menu main_menu { section { item { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1e7db3592..66fe03651 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -600,9 +600,8 @@ pub const Action = union(enum) { /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab and all splits therein _or_ close all tabs and - /// splits thein of tabs _other_ than the current tab, depending on the - /// mode. + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. /// /// If the mode is not specified, defaults to closing the current tab. /// @@ -1005,6 +1004,7 @@ pub const Action = union(enum) { pub const CloseTabMode = enum { this, other, + right, pub const default: CloseTabMode = .this; }; diff --git a/src/input/command.zig b/src/input/command.zig index 72fb7f4ee..6baeca23b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -538,6 +538,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Close Other Tabs", .description = "Close all tabs in this window except the current one.", }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs on the Right", + .description = "Close every tab to the right of the current one.", + }, }, .close_window => comptime &.{.{ From cca10f3ca8b701c9c34bbcd1fc918e0de3e004e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:17:25 -0800 Subject: [PATCH 061/605] Revert GTK UI changes, apple-sdk build stuff --- pkg/apple-sdk/build.zig | 27 --------------------------- src/apprt/gtk/ui/1.5/window.blp | 30 ------------------------------ src/input/command.zig | 2 +- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 32cb726fd..c573c3910 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,7 +30,6 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, - cxx_include: []const u8, }) = .{}; }; @@ -83,36 +82,11 @@ pub fn addPaths( }); }; - const cxx_include_path = cxx: { - const preferred = try std.fs.path.join(b.allocator, &.{ - libc.sys_include_dir.?, - "c++", - "v1", - }); - if (std.fs.accessAbsolute(preferred, .{})) |_| { - break :cxx preferred; - } else |_| {} - - const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; - const fallback = try std.fs.path.join(b.allocator, &.{ - sdk_root, - "include", - "c++", - "v1", - }); - if (std.fs.accessAbsolute(fallback, .{})) |_| { - break :cxx fallback; - } else |_| {} - - break :cxx preferred; - }; - gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, - .cxx_include = cxx_include_path, }; } @@ -133,6 +107,5 @@ pub fn addPaths( // 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.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index de06b04da..8c0a7bedb 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,7 +162,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); - menu-model: tab_context_menu; shortcuts: none; } } @@ -193,35 +192,6 @@ menu split_menu { } } -menu tab_context_menu { - section { - item { - label: _("New Tab"); - action: "win.new-tab"; - } - } - - section { - item { - label: _("Close Tab"); - action: "tab.close"; - target: "this"; - } - - item { - label: _("Close Other Tabs"); - action: "tab.close"; - target: "other"; - } - - item { - label: _("Close Tabs on the Right"); - action: "tab.close"; - target: "right"; - } - } -} - menu main_menu { section { item { diff --git a/src/input/command.zig b/src/input/command.zig index 6baeca23b..4cbe9ffc4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -540,7 +540,7 @@ fn actionCommands(action: Action.Key) []const Command { }, .{ .action = .{ .close_tab = .right }, - .title = "Close Tabs on the Right", + .title = "Close Tabs to the Right", .description = "Close every tab to the right of the current one.", }, }, From 10bac6a5dd94f072bcb4d95cd956d0516b50ff7b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:26:40 -0600 Subject: [PATCH 062/605] benchmark: use newer bytes api to generate ascii --- src/synthetic/cli/Ascii.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 22ca1ffb5..d416189ce 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -36,10 +36,12 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { var gen: Bytes = .{ .rand = rand, .alphabet = ascii, + .min_len = 1024, + .max_len = 1024, }; while (true) { - gen.next(writer, 1024) catch |err| { + _ = gen.write(writer) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed From 4424451c59eb16189054b1787b247e762fe74c4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:24:54 -0800 Subject: [PATCH 063/605] macos: remove to "close to the right" --- macos/Sources/Features/Terminal/TerminalController.swift | 6 ++---- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1083fb405..a275c3f39 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -640,9 +640,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } guard !tabsToClose.isEmpty else { return } - if let undoManager { - undoManager.beginUndoGrouping() - } + undoManager?.beginUndoGrouping() defer { undoManager?.endUndoGrouping() } @@ -654,7 +652,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } if let undoManager { - undoManager.setActionName("Close Tabs on the Right") + undoManager.setActionName("Close Tabs to the Right") undoManager.registerUndo( withTarget: self, diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cbbbf99f7..1f9f10502 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -297,7 +297,7 @@ class TerminalWindow: NSWindow { } guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = terminalController From cfdcd50e184240e48fe6b6d9e0bd6ed0afb3ae46 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:30:19 -0600 Subject: [PATCH 064/605] benchmark: generate more types of OSC sequences --- src/os/string_encoding.zig | 13 ++++ src/synthetic/Osc.zig | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig index 162023ad2..042001ea7 100644 --- a/src/os/string_encoding.zig +++ b/src/os/string_encoding.zig @@ -265,3 +265,16 @@ test "percent 7" { @memcpy(&src, s); try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); } + +/// Is the given character valid in URI percent encoding? +fn isValidChar(c: u8) bool { + return switch (c) { + ' ', ';', '=' => false, + else => return std.ascii.isPrint(c), + }; +} + +/// Write data to the writer after URI percent encoding. +pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void { + try std.Uri.Component.percentEncode(writer, data, isValidChar); +} diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index b43079e1a..00de43f7f 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -5,12 +5,23 @@ const std = @import("std"); const assert = std.debug.assert; const Generator = @import("Generator.zig"); const Bytes = @import("Bytes.zig"); +const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode; /// Valid OSC request kinds that can be generated. pub const ValidKind = enum { change_window_title, prompt_start, prompt_end, + end_of_input, + end_of_command, + rxvt_notify, + mouse_shape, + clipboard_operation, + report_pwd, + hyperlink_start, + hyperlink_end, + conemu_progress, + iterm2_notification, }; /// Invalid OSC request kinds that can be generated. @@ -55,6 +66,9 @@ fn checkOscAlphabet(c: u8) bool { /// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); +pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint); +pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic); +pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -143,6 +157,115 @@ fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKin if (max_len < 4) break :prompt_end; try writer.writeAll("133;B"); // End prompt }, + + .end_of_input => end_of_input: { + if (max_len < 5) break :end_of_input; + var remaining = max_len; + try writer.writeAll("133;C"); // End prompt + remaining -= 5; + if (self.rand.boolean()) cmdline: { + const prefix = ";cmdline_url="; + if (remaining < prefix.len + 1) break :cmdline; + try writer.writeAll(prefix); + remaining -= prefix.len; + var buf: [128]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w); + try urlPercentEncode(writer, w.buffered()); + remaining -= w.buffered().len; + } + }, + + .end_of_command => end_of_command: { + if (max_len < 4) break :end_of_command; + try writer.writeAll("133;D"); // End prompt + if (self.rand.boolean()) exit_code: { + if (max_len < 7) break :exit_code; + try writer.print(";{d}", .{self.rand.int(u8)}); + } + }, + + .mouse_shape => mouse_shape: { + if (max_len < 4) break :mouse_shape; + try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt + }, + + .rxvt_notify => rxvt_notify: { + const prefix = "777;notify;"; + if (max_len < prefix.len) break :rxvt_notify; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer); + try writer.writeByte(';'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + }, + + .clipboard_operation => { + try writer.writeAll("52;"); + var remaining = max_len - 3; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer); + } + try writer.writeByte(';'); + remaining -= 1; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + } + }, + + .report_pwd => report_pwd: { + const prefix = "7;file://localhost"; + if (max_len < prefix.len) break :report_pwd; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.writeByte('/'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer); + } + }, + + .hyperlink_start => { + try writer.writeAll("8;"); + if (self.rand.boolean()) { + try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + try writer.writeAll(";https://localhost"); + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + }, + + .hyperlink_end => hyperlink_end: { + if (max_len < 3) break :hyperlink_end; + try writer.writeAll("8;;"); + }, + + .conemu_progress => { + try writer.writeAll("9;"); + switch (self.rand.intRangeAtMost(u3, 0, 4)) { + 0, 3 => |c| { + try writer.print(";{d}", .{c}); + }, + 1, 2, 4 => |c| { + if (self.rand.boolean()) { + try writer.print(";{d}", .{c}); + } else { + try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) }); + } + }, + else => unreachable, + } + }, + + .iterm2_notification => iterm2_notification: { + if (max_len < 3) break :iterm2_notification; + // add a prefix to ensure that this is not interpreted as a ConEmu OSC + try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)}); + }, } } From 01a75ceec4e7619345cb5f1031b98626bbe85f3d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:31:27 -0600 Subject: [PATCH 065/605] benchmark: add option to microbenchmark OSC parser --- src/benchmark/OscParser.zig | 118 ++++++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/synthetic/cli/Osc.zig | 26 +++++++- 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/benchmark/OscParser.zig diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig new file mode 100644 index 000000000..6243aba7d --- /dev/null +++ b/src/benchmark/OscParser.zig @@ -0,0 +1,118 @@ +//! This benchmark tests the throughput of the OSC parser. +const OscParser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Parser = @import("../terminal/osc.zig").Parser; +const log = std.log.scoped(.@"osc-parser-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +parser: Parser, + +pub const Options = struct { + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*OscParser { + const ptr = try alloc.create(OscParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .data_f = null, + .parser = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *OscParser, alloc: Allocator) void { + self.parser.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *OscParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; + self.parser.reset(); +} + +fn teardown(ptr: *anyopaque) void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var r = f.reader(&read_buf); + + var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + while (true) { + r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + const len = r.interface.takeInt(usize, .little) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + if (len > osc_buf.len) return error.BenchmarkFailed; + + r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + for (osc_buf[0..len]) |c| self.parser.next(c); + _ = self.parser.end(std.ascii.control_code.bel); + self.parser.reset(); + } +} + +test OscParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *OscParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 816ecd3f6..13f070774 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -12,6 +12,7 @@ pub const Action = enum { @"terminal-parser", @"terminal-stream", @"is-symbol", + @"osc-parser", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -29,6 +30,7 @@ pub const Action = enum { .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), .@"is-symbol" => @import("IsSymbol.zig"), + .@"osc-parser" => @import("OscParser.zig"), }; } }; diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig index 8250b81de..686563fc3 100644 --- a/src/synthetic/cli/Osc.zig +++ b/src/synthetic/cli/Osc.zig @@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct { /// Probability of generating a valid value. @"p-valid": f64 = 0.5, + + style: enum { + /// Write all OSC data, including ESC ] and ST for end-to-end tests + streaming, + /// Only write data, prefixed with a length, used for testing just the + /// OSC parser. + parser, + } = .streaming, }; opts: Options, @@ -40,9 +48,21 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void { var fixed: std.Io.Writer = .fixed(&buf); try gen.next(&fixed, buf.len); const data = fixed.buffered(); - writer.writeAll(data) catch |err| switch (err) { - error.WriteFailed => return, - }; + switch (self.opts.style) { + .streaming => { + writer.writeAll(data) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + .parser => { + writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) { + error.WriteFailed => return, + }; + writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + } } } From f612e4632cc84ebad71c266c704f0d5bcfc1f829 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:43:38 -0800 Subject: [PATCH 066/605] macos: clean up some style on tab bar context menu configuring --- .../Window Styles/TerminalWindow.swift | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1f9f10502..0ae4c3b02 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -56,12 +56,14 @@ class TerminalWindow: NSWindow { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + // This is fragile, but there doesn't seem to be an official API for customizing + // native tab bar menus. tabMenuObserver = NotificationCenter.default.addObserver( forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), object: nil, queue: .main - ) { [weak self] note in - guard let self, let menu = note.object as? NSMenu else { return } + ) { [weak self] n in + guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } @@ -292,32 +294,26 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } - if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = nil + item.isEnabled = true + + // Remove any previously configured items, because the menu is + // cached across different tab targets. + if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { menu.removeItem(existing) } - guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") - let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = terminalController - item.isEnabled = true - - let closeOtherIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("other") && name.contains("tab") - }) - - let closeThisIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("tab") - }) - - if let idx = closeOtherIndex { + // Insert it wherever we can + if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performCloseOtherTabs:") + }) { menu.insertItem(item, at: idx + 1) - } else if let idx = closeThisIndex { + } else if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performClose:") + }) { menu.insertItem(item, at: idx + 1) } else { menu.addItem(item) @@ -326,8 +322,17 @@ class TerminalWindow: NSWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } - return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) } From dc641c7861c44b8ecdfb8a3747d99c8bc5360e41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:47:15 -0800 Subject: [PATCH 067/605] macos: change to NSMenu extension --- .../Window Styles/TerminalWindow.swift | 19 ++---------- .../Helpers/Extensions/NSMenu+Extension.swift | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSMenu+Extension.swift diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 0ae4c3b02..997996e3b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -299,23 +299,8 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = nil item.isEnabled = true - - // Remove any previously configured items, because the menu is - // cached across different tab targets. - if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { - menu.removeItem(existing) - } - - // Insert it wherever we can - if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performCloseOtherTabs:") - }) { - menu.insertItem(item, at: idx + 1) - } else if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performClose:") - }) { - menu.insertItem(item, at: idx + 1) - } else { + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && + !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift new file mode 100644 index 000000000..7ddfa419f --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -0,0 +1,29 @@ +import AppKit + +extension NSMenu { + /// Inserts a menu item after an existing item with the specified action selector. + /// + /// If an item with the same identifier already exists, it is removed first to avoid duplicates. + /// This is useful when menus are cached and reused across different targets. + /// + /// - Parameters: + /// - item: The menu item to insert. + /// - action: The action selector to search for. The new item will be inserted after the first + /// item with this action. + /// - Returns: `true` if the item was inserted after the specified action, `false` if the action + /// was not found and the item was not inserted. + @discardableResult + func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + if let identifier = item.identifier, + let existing = items.first(where: { $0.identifier == identifier }) { + removeItem(existing) + } + + guard let idx = items.firstIndex(where: { $0.action == action }) else { + return false + } + + insertItem(item, at: idx + 1) + return true + } +} From 1387dbefad18809a95a5eaacb7a3f223891d0e9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:50:26 -0800 Subject: [PATCH 068/605] macos: target should be the correct target --- .../Terminal/Window Styles/TerminalWindow.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 997996e3b..b8c9d4c7d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -294,11 +294,19 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = nil - item.isEnabled = true + item.target = targetController + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) From eb75d48e6b59f32cbad65ee7233586aa84940541 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:56:07 -0800 Subject: [PATCH 069/605] macos: add xmark to other tab close items --- .../Terminal/Window Styles/TerminalWindow.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index b8c9d4c7d..77ee98cb4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -303,14 +303,23 @@ class TerminalWindow: NSWindow { .flatMap { $0.target as? NSWindow } .flatMap { $0.windowController as? TerminalController } + // Close tabs to the right let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController - + item.setImageIfDesired(systemSymbolName: "xmark") if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } } private func isTabContextMenu(_ menu: NSMenu) -> Bool { From 3352d5f0810200e74b1bd537f6c70a3a3018e957 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:57:36 -0800 Subject: [PATCH 070/605] Fix up close right description --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index 4cbe9ffc4..b3f9e86b6 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -541,7 +541,7 @@ fn actionCommands(action: Action.Key) []const Command { .{ .action = .{ .close_tab = .right }, .title = "Close Tabs to the Right", - .description = "Close every tab to the right of the current one.", + .description = "Close all tabs to the right of the current one.", }, }, From 4a6d551941c5c8000e0f0921dbc5af37ee119da3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 21:20:38 -0800 Subject: [PATCH 071/605] macos: don't put NSMenu extension in iOS build --- macos/Ghostty.xcodeproj/project.pbxproj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ca420afaa..b70eb131b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", + "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", "Helpers/Extensions/NSPasteboard+Extension.swift", "Helpers/Extensions/NSScreen+Extension.swift", @@ -876,7 +877,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -915,7 +916,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -954,7 +955,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 669733d59775f013066e573aa7c88da3c4bc2f34 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 21:21:03 -0800 Subject: [PATCH 072/605] macos: remove iOS signing (dev team) --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b70eb131b..31e812f0c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -877,7 +877,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -916,7 +916,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -955,7 +955,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From f96aca7a3f96e057e72e8745446f4d1dbd5820e3 Mon Sep 17 00:00:00 2001 From: "Felipe M.B." Date: Thu, 11 Dec 2025 04:10:03 -0300 Subject: [PATCH 073/605] Fix typo in po/README_CONTRIB Change translable to translatable. --- po/README_CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/README_CONTRIBUTORS.md b/po/README_CONTRIBUTORS.md index 2c405acf3..e232c0620 100644 --- a/po/README_CONTRIBUTORS.md +++ b/po/README_CONTRIBUTORS.md @@ -9,7 +9,7 @@ for any localization that they may add. ## GTK -In the GTK app runtime, translable strings are mainly sourced from Blueprint +In the GTK app runtime, translatable strings are mainly sourced from Blueprint files (located under `src/apprt/gtk/ui`). Blueprints have a native syntax for translatable strings, which look like this: From 3b2f551dc0ba282c42eaf45448e019c970e83c08 Mon Sep 17 00:00:00 2001 From: "Felipe M.B." Date: Thu, 11 Dec 2025 07:49:07 -0300 Subject: [PATCH 074/605] Fix typo in po/README_TRANS via the its -> via its --- po/README_TRANSLATORS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/README_TRANSLATORS.md b/po/README_TRANSLATORS.md index 582d5037c..25b7cab5b 100644 --- a/po/README_TRANSLATORS.md +++ b/po/README_TRANSLATORS.md @@ -44,9 +44,9 @@ intended to be regenerated by code contributors. If there is a problem with the template file, please reach out to a code contributor. Instead, only edit the translation file corresponding to your language/locale, -identified via the its _locale name_: for example, `de_DE.UTF-8.po` would be -the translation file for German (language code `de`) as spoken in Germany -(country code `DE`). The GNU `gettext` manual contains +identified via its _locale name_: for example, `de_DE.UTF-8.po` would be the +translation file for German (language code `de`) as spoken in Germany (country +code `DE`). The GNU `gettext` manual contains [further information about locale names](https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Names-1), including a list of language and country codes. From 0d8c193bda92347a9e7273728f93a084e2934292 Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 11 Dec 2025 16:43:16 +0530 Subject: [PATCH 075/605] fix(terminal): prevent integer overflow in hash_map layoutForCapacity Co-Authored-By: Sachin --- src/terminal/hash_map.zig | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 23b10950e..989302df2 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -856,13 +856,17 @@ fn HashMapUnmanaged( pub fn layoutForCapacity(new_capacity: Size) Layout { assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity)); + // Cast to usize to prevent overflow in size calculations. + // See: https://github.com/ziglang/zig/pull/19048 + const cap: usize = new_capacity; + // Pack our metadata, keys, and values. const meta_start = @sizeOf(Header); - const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + const meta_end = @sizeOf(Header) + cap * @sizeOf(Metadata); const keys_start = std.mem.alignForward(usize, meta_end, key_align); - const keys_end = keys_start + new_capacity * @sizeOf(K); + const keys_end = keys_start + cap * @sizeOf(K); const vals_start = std.mem.alignForward(usize, keys_end, val_align); - const vals_end = vals_start + new_capacity * @sizeOf(V); + const vals_end = vals_start + cap * @sizeOf(V); // Our total memory size required is the end of our values // aligned to the base required alignment. @@ -1512,3 +1516,26 @@ test "OffsetHashMap remake map" { try expectEqual(5, map.get(5).?); } } + +test "layoutForCapacity no overflow for large capacity" { + // Test that layoutForCapacity correctly handles large capacities without overflow. + // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, + // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. + // See: https://github.com/ghostty-org/ghostty/issues/9862 + const Map = AutoHashMapUnmanaged(u64, u64); + + // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 + // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 + const large_cap: Map.Size = 1 << 30; + const layout = Map.layoutForCapacity(large_cap); + + // With the fix, total_size should be at least cap * (sizeof(K) + sizeof(V)) + // = 2^30 * 16 = 2^34 bytes = 16 GiB + // Without the fix, this would wrap and produce a much smaller value. + const min_expected: usize = @as(usize, large_cap) * (@sizeOf(u64) + @sizeOf(u64)); + try expect(layout.total_size >= min_expected); + + // Also verify the individual offsets don't wrap + try expect(layout.keys_start > 0); + try expect(layout.vals_start > layout.keys_start); +} From f4560390d7ad6abdb15a61ccc09f02895cabf538 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 11 Dec 2025 09:35:40 -0500 Subject: [PATCH 076/605] Remove accidental changes to macos/text/run.ig --- pkg/macos/text/run.zig | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig index a34cd5307..2895bfe34 100644 --- a/pkg/macos/text/run.zig +++ b/pkg/macos/text/run.zig @@ -106,19 +106,6 @@ pub const Run = opaque { pub fn getStatus(self: *Run) Status { return @bitCast(c.CTRunGetStatus(@ptrCast(self))); } - - pub fn getAttributes(self: *Run) *foundation.Dictionary { - return @ptrCast(@constCast(c.CTRunGetAttributes(@ptrCast(self)))); - } - - pub fn getFont(self: *Run) ?*text.Font { - const attrs = self.getAttributes(); - const font_ptr = attrs.getValue(*const anyopaque, c.kCTFontAttributeName); - if (font_ptr) |ptr| { - return @ptrCast(@constCast(ptr)); - } - return null; - } }; /// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc From b224b690545e2e51eef1e86365698709ee01a82f Mon Sep 17 00:00:00 2001 From: Devzeth Date: Thu, 11 Dec 2025 01:05:51 +0100 Subject: [PATCH 077/605] fix(terminal): increase grapheme_bytes instead of hyperlink_bytes during reflow When reflowing content with many graphemes, the code incorrectly increased hyperlink_bytes capacity instead of grapheme_bytes, causing GraphemeMapOutOfMemory errors. --- src/terminal/PageList.zig | 85 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 29f414e03..9e14e2a75 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1220,7 +1220,7 @@ const ReflowCursor = struct { // with graphemes then we increase capacity. if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { try self.adjustCapacity(list, .{ - .hyperlink_bytes = cap.grapheme_bytes * 2, + .grapheme_bytes = cap.grapheme_bytes * 2, }); } @@ -10758,3 +10758,86 @@ test "PageList clears history" { .x = 0, }, s.getTopLeft(.active)); } + +test "PageList resize reflow grapheme map capacity exceeded" { + // This test verifies that when reflowing content with many graphemes, + // the grapheme map capacity is correctly increased when needed. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Get the grapheme capacity from the page. We need more than this many + // graphemes in a single destination page to trigger capacity increase + // during reflow. Since each source page can only hold this many graphemes, + // we create two source pages with graphemes that will merge into one + // destination page. + const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + // Use slightly more than half the capacity per page, so combined they + // exceed the capacity of a single destination page. + const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; + + // Grow to the capacity of the first page and add more rows + // so that we have two pages total. + { + const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + page.pauseIntegrityChecks(false); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(graphemes_per_page); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + + // We now have two pages. + try testing.expect(s.pages.first.? != s.pages.last.?); + try testing.expectEqual(s.pages.last.?, s.pages.first.?.next); + } + + // Add graphemes to both pages. We add graphemes to rows at the END of the + // first page, and graphemes to rows at the START of the second page. + // When reflowing to 2 columns, these rows will wrap and stay together + // on the same destination page, requiring capacity increase. + + // Add graphemes to the end of the first page (last rows) + { + const page = &s.pages.first.?.data; + const start_row = page.size.rows - graphemes_per_page; + for (0..graphemes_per_page) |i| { + const y = start_row + i; + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0301))); + } + } + + // Add graphemes to the beginning of the second page + { + const page = &s.pages.last.?.data; + const count = @min(graphemes_per_page, page.size.rows); + for (0..count) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0302))); + } + } + + // Resize to fewer columns to trigger reflow. + // The graphemes from both pages will be copied to destination pages. + // They will all end up in a contiguous region of the destination. + // If the bug exists (hyperlink_bytes increased instead of grapheme_bytes), + // this will fail with GraphemeMapOutOfMemory when we exceed capacity. + try s.resize(.{ .cols = 2, .reflow = true }); + + // Verify the resize succeeded + try testing.expectEqual(@as(usize, 2), s.cols); +} From 1a65c1aae2f35e4accd9c2b4761d92dbd7d742ae Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 22:24:10 +0200 Subject: [PATCH 078/605] feat(macos): add tab color picker to tab context menu --- .../Terminal/TerminalController.swift | 96 +++-- .../Terminal/TerminalRestorable.swift | 7 +- .../Window Styles/TerminalWindow.swift | 357 ++++++++++++++++-- .../Helpers/Extensions/NSMenu+Extension.swift | 13 +- 4 files changed, 404 insertions(+), 69 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a275c3f39..1b2a31928 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,6 +54,17 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig + /// The accent color that should be rendered for this tab. + var tabColor: TerminalWindow.TabColor = .none { + didSet { + guard tabColor != oldValue else { return } + if let terminalWindow = window as? TerminalWindow { + terminalWindow.display(tabColor: tabColor) + } + window?.invalidateRestorableState() + } + } + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -148,7 +159,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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() @@ -195,7 +206,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr $0.window?.isMainWindow ?? false } ?? lastMain ?? all.last } - + // The last controller to be main. We use this when paired with "preferredParent" // to find the preferred window to attach new tabs, perform actions, etc. We // always prefer the main window but if there isn't any (because we're triggered @@ -517,13 +528,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr fromTopLeftOffsetX: CGFloat(x), offsetY: CGFloat(y), windowSize: frame.size) - + // Clamp the origin to ensure the window stays fully visible on screen var safeOrigin = origin let vf = screen.visibleFrame safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) - + // Return our new origin var result = frame result.origin = safeOrigin @@ -558,7 +569,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindowImmediately() return } - + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -579,15 +590,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + window.close() } - + private func closeOtherTabsImmediately() { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } guard tabGroup.windows.count > 1 else { return } - + // Start an undo grouping if let undoManager { undoManager.beginUndoGrouping() @@ -595,7 +606,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr defer { undoManager?.endUndoGrouping() } - + // Iterate through all tabs except the current one. for window in tabGroup.windows where window != self.window { // We ignore any non-terminal tabs. They don't currently exist and we can't @@ -607,10 +618,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr controller.closeTabImmediately(registerRedo: false) } } - + if let undoManager { undoManager.setActionName("Close Other Tabs") - + // We need to register an undo that refocuses this window. Otherwise, the // undo operation above for each tab will steal focus. undoManager.registerUndo( @@ -620,7 +631,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { target.window?.makeKeyAndOrderFront(nil) } - + // Register redo action undoManager.registerUndo( withTarget: target, @@ -746,7 +757,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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. @@ -772,12 +783,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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) @@ -786,7 +797,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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 { @@ -852,12 +863,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? + let tabColor: TerminalWindow.TabColor } convenience init(_ ghostty: Ghostty.App, with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + self.tabColor = undoState.tabColor // Show the window and restore its frame showWindow(nil) @@ -898,7 +911,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr surfaceTree: surfaceTree, focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), - tabGroup: window.tabGroup) + tabGroup: window.tabGroup, + tabColor: tabColor) } //MARK: - NSWindowController @@ -939,14 +953,17 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr viewModel: self, delegate: self, )) - + + if let terminalWindow = window as? TerminalWindow { + terminalWindow.display(tabColor: tabColor) + } // If we have a default size, we want to apply it. if let defaultSize { switch (defaultSize) { case .frame: // Frames can be applied immediately defaultSize.apply(to: window) - + case .contentIntrinsicSize: // Content intrinsic size requires a short delay so that AppKit // can layout our SwiftUI views. @@ -956,13 +973,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + // Store our initial frame so we can know our default later. This MUST // be after the defaultSize call above so that we don't re-apply our frame. // Note: we probably want to set this on the first frame change or something // so it respects cascade. initialFrame = window.frame - + // 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. @@ -1073,7 +1090,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if let window { LastWindowPosition.shared.save(window) } - + // Remember our last main Self.lastMain = self } @@ -1120,7 +1137,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr @IBAction func closeOtherTabs(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } - + // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } @@ -1178,6 +1195,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + + func setTabColor(_ color: TerminalWindow.TabColor) { + tabColor = color + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1219,7 +1241,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } //MARK: - TerminalViewDelegate - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -1283,7 +1305,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Get our target window let targetWindow = tabbedWindows[finalIndex] - + // Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs. // I believe this is due to messed up constraints for our hacky tab bar. I'd like to // find a better workaround. For now, this improves things dramatically. @@ -1296,7 +1318,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { selectedWindow.makeKey() } - + return } } @@ -1451,24 +1473,24 @@ extension TerminalController { guard let window, let tabGroup = window.tabGroup else { return false } guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } - + case #selector(returnToDefaultSize): guard let window else { return false } - + // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } - + // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } - + // If our window is already the default size or we don't have a // default size, then disable. return defaultSize?.isChanged(for: window) ?? false - + default: return super.validateMenuItem(item) } @@ -1484,10 +1506,10 @@ extension TerminalController { enum DefaultSize { /// A frame, set with `window.setFrame` case frame(NSRect) - + /// A content size, set with `window.setContentSize` case contentIntrinsicSize - + func isChanged(for window: NSWindow) -> Bool { switch self { case .frame(let rect): @@ -1496,11 +1518,11 @@ extension TerminalController { guard let view = window.contentView else { return false } - + return view.frame.size != view.intrinsicContentSize } } - + func apply(to window: NSWindow) { switch self { case .frame(let rect): @@ -1509,13 +1531,13 @@ extension TerminalController { guard let size = window.contentView?.intrinsicContentSize else { return } - + window.setContentSize(size) window.constrainToScreen() } } } - + private var defaultSize: DefaultSize? { if derivedConfig.maximize, let screen = window?.screen ?? NSScreen.main { // Maximize takes priority, we take up the full screen we're on. diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 71e54b612..852cad581 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,16 +4,18 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 5 + static let version: Int = 6 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? + let tabColorRawValue: Int init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode + self.tabColorRawValue = controller.tabColor.rawValue } init?(coder aDecoder: NSCoder) { @@ -31,6 +33,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode + self.tabColorRawValue = v.value.tabColorRawValue } func encode(with coder: NSCoder) { @@ -94,6 +97,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } + c.tabColor = TerminalWindow.TabColor(rawValue: state.tabColorRawValue) ?? .none + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 77ee98cb4..1361bbd1f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -7,10 +7,10 @@ import GhosttyKit class TerminalWindow: NSWindow { /// Posted when a terminal window awakes from nib. static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") - + /// Posted when a terminal window will close static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") - + /// 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" @@ -20,15 +20,20 @@ class TerminalWindow: NSWindow { /// Reset split zoom button in titlebar private let resetZoomAccessory = NSTitlebarAccessoryViewController() - + /// Update notification UI in titlebar private let updateAccessory = NSTitlebarAccessoryViewController() + /// Visual indicator that mirrors the selected tab color. + private let tabColorIndicator = TabColorIndicator() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() - private var tabMenuObserver: NSObjectProtocol? = nil - + private var tabColorSelection: TabColor = .none { + didSet { tabColorIndicator.tabColor = tabColorSelection } + } + /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. var supportsUpdateAccessory: Bool { @@ -40,7 +45,11 @@ class TerminalWindow: NSWindow { var terminalController: TerminalController? { windowController as? TerminalController } - + + func display(tabColor: TabColor) { + tabColorSelection = tabColor + } + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -66,7 +75,7 @@ class TerminalWindow: NSWindow { guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } - + // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then // tabs restore as separate windows. @@ -74,14 +83,14 @@ class TerminalWindow: NSWindow { DispatchQueue.main.async { self.tabbingMode = .automatic } - + // All new windows are based on the app config at the time of creation. guard let appDelegate = NSApp.delegate as? AppDelegate else { return } let config = appDelegate.ghostty.config // Setup our initial config derivedConfig = .init(config) - + // If there is a hardcoded title in the configuration, we set that // immediately. Future `set_title` apprt actions will override this // if necessary but this ensures our window loads with the proper @@ -116,7 +125,7 @@ class TerminalWindow: NSWindow { })) addTitlebarAccessoryViewController(resetZoomAccessory) resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false - + // Create update notification accessory if supportsUpdateAccessory { updateAccessory.layoutAttribute = .right @@ -132,9 +141,19 @@ class TerminalWindow: NSWindow { // 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]) + tabColorIndicator.translatesAutoresizingMaskIntoConstraints = false + tabColorIndicator.widthAnchor.constraint(equalToConstant: 12).isActive = true + tabColorIndicator.heightAnchor.constraint(equalToConstant: 4).isActive = true + tabColorIndicator.tabColor = tabColorSelection + + let stackView = NSStackView() + stackView.orientation = .horizontal stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 + stackView.spacing = 4 + stackView.alignment = .centerY + stackView.addArrangedSubview(tabColorIndicator) + stackView.addArrangedSubview(keyEquivalentLabel) + stackView.addArrangedSubview(resetZoomTabButton) tab.accessoryView = stackView // Get our saved level @@ -145,7 +164,7 @@ class TerminalWindow: NSWindow { // still become key/main and receive events. override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } - + override func close() { NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() @@ -216,6 +235,9 @@ class TerminalWindow: NSWindow { static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") + private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. @@ -279,7 +301,7 @@ class TerminalWindow: NSWindow { if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { removeTitlebarAccessoryViewController(at: idx) } - + // We don't need to do this with the update accessory. I don't know why but // everything works fine. } @@ -302,29 +324,37 @@ class TerminalWindow: NSWindow { .first { $0.action == NSSelectorFromString("performClose:") } .flatMap { $0.target as? NSWindow } .flatMap { $0.windowController as? TerminalController } - + // Close tabs to the right let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController item.setImageIfDesired(systemSymbolName: "xmark") - if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && - !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { + let insertionIndex: UInt + if let idx = menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) { + insertionIndex = idx + } else if let idx = menu.insertItem(item, after: NSSelectorFromString("performClose:")) { + insertionIndex = idx + } else { menu.addItem(item) + insertionIndex = UInt(menu.items.count - 1) } // Other close items should have the xmark to match Safari on macOS 26 for menuItem in menu.items { if menuItem.action == NSSelectorFromString("performClose:") || - menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { menuItem.setImageIfDesired(systemSymbolName: "xmark") } } + + removeTabColorSection(from: menu) + insertTabColorSection(into: menu, startingAt: Int(insertionIndex) + 1) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - + // These are the target selectors, at least for macOS 26. let tabContextSelectors: Set = [ "performClose:", @@ -332,12 +362,56 @@ class TerminalWindow: NSWindow { "moveTabToNewWindow:", "toggleTabOverview:" ] - + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) return !selectorNames.isDisjoint(with: tabContextSelectors) } + private func removeTabColorSection(from menu: NSMenu) { + let identifiers: Set = [ + Self.tabColorSeparatorIdentifier, + Self.tabColorHeaderIdentifier, + Self.tabColorPaletteIdentifier + ] + + for (index, item) in menu.items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + menu.removeItem(at: index) + } + } + } + + private func insertTabColorSection(into menu: NSMenu, startingAt index: Int) { + guard let terminalController else { return } + + var insertionIndex = index + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.insertItem(separator, at: insertionIndex) + insertionIndex += 1 + + let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") + let headerItem = NSMenuItem() + headerItem.identifier = Self.tabColorHeaderIdentifier + headerItem.title = headerTitle + headerItem.isEnabled = false + menu.insertItem(headerItem, at: insertionIndex) + insertionIndex += 1 + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + let paletteView = TabColorPaletteView( + selectedColor: tabColorSelection + ) { [weak terminalController] color in + terminalController?.setTabColor(color) + } + paletteItem.view = paletteView + menu.insertItem(paletteItem, at: insertionIndex) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -549,7 +623,7 @@ class TerminalWindow: NSWindow { private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { + guard x != nil, y != nil else { if (!LastWindowPosition.shared.restore(self)) { center() } @@ -568,7 +642,7 @@ class TerminalWindow: NSWindow { center() return } - + let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) setFrameOrigin(frame.origin) } @@ -584,7 +658,7 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - + // MARK: Config struct DerivedConfig { @@ -651,12 +725,12 @@ extension TerminalWindow { } } } - + /// A pill-shaped button that displays update status and provides access to update actions. struct UpdateAccessoryView: View { @ObservedObject var viewModel: ViewModel @ObservedObject var model: UpdateViewModel - + var body: some View { // We use the same top/trailing padding so that it hugs the same. UpdatePill(model: model) @@ -666,3 +740,236 @@ extension TerminalWindow { } } + +extension TerminalWindow { + enum TabColor: Int, CaseIterable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + static let paletteRows: [[TabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + var localizedName: String { + switch self { + case .none: + return NSLocalizedString("None", comment: "Tab color option label") + case .blue: + return NSLocalizedString("Blue", comment: "Tab color option label") + case .purple: + return NSLocalizedString("Purple", comment: "Tab color option label") + case .pink: + return NSLocalizedString("Pink", comment: "Tab color option label") + case .red: + return NSLocalizedString("Red", comment: "Tab color option label") + case .orange: + return NSLocalizedString("Orange", comment: "Tab color option label") + case .yellow: + return NSLocalizedString("Yellow", comment: "Tab color option label") + case .green: + return NSLocalizedString("Green", comment: "Tab color option label") + case .teal: + return NSLocalizedString("Teal", comment: "Tab color option label") + case .graphite: + return NSLocalizedString("Graphite", comment: "Tab color option label") + } + } + + var displayColor: NSColor? { + switch self { + case .none: + return nil + case .blue: + return .systemBlue + case .purple: + return .systemPurple + case .pink: + return .systemPink + case .red: + return .systemRed + case .orange: + return .systemOrange + case .yellow: + return .systemYellow + case .green: + return .systemGreen + case .teal: + if #available(macOS 13.0, *) { + return .systemMint + } else { + return .systemTeal + } + case .graphite: + return .systemGray + } + } + + func swatchImage(selected: Bool) -> NSImage { + let size = NSSize(width: 18, height: 18) + return NSImage(size: size, flipped: false) { rect in + let circleRect = rect.insetBy(dx: 1, dy: 1) + let circlePath = NSBezierPath(ovalIn: circleRect) + + if let fillColor = self.displayColor { + fillColor.setFill() + circlePath.fill() + } else { + NSColor.clear.setFill() + circlePath.fill() + NSColor.quaternaryLabelColor.setStroke() + circlePath.lineWidth = 1 + circlePath.stroke() + } + + if self == .none { + let slash = NSBezierPath() + slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) + slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) + slash.lineWidth = 1.5 + NSColor.secondaryLabelColor.setStroke() + slash.stroke() + } + + if selected { + let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + highlight.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + highlight.stroke() + } + + return true + } + } + } +} + +private final class TabColorIndicator: NSView { + var tabColor: TerminalWindow.TabColor = .none { + didSet { updateAppearance() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + updateAppearance() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + updateAppearance() + } + + private func updateAppearance() { + guard let layer else { return } + layer.cornerRadius = bounds.height / 2 + + if let color = tabColor.displayColor { + alphaValue = 1 + layer.backgroundColor = color.cgColor + layer.borderWidth = 0 + layer.borderColor = nil + } else { + alphaValue = 0 + layer.backgroundColor = NSColor.clear.cgColor + layer.borderWidth = 0 + layer.borderColor = nil + } + } +} + +private final class TabColorPaletteView: NSView { + private let stackView = NSStackView() + private var selectedColor: TerminalWindow.TabColor + private let selectionHandler: (TerminalWindow.TabColor) -> Void + private var buttons: [NSButton] = [] + + init(selectedColor: TerminalWindow.TabColor, + selectionHandler: @escaping (TerminalWindow.TabColor) -> Void) { + self.selectedColor = selectedColor + self.selectionHandler = selectionHandler + super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) + + stackView.orientation = .vertical + stackView.spacing = 6 + addSubview(stackView) + + for row in TerminalWindow.TabColor.paletteRows { + let rowStack = NSStackView() + rowStack.orientation = .horizontal + rowStack.spacing = 6 + + for color in row { + let button = makeButton(for: color) + rowStack.addArrangedSubview(button) + buttons.append(button) + } + + stackView.addArrangedSubview(rowStack) + } + + translatesAutoresizingMaskIntoConstraints = true + setFrameSize(intrinsicContentSize) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 190, height: 70) + } + + override func layout() { + super.layout() + stackView.frame = bounds.insetBy(dx: 10, dy: 6) + } + + private func makeButton(for color: TerminalWindow.TabColor) -> NSButton { + let button = NSButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyUpOrDown + button.image = color.swatchImage(selected: color == selectedColor) + button.setButtonType(.momentaryChange) + button.isBordered = false + button.focusRingType = .none + button.target = self + button.action = #selector(onSelectColor(_:)) + button.tag = color.rawValue + button.toolTip = color.localizedName + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 24), + button.heightAnchor.constraint(equalToConstant: 24) + ]) + + return button + } + + @objc private func onSelectColor(_ sender: NSButton) { + guard let color = TerminalWindow.TabColor(rawValue: sender.tag) else { return } + selectedColor = color + updateButtonImages() + selectionHandler(color) + } + + private func updateButtonImages() { + for button in buttons { + guard let color = TerminalWindow.TabColor(rawValue: button.tag) else { continue } + button.image = color.swatchImage(selected: color == selectedColor) + } + } +} diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 7ddfa419f..0166047c0 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -10,20 +10,21 @@ extension NSMenu { /// - item: The menu item to insert. /// - action: The action selector to search for. The new item will be inserted after the first /// item with this action. - /// - Returns: `true` if the item was inserted after the specified action, `false` if the action - /// was not found and the item was not inserted. + /// - Returns: The index where the item was inserted, or `nil` if the action was not found + /// and the item was not inserted. @discardableResult - func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? { if let identifier = item.identifier, let existing = items.first(where: { $0.identifier == identifier }) { removeItem(existing) } guard let idx = items.firstIndex(where: { $0.action == action }) else { - return false + return nil } - insertItem(item, at: idx + 1) - return true + let insertionIndex = idx + 1 + insertItem(item, at: insertionIndex) + return UInt(insertionIndex) } } From 51589a4e0248256ff38a4ca3169f8db849195a4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 07:23:50 -0800 Subject: [PATCH 079/605] macos: move TerminalTabColor to its own file --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Terminal/TerminalController.swift | 6 +- .../Terminal/TerminalRestorable.swift | 2 +- .../Features/Terminal/TerminalTabColor.swift | 110 +++++++++++++++ .../Window Styles/TerminalWindow.swift | 131 ++---------------- 5 files changed, 126 insertions(+), 124 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalTabColor.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 31e812f0c..eb5d706c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ Features/Terminal/ErrorView.swift, Features/Terminal/TerminalController.swift, Features/Terminal/TerminalRestorable.swift, + Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1b2a31928..7941ae22e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -55,7 +55,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr private(set) var derivedConfig: DerivedConfig /// The accent color that should be rendered for this tab. - var tabColor: TerminalWindow.TabColor = .none { + var tabColor: TerminalTabColor = .none { didSet { guard tabColor != oldValue else { return } if let terminalWindow = window as? TerminalWindow { @@ -863,7 +863,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? - let tabColor: TerminalWindow.TabColor + let tabColor: TerminalTabColor } convenience init(_ ghostty: Ghostty.App, @@ -1196,7 +1196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } - func setTabColor(_ color: TerminalWindow.TabColor) { + func setTabColor(_ color: TerminalTabColor) { tabColor = color } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 852cad581..c527a01c1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -97,7 +97,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = TerminalWindow.TabColor(rawValue: state.tabColorRawValue) ?? .none + c.tabColor = TerminalTabColor(rawValue: state.tabColorRawValue) ?? .none // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift new file mode 100644 index 000000000..3d2b9c447 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -0,0 +1,110 @@ +import AppKit + +enum TerminalTabColor: Int, CaseIterable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + var localizedName: String { + switch self { + case .none: + return "None" + case .blue: + return "Blue" + case .purple: + return "Purple" + case .pink: + return "Pink" + case .red: + return "Red" + case .orange: + return "Orange" + case .yellow: + return "Yellow" + case .green: + return "Green" + case .teal: + return "Teal" + case .graphite: + return "Graphite" + } + } + + var displayColor: NSColor? { + switch self { + case .none: + return nil + case .blue: + return .systemBlue + case .purple: + return .systemPurple + case .pink: + return .systemPink + case .red: + return .systemRed + case .orange: + return .systemOrange + case .yellow: + return .systemYellow + case .green: + return .systemGreen + case .teal: + if #available(macOS 13.0, *) { + return .systemMint + } else { + return .systemTeal + } + case .graphite: + return .systemGray + } + } + + func swatchImage(selected: Bool) -> NSImage { + let size = NSSize(width: 18, height: 18) + return NSImage(size: size, flipped: false) { rect in + let circleRect = rect.insetBy(dx: 1, dy: 1) + let circlePath = NSBezierPath(ovalIn: circleRect) + + if let fillColor = self.displayColor { + fillColor.setFill() + circlePath.fill() + } else { + NSColor.clear.setFill() + circlePath.fill() + NSColor.quaternaryLabelColor.setStroke() + circlePath.lineWidth = 1 + circlePath.stroke() + } + + if self == .none { + let slash = NSBezierPath() + slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) + slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) + slash.lineWidth = 1.5 + NSColor.secondaryLabelColor.setStroke() + slash.stroke() + } + + if selected { + let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + highlight.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + highlight.stroke() + } + + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1361bbd1f..9e329b76e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -30,7 +30,7 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() private var tabMenuObserver: NSObjectProtocol? = nil - private var tabColorSelection: TabColor = .none { + private var tabColorSelection: TerminalTabColor = .none { didSet { tabColorIndicator.tabColor = tabColorSelection } } @@ -46,7 +46,7 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - func display(tabColor: TabColor) { + func display(tabColor: TerminalTabColor) { tabColorSelection = tabColor } @@ -741,119 +741,10 @@ extension TerminalWindow { } -extension TerminalWindow { - enum TabColor: Int, CaseIterable { - case none - case blue - case purple - case pink - case red - case orange - case yellow - case green - case teal - case graphite - static let paletteRows: [[TabColor]] = [ - [.none, .blue, .purple, .pink, .red], - [.orange, .yellow, .green, .teal, .graphite], - ] - - var localizedName: String { - switch self { - case .none: - return NSLocalizedString("None", comment: "Tab color option label") - case .blue: - return NSLocalizedString("Blue", comment: "Tab color option label") - case .purple: - return NSLocalizedString("Purple", comment: "Tab color option label") - case .pink: - return NSLocalizedString("Pink", comment: "Tab color option label") - case .red: - return NSLocalizedString("Red", comment: "Tab color option label") - case .orange: - return NSLocalizedString("Orange", comment: "Tab color option label") - case .yellow: - return NSLocalizedString("Yellow", comment: "Tab color option label") - case .green: - return NSLocalizedString("Green", comment: "Tab color option label") - case .teal: - return NSLocalizedString("Teal", comment: "Tab color option label") - case .graphite: - return NSLocalizedString("Graphite", comment: "Tab color option label") - } - } - - var displayColor: NSColor? { - switch self { - case .none: - return nil - case .blue: - return .systemBlue - case .purple: - return .systemPurple - case .pink: - return .systemPink - case .red: - return .systemRed - case .orange: - return .systemOrange - case .yellow: - return .systemYellow - case .green: - return .systemGreen - case .teal: - if #available(macOS 13.0, *) { - return .systemMint - } else { - return .systemTeal - } - case .graphite: - return .systemGray - } - } - - func swatchImage(selected: Bool) -> NSImage { - let size = NSSize(width: 18, height: 18) - return NSImage(size: size, flipped: false) { rect in - let circleRect = rect.insetBy(dx: 1, dy: 1) - let circlePath = NSBezierPath(ovalIn: circleRect) - - if let fillColor = self.displayColor { - fillColor.setFill() - circlePath.fill() - } else { - NSColor.clear.setFill() - circlePath.fill() - NSColor.quaternaryLabelColor.setStroke() - circlePath.lineWidth = 1 - circlePath.stroke() - } - - if self == .none { - let slash = NSBezierPath() - slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) - slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) - slash.lineWidth = 1.5 - NSColor.secondaryLabelColor.setStroke() - slash.stroke() - } - - if selected { - let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) - highlight.lineWidth = 2 - NSColor.controlAccentColor.setStroke() - highlight.stroke() - } - - return true - } - } - } -} private final class TabColorIndicator: NSView { - var tabColor: TerminalWindow.TabColor = .none { + var tabColor: TerminalTabColor = .none { didSet { updateAppearance() } } @@ -892,12 +783,12 @@ private final class TabColorIndicator: NSView { private final class TabColorPaletteView: NSView { private let stackView = NSStackView() - private var selectedColor: TerminalWindow.TabColor - private let selectionHandler: (TerminalWindow.TabColor) -> Void + private var selectedColor: TerminalTabColor + private let selectionHandler: (TerminalTabColor) -> Void private var buttons: [NSButton] = [] - init(selectedColor: TerminalWindow.TabColor, - selectionHandler: @escaping (TerminalWindow.TabColor) -> Void) { + init(selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void) { self.selectedColor = selectedColor self.selectionHandler = selectionHandler super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) @@ -906,7 +797,7 @@ private final class TabColorPaletteView: NSView { stackView.spacing = 6 addSubview(stackView) - for row in TerminalWindow.TabColor.paletteRows { + for row in TerminalTabColor.paletteRows { let rowStack = NSStackView() rowStack.orientation = .horizontal rowStack.spacing = 6 @@ -937,7 +828,7 @@ private final class TabColorPaletteView: NSView { stackView.frame = bounds.insetBy(dx: 10, dy: 6) } - private func makeButton(for color: TerminalWindow.TabColor) -> NSButton { + private func makeButton(for color: TerminalTabColor) -> NSButton { let button = NSButton() button.translatesAutoresizingMaskIntoConstraints = false button.imagePosition = .imageOnly @@ -960,7 +851,7 @@ private final class TabColorPaletteView: NSView { } @objc private func onSelectColor(_ sender: NSButton) { - guard let color = TerminalWindow.TabColor(rawValue: sender.tag) else { return } + guard let color = TerminalTabColor(rawValue: sender.tag) else { return } selectedColor = color updateButtonImages() selectionHandler(color) @@ -968,7 +859,7 @@ private final class TabColorPaletteView: NSView { private func updateButtonImages() { for button in buttons { - guard let color = TerminalWindow.TabColor(rawValue: button.tag) else { continue } + guard let color = TerminalTabColor(rawValue: button.tag) else { continue } button.image = color.swatchImage(selected: color == selectedColor) } } From 04913905a385e6c836783d7b70aa1cffed908293 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 07:24:46 -0800 Subject: [PATCH 080/605] macos: tab color is codable for restoration --- macos/Sources/Features/Terminal/TerminalRestorable.swift | 8 ++++---- macos/Sources/Features/Terminal/TerminalTabColor.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index c527a01c1..931739987 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -9,13 +9,13 @@ class TerminalRestorableState: Codable { let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? - let tabColorRawValue: Int + let tabColor: TerminalTabColor init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode - self.tabColorRawValue = controller.tabColor.rawValue + self.tabColor = controller.tabColor } init?(coder aDecoder: NSCoder) { @@ -33,7 +33,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode - self.tabColorRawValue = v.value.tabColorRawValue + self.tabColor = v.value.tabColor } func encode(with coder: NSCoder) { @@ -97,7 +97,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = TerminalTabColor(rawValue: state.tabColorRawValue) ?? .none + c.tabColor = state.tabColor // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 3d2b9c447..41e85eb7a 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,6 +1,6 @@ import AppKit -enum TerminalTabColor: Int, CaseIterable { +enum TerminalTabColor: Int, CaseIterable, Codable { case none case blue case purple From 6addccdeeb450fbee6661c36e3ccdbecd94e94d4 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 11 Dec 2025 10:48:28 -0500 Subject: [PATCH 081/605] Add shape Tai Tham vowels test --- src/font/shaper/coretext.zig | 149 +++++++++++++++++++++++++---------- src/terminal/Terminal.zig | 2 +- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 498b45799..32b7ab77b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -390,8 +390,8 @@ pub const Shaper = struct { y: f64 = 0, // For debugging positions, turn this on: - start_index: usize = 0, - end_index: usize = 0, + //start_index: usize = 0, + //end_index: usize = 0, } = .{}; // Clear our cell buf and make sure we have enough room for the whole @@ -450,53 +450,56 @@ pub const Shaper = struct { .y = run_offset.y, // For debugging positions, turn this on: - .start_index = index, - .end_index = index, + //.start_index = index, + //.end_index = index, }; - } else { - if (index < cell_offset.start_index) { - cell_offset.start_index = index; - } - if (index > cell_offset.end_index) { - cell_offset.end_index = index; - } + + // For debugging positions, turn this on: + //} else { + // if (index < cell_offset.start_index) { + // cell_offset.start_index = index; + // } + // if (index > cell_offset.end_index) { + // cell_offset.end_index = index; + // } } const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - const advance_x_offset = run_offset.x - cell_offset.x; - const advance_y_offset = run_offset.y - cell_offset.y; - const x_offset_diff = x_offset - advance_x_offset; - const y_offset_diff = y_offset - advance_y_offset; + // Ford debugging positions, turn this on: + //const advance_x_offset = run_offset.x - cell_offset.x; + //const advance_y_offset = run_offset.y - cell_offset.y; + //const x_offset_diff = x_offset - advance_x_offset; + //const y_offset_diff = y_offset - advance_y_offset; - if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { - var allocating = std.Io.Writer.Allocating.init(alloc); - const writer = &allocating.writer; - const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; - for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("\\u{{{x}}}", .{cp.codepoint}); - } - try writer.writeAll(" → "); - for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); - } - const formatted_cps = try allocating.toOwnedSlice(); + //if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + // var allocating = std.Io.Writer.Allocating.init(alloc); + // const writer = &allocating.writer; + // const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; + // for (codepoints) |cp| { + // if (cp.codepoint == 0) continue; // Skip surrogate pair padding + // try writer.print("\\u{{{x}}}", .{cp.codepoint}); + // } + // try writer.writeAll(" → "); + // for (codepoints) |cp| { + // if (cp.codepoint == 0) continue; // Skip surrogate pair padding + // try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + // } + // const formatted_cps = try allocating.toOwnedSlice(); - log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - cluster, - x_offset, - y_offset, - advance_x_offset, - advance_y_offset, - x_offset_diff, - y_offset_diff, - state.codepoints.items[index].codepoint, - formatted_cps, - }); - } + // log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + // cluster, + // x_offset, + // y_offset, + // advance_x_offset, + // advance_y_offset, + // x_offset_diff, + // y_offset_diff, + // state.codepoints.items[index].codepoint, + // formatted_cps, + // }); + //} self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), @@ -1332,7 +1335,7 @@ test "shape with empty cells in between" { } } -test "shape Chinese characters" { +test "shape Combining characters" { const testing = std.testing; const alloc = testing.allocator; @@ -1350,6 +1353,9 @@ test "shape Chinese characters" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + var s = t.vtStream(); defer s.deinit(); try s.nextSlice(buf[0..buf_idx]); @@ -1397,6 +1403,9 @@ test "shape Devanagari string" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + var s = t.vtStream(); defer s.deinit(); try s.nextSlice("अपार्टमेंट"); @@ -1429,6 +1438,62 @@ test "shape Devanagari string" { try testing.expect(try it.next(alloc) == null); } +test "shape Tai Tham vowels (position differs from advance)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + + // The first glyph renders in the next cell + try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); + try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9db6a8d..b0d43c192 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -8893,7 +8893,7 @@ test "Terminal: insertBlanks shift graphemes" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Disable grapheme clustering + // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); try t.printString("A"); From a0089702f18cb2be1ce0c9ab7f358b1a07a4b61e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:18:25 -0800 Subject: [PATCH 082/605] macos: convert tab color view to SwiftUI --- .../Features/Terminal/TerminalTabColor.swift | 72 ++++++++++++ .../Window Styles/TerminalWindow.swift | 110 +++--------------- 2 files changed, 89 insertions(+), 93 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 41e85eb7a..1af6aa10b 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI enum TerminalTabColor: Int, CaseIterable, Codable { case none @@ -108,3 +109,74 @@ enum TerminalTabColor: Int, CaseIterable, Codable { } } } + +// MARK: - Menu View + +/// A SwiftUI view displaying a color palette for tab color selection. +/// Used as a custom view inside an NSMenuItem in the tab context menu. +struct TabColorMenuView: View { + @State private var currentSelection: TerminalTabColor + let onSelect: (TerminalTabColor) -> Void + + init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) { + self._currentSelection = State(initialValue: selectedColor) + self.onSelect = onSelect + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(TerminalTabColor.paletteRows, id: \.self) { row in + HStack(spacing: 2) { + ForEach(row, id: \.self) { color in + TabColorSwatch( + color: color, + isSelected: color == currentSelection + ) { + currentSelection = color + onSelect(color) + } + } + } + } + } + .padding(.leading, Self.leadingPadding) + .padding(.trailing, 12) + .padding(.top, 4) + .padding(.bottom, 4) + } + + /// Leading padding to align with the menu's icon gutter. + /// macOS 26 introduced icons in menus, requiring additional padding. + private static var leadingPadding: CGFloat { + if #available(macOS 26.0, *) { + return 40 + } else { + return 12 + } + } +} + +/// A single color swatch button in the tab color palette. +private struct TabColorSwatch: View { + let color: TerminalTabColor + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if color == .none { + Image(systemName: isSelected ? "circle.slash" : "circle") + .foregroundStyle(.secondary) + } else if let displayColor = color.displayColor { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill") + .foregroundStyle(Color(nsColor: displayColor)) + } + } + .font(.system(size: 16)) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .help(color.localizedName) + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9e329b76e..ff3814b03 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -349,7 +349,7 @@ class TerminalWindow: NSWindow { } removeTabColorSection(from: menu) - insertTabColorSection(into: menu, startingAt: Int(insertionIndex) + 1) + appendTabColorSection(to: menu) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -383,33 +383,29 @@ class TerminalWindow: NSWindow { } } - private func insertTabColorSection(into menu: NSMenu, startingAt index: Int) { + private func appendTabColorSection(to menu: NSMenu) { guard let terminalController else { return } - var insertionIndex = index - let separator = NSMenuItem.separator() separator.identifier = Self.tabColorSeparatorIdentifier - menu.insertItem(separator, at: insertionIndex) - insertionIndex += 1 + menu.addItem(separator) let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") let headerItem = NSMenuItem() headerItem.identifier = Self.tabColorHeaderIdentifier headerItem.title = headerTitle headerItem.isEnabled = false - menu.insertItem(headerItem, at: insertionIndex) - insertionIndex += 1 + headerItem.setImageIfDesired(systemSymbolName: "eyedropper") + menu.addItem(headerItem) let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier - let paletteView = TabColorPaletteView( + paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection ) { [weak terminalController] color in terminalController?.setTabColor(color) } - paletteItem.view = paletteView - menu.insertItem(paletteItem, at: insertionIndex) + menu.addItem(paletteItem) } // MARK: Tab Key Equivalents @@ -781,86 +777,14 @@ private final class TabColorIndicator: NSView { } } -private final class TabColorPaletteView: NSView { - private let stackView = NSStackView() - private var selectedColor: TerminalTabColor - private let selectionHandler: (TerminalTabColor) -> Void - private var buttons: [NSButton] = [] - - init(selectedColor: TerminalTabColor, - selectionHandler: @escaping (TerminalTabColor) -> Void) { - self.selectedColor = selectedColor - self.selectionHandler = selectionHandler - super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) - - stackView.orientation = .vertical - stackView.spacing = 6 - addSubview(stackView) - - for row in TerminalTabColor.paletteRows { - let rowStack = NSStackView() - rowStack.orientation = .horizontal - rowStack.spacing = 6 - - for color in row { - let button = makeButton(for: color) - rowStack.addArrangedSubview(button) - buttons.append(button) - } - - stackView.addArrangedSubview(rowStack) - } - - translatesAutoresizingMaskIntoConstraints = true - setFrameSize(intrinsicContentSize) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 190, height: 70) - } - - override func layout() { - super.layout() - stackView.frame = bounds.insetBy(dx: 10, dy: 6) - } - - private func makeButton(for color: TerminalTabColor) -> NSButton { - let button = NSButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyUpOrDown - button.image = color.swatchImage(selected: color == selectedColor) - button.setButtonType(.momentaryChange) - button.isBordered = false - button.focusRingType = .none - button.target = self - button.action = #selector(onSelectColor(_:)) - button.tag = color.rawValue - button.toolTip = color.localizedName - - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: 24), - button.heightAnchor.constraint(equalToConstant: 24) - ]) - - return button - } - - @objc private func onSelectColor(_ sender: NSButton) { - guard let color = TerminalTabColor(rawValue: sender.tag) else { return } - selectedColor = color - updateButtonImages() - selectionHandler(color) - } - - private func updateButtonImages() { - for button in buttons { - guard let color = TerminalTabColor(rawValue: button.tag) else { continue } - button.image = color.swatchImage(selected: color == selectedColor) - } - } +private func makeTabColorPaletteView( + selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void +) -> NSView { + let hostingView = NSHostingView(rootView: TabColorMenuView( + selectedColor: selectedColor, + onSelect: selectionHandler + )) + hostingView.frame.size = hostingView.intrinsicContentSize + return hostingView } From f559bccc385acceb803a3edc07aa04146e3378ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:36:46 -0800 Subject: [PATCH 083/605] macos: clean up setting up the tab menu by using an NSMenu extension --- .../Window Styles/TerminalWindow.swift | 37 +++++-------------- .../Helpers/Extensions/NSMenu+Extension.swift | 12 ++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ff3814b03..d0c0f750e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -330,14 +330,9 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController item.setImageIfDesired(systemSymbolName: "xmark") - let insertionIndex: UInt - if let idx = menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) { - insertionIndex = idx - } else if let idx = menu.insertItem(item, after: NSSelectorFromString("performClose:")) { - insertionIndex = idx - } else { + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { menu.addItem(item) - insertionIndex = UInt(menu.items.count - 1) } // Other close items should have the xmark to match Safari on macOS 26 @@ -348,8 +343,7 @@ class TerminalWindow: NSWindow { } } - removeTabColorSection(from: menu) - appendTabColorSection(to: menu) + appendTabColorSection(to: menu, target: targetController) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -367,33 +361,20 @@ class TerminalWindow: NSWindow { return !selectorNames.isDisjoint(with: tabContextSelectors) } - - private func removeTabColorSection(from menu: NSMenu) { - let identifiers: Set = [ + private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, Self.tabColorHeaderIdentifier, Self.tabColorPaletteIdentifier - ] - - for (index, item) in menu.items.enumerated().reversed() { - guard let identifier = item.identifier else { continue } - if identifiers.contains(identifier) { - menu.removeItem(at: index) - } - } - } - - private func appendTabColorSection(to menu: NSMenu) { - guard let terminalController else { return } + ]) let separator = NSMenuItem.separator() separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") let headerItem = NSMenuItem() headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = headerTitle + headerItem.title = "Tab Color" headerItem.isEnabled = false headerItem.setImageIfDesired(systemSymbolName: "eyedropper") menu.addItem(headerItem) @@ -402,8 +383,8 @@ class TerminalWindow: NSWindow { paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection - ) { [weak terminalController] color in - terminalController?.setTabColor(color) + ) { [weak target] color in + target?.setTabColor(color) } menu.addItem(paletteItem) } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 0166047c0..82c0a3a41 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -27,4 +27,16 @@ extension NSMenu { insertItem(item, at: insertionIndex) return UInt(insertionIndex) } + + /// Removes all menu items whose identifier is in the given set. + /// + /// - Parameter identifiers: The set of identifiers to match for removal. + func removeItems(withIdentifiers identifiers: Set) { + for (index, item) in items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + removeItem(at: index) + } + } + } } From 1073e89a0dfcf4fe042f6160e6cce93aaed7ad24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:40:01 -0800 Subject: [PATCH 084/605] macos: move context menu stuff in terminal window down to an ext --- .../Window Styles/TerminalWindow.swift | 164 +++++++++--------- 1 file changed, 84 insertions(+), 80 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d0c0f750e..706165573 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -234,11 +234,6 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") - private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") - private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") - private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") - private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") - func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -314,81 +309,6 @@ class TerminalWindow: NSWindow { } } - private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { - guard isTabContextMenu(menu) else { return } - - // Get the target from an existing menu item. The native tab context menu items - // target the specific window/controller that was right-clicked, not the focused one. - // We need to use that same target so validation and action use the correct tab. - let targetController = menu.items - .first { $0.action == NSSelectorFromString("performClose:") } - .flatMap { $0.target as? NSWindow } - .flatMap { $0.windowController as? TerminalController } - - // Close tabs to the right - let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = targetController - item.setImageIfDesired(systemSymbolName: "xmark") - if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, - menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { - menu.addItem(item) - } - - // Other close items should have the xmark to match Safari on macOS 26 - for menuItem in menu.items { - if menuItem.action == NSSelectorFromString("performClose:") || - menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { - menuItem.setImageIfDesired(systemSymbolName: "xmark") - } - } - - appendTabColorSection(to: menu, target: targetController) - } - - private func isTabContextMenu(_ menu: NSMenu) -> Bool { - guard NSApp.keyWindow === self else { return false } - - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ - "performClose:", - "performCloseOtherTabs:", - "moveTabToNewWindow:", - "toggleTabOverview:" - ] - - let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) - } - - private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { - menu.removeItems(withIdentifiers: [ - Self.tabColorSeparatorIdentifier, - Self.tabColorHeaderIdentifier, - Self.tabColorPaletteIdentifier - ]) - - let separator = NSMenuItem.separator() - separator.identifier = Self.tabColorSeparatorIdentifier - menu.addItem(separator) - - let headerItem = NSMenuItem() - headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = "Tab Color" - headerItem.isEnabled = false - headerItem.setImageIfDesired(systemSymbolName: "eyedropper") - menu.addItem(headerItem) - - let paletteItem = NSMenuItem() - paletteItem.identifier = Self.tabColorPaletteIdentifier - paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColorSelection - ) { [weak target] color in - target?.setTabColor(color) - } - menu.addItem(paletteItem) - } - // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -758,6 +678,90 @@ private final class TabColorIndicator: NSView { } } +// MARK: - Tab Context Menu + +extension TerminalWindow { + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") + private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") + + func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } + + // Close tabs to the right + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = targetController + item.setImageIfDesired(systemSymbolName: "xmark") + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { + menu.addItem(item) + } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } + + appendTabColorSection(to: menu, target: targetController) + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) + } + + private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ + Self.tabColorSeparatorIdentifier, + Self.tabColorHeaderIdentifier, + Self.tabColorPaletteIdentifier + ]) + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.addItem(separator) + + let headerItem = NSMenuItem() + headerItem.identifier = Self.tabColorHeaderIdentifier + headerItem.title = "Tab Color" + headerItem.isEnabled = false + headerItem.setImageIfDesired(systemSymbolName: "eyedropper") + menu.addItem(headerItem) + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + paletteItem.view = makeTabColorPaletteView( + selectedColor: tabColorSelection + ) { [weak target] color in + target?.setTabColor(color) + } + menu.addItem(paletteItem) + } +} + private func makeTabColorPaletteView( selectedColor: TerminalTabColor, selectionHandler: @escaping (TerminalTabColor) -> Void From c83bf1de75a401b0eacc6417018c1f316dcf2b94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:50:12 -0800 Subject: [PATCH 085/605] macos: simplify terminal controller a bunch --- .../Terminal/TerminalController.swift | 24 ++++--------------- .../Terminal/TerminalRestorable.swift | 5 ++-- .../Window Styles/TerminalWindow.swift | 11 ++++++--- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7941ae22e..a980723ba 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,16 +54,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig - /// The accent color that should be rendered for this tab. - var tabColor: TerminalTabColor = .none { - didSet { - guard tabColor != oldValue else { return } - if let terminalWindow = window as? TerminalWindow { - terminalWindow.display(tabColor: tabColor) - } - window?.invalidateRestorableState() - } - } /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -870,12 +860,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) - self.tabColor = undoState.tabColor // Show the window and restore its frame showWindow(nil) if let window { window.setFrame(undoState.frame, display: true) + if let terminalWindow = window as? TerminalWindow { + terminalWindow.tabColor = undoState.tabColor + } // If we have a tab group and index, restore the tab to its original position if let tabGroup = undoState.tabGroup, @@ -912,7 +904,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), tabGroup: window.tabGroup, - tabColor: tabColor) + tabColor: (window as? TerminalWindow)?.tabColor ?? .none) } //MARK: - NSWindowController @@ -954,9 +946,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr delegate: self, )) - if let terminalWindow = window as? TerminalWindow { - terminalWindow.display(tabColor: tabColor) - } // If we have a default size, we want to apply it. if let defaultSize { switch (defaultSize) { @@ -1195,11 +1184,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - func setTabColor(_ color: TerminalTabColor) { - tabColor = color - } - @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 931739987..ce13f2620 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -15,7 +15,7 @@ class TerminalRestorableState: Codable { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode - self.tabColor = controller.tabColor + self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none } init?(coder aDecoder: NSCoder) { @@ -97,7 +97,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = state.tabColor + // Restore our tab color + (window as? TerminalWindow)?.tabColor = state.tabColor // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 706165573..2828a9c56 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -46,8 +46,13 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - func display(tabColor: TerminalTabColor) { - tabColorSelection = tabColor + var tabColor: TerminalTabColor { + get { tabColorSelection } + set { + guard tabColorSelection != newValue else { return } + tabColorSelection = newValue + invalidateRestorableState() + } } // MARK: NSWindow Overrides @@ -756,7 +761,7 @@ extension TerminalWindow { paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection ) { [weak target] color in - target?.setTabColor(color) + (target?.window as? TerminalWindow)?.tabColor = color } menu.addItem(paletteItem) } From f71a25a62113b6646ae8ce68dae8382205ef829a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:53:56 -0800 Subject: [PATCH 086/605] macos: make the tab color indicator SwiftUI --- .../Window Styles/TerminalWindow.swift | 75 +++++++------------ 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2828a9c56..5874f354e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -25,14 +25,17 @@ class TerminalWindow: NSWindow { private let updateAccessory = NSTitlebarAccessoryViewController() /// Visual indicator that mirrors the selected tab color. - private let tabColorIndicator = TabColorIndicator() + private lazy var tabColorIndicator: NSHostingView = { + let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor)) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? = nil - private var tabColorSelection: TerminalTabColor = .none { - didSet { tabColorIndicator.tabColor = tabColorSelection } - } /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -46,11 +49,12 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - var tabColor: TerminalTabColor { - get { tabColorSelection } - set { - guard tabColorSelection != newValue else { return } - tabColorSelection = newValue + /// The color assigned to this window's tab. Setting this updates the tab color indicator + /// and marks the window's restorable state as dirty. + var tabColor: TerminalTabColor = .none { + didSet { + guard tabColor != oldValue else { return } + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) invalidateRestorableState() } } @@ -146,10 +150,7 @@ class TerminalWindow: NSWindow { // 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. - tabColorIndicator.translatesAutoresizingMaskIntoConstraints = false - tabColorIndicator.widthAnchor.constraint(equalToConstant: 12).isActive = true - tabColorIndicator.heightAnchor.constraint(equalToConstant: 4).isActive = true - tabColorIndicator.tabColor = tabColorSelection + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) let stackView = NSStackView() stackView.orientation = .horizontal @@ -643,42 +644,22 @@ extension TerminalWindow { } +/// A pill-shaped visual indicator displayed in the tab accessory view that shows +/// the user-assigned tab color. When no color is set, the view is hidden. +private struct TabColorIndicatorView: View { + /// The tab color to display. + let tabColor: TerminalTabColor - -private final class TabColorIndicator: NSView { - var tabColor: TerminalTabColor = .none { - didSet { updateAppearance() } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - updateAppearance() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layout() { - super.layout() - updateAppearance() - } - - private func updateAppearance() { - guard let layer else { return } - layer.cornerRadius = bounds.height / 2 - + var body: some View { if let color = tabColor.displayColor { - alphaValue = 1 - layer.backgroundColor = color.cgColor - layer.borderWidth = 0 - layer.borderColor = nil + Capsule() + .fill(Color(color)) + .frame(width: 12, height: 4) } else { - alphaValue = 0 - layer.backgroundColor = NSColor.clear.cgColor - layer.borderWidth = 0 - layer.borderColor = nil + Capsule() + .fill(Color.clear) + .frame(width: 12, height: 4) + .hidden() } } } @@ -759,7 +740,7 @@ extension TerminalWindow { let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColorSelection + selectedColor: tabColor ) { [weak target] color in (target?.window as? TerminalWindow)?.tabColor = color } From 6332fb5c0197daf45c585b4752f85c8e55d86095 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:59:06 -0800 Subject: [PATCH 087/605] macos: some cleanup --- .../Sources/Features/Terminal/TerminalTabColor.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 1af6aa10b..9059d3202 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -13,11 +13,6 @@ enum TerminalTabColor: Int, CaseIterable, Codable { case teal case graphite - static let paletteRows: [[TerminalTabColor]] = [ - [.none, .blue, .purple, .pink, .red], - [.orange, .yellow, .green, .teal, .graphite], - ] - var localizedName: String { switch self { case .none: @@ -125,7 +120,7 @@ struct TabColorMenuView: View { var body: some View { VStack(alignment: .leading, spacing: 3) { - ForEach(TerminalTabColor.paletteRows, id: \.self) { row in + ForEach(Self.paletteRows, id: \.self) { row in HStack(spacing: 2) { ForEach(row, id: \.self) { color in TabColorSwatch( @@ -144,6 +139,11 @@ struct TabColorMenuView: View { .padding(.top, 4) .padding(.bottom, 4) } + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] /// Leading padding to align with the menu's icon gutter. /// macOS 26 introduced icons in menus, requiring additional padding. From c0deaaba4e9e3bd97eccab33c33008c6734cee97 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 11 Dec 2025 16:50:27 -0500 Subject: [PATCH 088/605] bash: use a shell command for shell integration Prior to #7044, on macOS, our shell-integrated command line would be executed under exec -l, which causes bash to be started as a login shell. This matches the macOS platform norms. The change to direct command execution meant that we'd skip that path, and bash would start as a normal interactive (non-login) shell on macOS. We fixed this in #7253 by adding `--login` to the `bash` direct command on macOS. This avoided some of the overhead of starting an extra process just to get a login shell, but it unfortunately doesn't quite match the bash environment we get when shell integration isn't enabled (namely, $0 doesn't get the login-shell-identifying "-" prefix). Instead, this change implements the approach proposed in #7254, which switches the bash shell integration path to use a .shell command, giving us the same execution environment as the non-shell-integrated command. --- src/termio/shell_integration.zig | 73 +++++--------------------------- 1 file changed, 10 insertions(+), 63 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index c2a637b80..a79e38639 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,7 +259,7 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 3); + var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); defer args.deinit(alloc); // Iterator that yields each argument in the original command line. @@ -273,11 +273,6 @@ fn setupBash( } else return null; try args.append(alloc, "--posix"); - // On macOS, we request a login shell to match that platform's norms. - if (comptime builtin.target.os.tag.isDarwin()) { - try args.append(alloc, "--login"); - } - // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile // We always include at least "1" so the script can differentiate between @@ -357,9 +352,8 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Since we built up a command line, we don't need to wrap it in - // ANOTHER shell anymore and can do a direct command. - return .{ .direct = try args.toOwnedSlice(alloc) }; + // Join the accumulated arguments to form the final command string. + return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; } test "bash" { @@ -373,12 +367,7 @@ test "bash" { const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); } @@ -421,12 +410,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?); } @@ -437,12 +421,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); } } @@ -459,24 +438,14 @@ test "bash: rcfile" { // bash --rcfile { const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } // bash --init-file { const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } } @@ -538,35 +507,13 @@ test "bash: additional arguments" { // "-" argument separator { const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("-", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); } // "--" argument separator { const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("--", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); } } From 2331e178351c92363dcb7b100533ffe8aa18ea3d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:12:24 -0800 Subject: [PATCH 089/605] macos: change tab color label to circle --- .../Terminal/Window Styles/TerminalWindow.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 5874f354e..3db1b275b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -644,7 +644,7 @@ extension TerminalWindow { } -/// A pill-shaped visual indicator displayed in the tab accessory view that shows +/// A small circle indicator displayed in the tab accessory view that shows /// the user-assigned tab color. When no color is set, the view is hidden. private struct TabColorIndicatorView: View { /// The tab color to display. @@ -652,13 +652,13 @@ private struct TabColorIndicatorView: View { var body: some View { if let color = tabColor.displayColor { - Capsule() + Circle() .fill(Color(color)) - .frame(width: 12, height: 4) + .frame(width: 6, height: 6) } else { - Capsule() + Circle() .fill(Color.clear) - .frame(width: 12, height: 4) + .frame(width: 6, height: 6) .hidden() } } From 89bdee447f64a66806b54ae6b52f2edc04920819 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:33:50 -0800 Subject: [PATCH 090/605] macos: selected color in tab color menu should use target's color --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 3db1b275b..ab90f7072 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -740,7 +740,7 @@ extension TerminalWindow { let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColor + selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none ) { [weak target] color in (target?.window as? TerminalWindow)?.tabColor = color } From 4d757f0f28379f8392370374a8b80dcfa4c44639 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:43:21 -0800 Subject: [PATCH 091/605] macos: show tab color as header for menu item so its not grey --- macos/Sources/Features/Terminal/TerminalTabColor.swift | 3 +++ .../Terminal/Window Styles/TerminalWindow.swift | 10 +--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 9059d3202..08d89324c 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -120,6 +120,9 @@ struct TabColorMenuView: View { var body: some View { VStack(alignment: .leading, spacing: 3) { + Text("Tab Color") + .padding(.bottom, 2) + ForEach(Self.paletteRows, id: \.self) { row in HStack(spacing: 2) { ForEach(row, id: \.self) { color in diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ab90f7072..5bbf9322d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -669,7 +669,7 @@ private struct TabColorIndicatorView: View { extension TerminalWindow { private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") - private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") func configureTabContextMenuIfNeeded(_ menu: NSMenu) { @@ -722,7 +722,6 @@ extension TerminalWindow { private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, - Self.tabColorHeaderIdentifier, Self.tabColorPaletteIdentifier ]) @@ -730,13 +729,6 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - let headerItem = NSMenuItem() - headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = "Tab Color" - headerItem.isEnabled = false - headerItem.setImageIfDesired(systemSymbolName: "eyedropper") - menu.addItem(headerItem) - let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( From 32033c9e1a66cd08d924a15fd88057fab41b1d8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:00:13 -0800 Subject: [PATCH 092/605] core: prompt_tab_title binding and apprt action --- include/ghostty.h | 3 ++- src/Surface.zig | 6 ++++++ src/apprt/action.zig | 6 ++++++ src/input/Binding.zig | 6 ++++++ src/input/command.zig | 6 ++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index cb8646560..416db8bab 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -804,6 +804,7 @@ typedef enum { GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, + GHOSTTY_ACTION_PROMPT_TAB_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, GHOSTTY_ACTION_MOUSE_VISIBILITY, @@ -831,7 +832,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, -} ghostty_action_tag_e; + } ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; diff --git a/src/Surface.zig b/src/Surface.zig index 9e7ad0b97..b1919e13f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5186,6 +5186,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .prompt_tab_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_tab_title, + {}, + ), + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 365f525f8..f29150f13 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -192,6 +192,11 @@ pub const Action = union(Key) { /// the apprt to prompt. prompt_title, + /// Set the title of the current tab/window to a prompted value. The title + /// set via this prompt overrides any title set by the terminal and persists + /// across focus changes within the tab. It is up to the apprt to prompt. + prompt_tab_title, + /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -347,6 +352,7 @@ pub const Action = union(Key) { desktop_notification, set_title, prompt_title, + prompt_tab_title, pwd, mouse_shape, mouse_visibility, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 66fe03651..e1c636ab7 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -519,6 +519,11 @@ pub const Action = union(enum) { /// version can be found by running `ghostty +version`. prompt_surface_title, + /// Change the title of the current tab/window via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + prompt_tab_title, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1191,6 +1196,7 @@ pub const Action = union(enum) { .reset_font_size, .set_font_size, .prompt_surface_title, + .prompt_tab_title, .clear_screen, .select_all, .scroll_to_top, diff --git a/src/input/command.zig b/src/input/command.zig index b3f9e86b6..120c7e7e0 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -417,6 +417,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Prompt for a new title for the current terminal.", }}, + .prompt_tab_title => comptime &.{.{ + .action = .prompt_tab_title, + .title = "Change Tab Title...", + .description = "Prompt for a new title for the current tab.", + }}, + .new_split => comptime &.{ .{ .action = .{ .new_split = .left }, From e93a4a911f2631d79b8673b8e6d974f44b7b78a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:00:13 -0800 Subject: [PATCH 093/605] macos: implement prompt_tab_title --- .../Terminal/BaseTerminalController.swift | 49 +++++++++++++++++-- macos/Sources/Ghostty/Ghostty.App.swift | 29 +++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1c8e258f7..c60f0ee1d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -81,6 +81,15 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// An override title for the tab/window set by the user via prompt_tab_title. + /// When set, this takes precedence over the computed title from the terminal. + var tabTitleOverride: String? = nil { + didSet { applyTitleToWindow() } + } + + /// The last computed title from the focused surface (without the override). + private var lastComputedTitle: String = "👻" + /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout @@ -325,6 +334,37 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + /// Prompt the user to change the tab/window title. + func promptTabTitle() { + guard let window else { return } + + let alert = NSAlert() + alert.messageText = "Change Tab Title" + alert.informativeText = "Leave blank to restore the default." + alert.alertStyle = .informational + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) + textField.stringValue = tabTitleOverride ?? window.title + alert.accessoryView = textField + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + alert.window.initialFirstResponder = textField + + alert.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + guard response == .alertFirstButtonReturn else { return } + + let newTitle = textField.stringValue + if newTitle.isEmpty { + self.tabTitleOverride = nil + } else { + self.tabTitleOverride = newTitle + } + } + } + /// Close a surface from a view. func closeSurface( _ view: Ghostty.SurfaceView, @@ -718,10 +758,13 @@ class BaseTerminalController: NSWindowController, } private func titleDidChange(to: String) { + lastComputedTitle = to + applyTitleToWindow() + } + + private func applyTitleToWindow() { guard let window else { return } - - // Set the main window title - window.title = to + window.title = tabTitleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f6452e54e..db98d56be 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -525,6 +525,9 @@ extension Ghostty { case GHOSTTY_ACTION_PROMPT_TITLE: return promptTitle(app, target: target) + case GHOSTTY_ACTION_PROMPT_TAB_TITLE: + return promptTabTitle(app, target: target) + case GHOSTTY_ACTION_PWD: pwdChanged(app, target: target, v: action.action.pwd) @@ -1368,6 +1371,32 @@ extension Ghostty { return true } + private static func promptTabTitle( + _ app: ghostty_app_t, + target: ghostty_target_s) -> Bool { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + default: + assertionFailure() + return false + } + } + private static func pwdChanged( _ app: ghostty_app_t, target: ghostty_target_s, From 1f05625d3fe3ae81c0662cca27c5bc4263415a9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:06:57 +0000 Subject: [PATCH 094/605] build(deps): bump cachix/install-nix-action from 31.8.4 to 31.9.0 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.4 to 31.9.0. - [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/0b0e072294b088b73964f1d72dfdac0951439dbd...4e002c8ec80594ecd40e759629461e26c8abed15) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 4 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f928ed5a5..d992ba034 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -47,7 +47,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 82970a065..c8c0fbf66 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@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index df73198d1..fb6aef87d 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -33,7 +33,7 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -166,7 +166,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20f674bab..18af9d909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,7 +84,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -127,7 +127,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -160,7 +160,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -194,7 +194,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -237,7 +237,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -273,7 +273,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -302,7 +302,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -335,7 +335,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -381,7 +381,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -600,7 +600,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -642,7 +642,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -690,7 +690,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -725,7 +725,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -789,7 +789,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -816,7 +816,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -844,7 +844,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -871,7 +871,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -898,7 +898,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -925,7 +925,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -952,7 +952,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -986,7 +986,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1013,7 +1013,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1050,7 +1050,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1138,7 +1138,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index bceb8aef1..dc3ebb2b6 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@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 65cf124e2c8f1dc3578efebb08ea7e8f8ac459d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:09:08 -0800 Subject: [PATCH 095/605] core: change apprt action to enum value instead of a new one --- include/ghostty.h | 8 ++- macos/Sources/Ghostty/Ghostty.Action.swift | 14 ++++ macos/Sources/Ghostty/Ghostty.App.swift | 79 +++++++++++----------- src/Surface.zig | 6 +- src/apprt/action.zig | 17 ++--- src/apprt/gtk/class/application.zig | 20 ++++-- 6 files changed, 85 insertions(+), 59 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 416db8bab..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -584,6 +584,12 @@ typedef struct { const char* title; } ghostty_action_set_title_s; +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, +} ghostty_action_prompt_title_e; + // apprt.action.Pwd.C typedef struct { const char* pwd; @@ -804,7 +810,6 @@ typedef enum { GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, - GHOSTTY_ACTION_PROMPT_TAB_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, GHOSTTY_ACTION_MOUSE_VISIBILITY, @@ -848,6 +853,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; ghostty_action_mouse_visibility_e mouse_visibility; diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 8fce2199d..9eb7a8e46 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -127,6 +127,20 @@ extension Ghostty.Action { } } } + + enum PromptTitle { + case surface + case tab + + init(_ c: ghostty_action_prompt_title_e) { + switch c { + case GHOSTTY_PROMPT_TITLE_TAB: + self = .tab + default: + self = .surface + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index db98d56be..aff3edbc7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -523,10 +523,7 @@ extension Ghostty { setTitle(app, target: target, v: action.action.set_title) case GHOSTTY_ACTION_PROMPT_TITLE: - return promptTitle(app, target: target) - - case GHOSTTY_ACTION_PROMPT_TAB_TITLE: - return promptTabTitle(app, target: target) + return promptTitle(app, target: target, v: action.action.prompt_title) case GHOSTTY_ACTION_PWD: pwdChanged(app, target: target, v: action.action.pwd) @@ -1353,47 +1350,49 @@ extension Ghostty { private static func promptTitle( _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("set title prompt does nothing with an app target") - return false + target: ghostty_target_s, + v: ghostty_action_prompt_title_e) -> Bool { + let promptTitle = Action.PromptTitle(v) + switch promptTitle { + case .surface: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set title prompt does nothing with an app target") + return false - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - surfaceView.promptTitle() + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + surfaceView.promptTitle() + return true - default: - assertionFailure() - } + default: + assertionFailure() + return false + } - return true - } + case .tab: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true - private static func promptTabTitle( - _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - guard let window = NSApp.mainWindow ?? NSApp.keyWindow, - let controller = window.windowController as? BaseTerminalController - else { return false } - controller.promptTabTitle() - return true + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - guard let window = surfaceView.window, - let controller = window.windowController as? BaseTerminalController - else { return false } - controller.promptTabTitle() - return true - - default: - assertionFailure() - return false + default: + assertionFailure() + return false + } } } diff --git a/src/Surface.zig b/src/Surface.zig index b1919e13f..8cd8d253b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5183,13 +5183,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .prompt_surface_title => return try self.rt_app.performAction( .{ .surface = self }, .prompt_title, - {}, + .surface, ), .prompt_tab_title => return try self.rt_app.performAction( .{ .surface = self }, - .prompt_tab_title, - {}, + .prompt_title, + .tab, ), .clear_screen => { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f29150f13..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -189,13 +189,9 @@ pub const Action = union(Key) { set_title: SetTitle, /// Set the title of the target to a prompted value. It is up to - /// the apprt to prompt. - prompt_title, - - /// Set the title of the current tab/window to a prompted value. The title - /// set via this prompt overrides any title set by the terminal and persists - /// across focus changes within the tab. It is up to the apprt to prompt. - prompt_tab_title, + /// the apprt to prompt. The value specifies whether to prompt for the + /// surface title or the tab title. + prompt_title: PromptTitle, /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -352,7 +348,6 @@ pub const Action = union(Key) { desktop_notification, set_title, prompt_title, - prompt_tab_title, pwd, mouse_shape, mouse_visibility, @@ -542,6 +537,12 @@ pub const MouseVisibility = enum(c_int) { hidden, }; +/// Whether to prompt for the surface title or tab title. +pub const PromptTitle = enum(c_int) { + surface, + tab, +}; + pub const MouseOverLink = struct { url: [:0]const u8, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 52a9f1a35..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -693,7 +693,7 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), - .prompt_title => return Action.promptTitle(target), + .prompt_title => return Action.promptTitle(target, value), .quit => self.quit(), @@ -2250,12 +2250,18 @@ const Action = struct { }; } - pub fn promptTitle(target: apprt.Target) bool { - switch (target) { - .app => return false, - .surface => |v| { - v.rt_surface.surface.promptTitle(); - return true; + pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + // GTK does not yet support tab title prompting + return false; }, } } From 7b48eb5c6296e8dd62066395312c452c09a0b93c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:12:48 -0800 Subject: [PATCH 096/605] macos: add change tab title to menu --- macos/Sources/App/macOS/AppDelegate.swift | 4 +++- macos/Sources/App/macOS/MainMenu.xib | 9 ++++++++- .../Features/Terminal/BaseTerminalController.swift | 4 ++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 192135c15..8baee3d89 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -68,6 +68,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? + @IBOutlet private var menuChangeTabTitle: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? @@ -541,7 +542,7 @@ class AppDelegate: NSObject, 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.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") @@ -609,6 +610,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 3e1084cd7..d009b9c62 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -16,6 +16,7 @@ + @@ -315,7 +316,13 @@ - + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index c60f0ee1d..05e3c8142 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1060,6 +1060,10 @@ class BaseTerminalController: NSWindowController, window.performClose(sender) } + @IBAction func changeTabTitle(_ sender: Any) { + promptTabTitle() + } + @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) From 65c5e72d3e17dbdfc2a45beb152e86bc301c6ac7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:22:22 -0800 Subject: [PATCH 097/605] macos: add tab title change to tab context menu --- .../Terminal/Window Styles/TerminalWindow.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 5bbf9322d..d04d7001c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -668,6 +668,7 @@ private struct TabColorIndicatorView: View { extension TerminalWindow { private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem") private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") @@ -701,7 +702,7 @@ extension TerminalWindow { } } - appendTabColorSection(to: menu, target: targetController) + appendTabModifierSection(to: menu, target: targetController) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -719,9 +720,10 @@ extension TerminalWindow { return !selectorNames.isDisjoint(with: tabContextSelectors) } - private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, + Self.changeTitleMenuItemIdentifier, Self.tabColorPaletteIdentifier ]) @@ -729,6 +731,13 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) + // Change Title... + let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier + changeTitleItem.target = target + changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") + menu.addItem(changeTitleItem) + let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( From 6105344c31569ac6442c21dbaf97e48098f8c9e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:28:10 -0800 Subject: [PATCH 098/605] macos: add change tab title to right click menu --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 ++- src/input/command.zig | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e86df4454..130df6f44 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1417,8 +1417,9 @@ extension Ghostty { item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") + item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") return menu } diff --git a/src/input/command.zig b/src/input/command.zig index 120c7e7e0..639fc6e39 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -413,7 +413,7 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Title...", + .title = "Change Terminal Title...", .description = "Prompt for a new title for the current terminal.", }}, From 50bbced0c94e586fda68cab1a7299c77947fd9fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:40:09 -0800 Subject: [PATCH 099/605] macos: add title override to restorable state --- .../Features/Terminal/BaseTerminalController.swift | 10 +++++----- .../Sources/Features/Terminal/TerminalRestorable.swift | 8 +++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 05e3c8142..6336f0f55 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -83,7 +83,7 @@ class BaseTerminalController: NSWindowController, /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. - var tabTitleOverride: String? = nil { + var titleOverride: String? = nil { didSet { applyTitleToWindow() } } @@ -344,7 +344,7 @@ class BaseTerminalController: NSWindowController, alert.alertStyle = .informational let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) - textField.stringValue = tabTitleOverride ?? window.title + textField.stringValue = titleOverride ?? window.title alert.accessoryView = textField alert.addButton(withTitle: "OK") @@ -358,9 +358,9 @@ class BaseTerminalController: NSWindowController, let newTitle = textField.stringValue if newTitle.isEmpty { - self.tabTitleOverride = nil + self.titleOverride = nil } else { - self.tabTitleOverride = newTitle + self.titleOverride = newTitle } } } @@ -764,7 +764,7 @@ class BaseTerminalController: NSWindowController, private func applyTitleToWindow() { guard let window else { return } - window.title = tabTitleOverride ?? lastComputedTitle + window.title = titleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index ce13f2620..425f7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,18 +4,20 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 6 + static let version: Int = 7 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? let tabColor: TerminalTabColor + let titleOverride: String? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none + self.titleOverride = controller.titleOverride } init?(coder aDecoder: NSCoder) { @@ -34,6 +36,7 @@ class TerminalRestorableState: Codable { self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode self.tabColor = v.value.tabColor + self.titleOverride = v.value.titleOverride } func encode(with coder: NSCoder) { @@ -100,6 +103,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Restore our tab color (window as? TerminalWindow)?.tabColor = state.tabColor + // Restore the tab title override + c.titleOverride = state.titleOverride + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { From 65539d0d54faef71d49afc23a7b6fd0a875d2bcb Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:21:17 +0800 Subject: [PATCH 100/605] CONTRIBUTING: limit AI assistance to code only I think at this point all moderators and helpers can agree with me in that LLM-generated responses are a blight upon this Earth. Also probably worth putting in a clause against AI-generated assets (cf. the Commit Goods situation) --- CONTRIBUTING.md | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4285f42f..a5f9213c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,38 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -As a small exception, trivial tab-completion doesn't need to be disclosed, -so long as it is limited to single keywords or short phrases. + +**We currently restrict AI assistance to code changes only.** +No AI-generated media, e.g. artwork, icons, videos and other assets is +allowed, as it goes against the methodology and ethos behind Ghostty. +While AI-assisted code can help with productive prototyping, creative +inspiration and even automated bugfinding, we have currently found zero +benefit to AI-generated assets. Instead, we are far more interested and +invested in funding professional work done by human designers and artists. +If you intend to submit AI-generated assets to Ghostty, sorry, +**we are not interested**. + +Likewise, all community interactions, including all comments on issues and +discussions and all PR titles and descriptions **must be composed by a human**. +Community moderators and Ghostty maintainers reserve the right to mark +AI-generated responses as spam or disruptive content, and ban users who have +been repeatedly caught relying entirely on LLMs during interactions. + +> [!NOTE] +> If your English isn't the best and you are currently relying on an LLM to +> translate your responses, don't fret — usually we maintainers will be able +> to understand your messages well enough. We'd like to encourage real humans +> to interact with each other more, and the positive impact of genuine, +> responsive yet imperfect human interaction more than makes up for any +> language barrier. +> +> Please write your responses yourself, to the best of your ability. +> We greatly appreciate it. Thank you. ❤️ + +Minor exceptions to this policy include trivial AI-generated tab completion +functionality, as it usually does not impact the quality of the code and +do not need to be disclosed, and commit titles and messages, which are often +generated by AI coding agents. The submitter must have also tested the pull request on all impacted platforms, and it's **highly discouraged** to code for an unfamiliar platform @@ -32,11 +62,6 @@ with AI assistance alone: if you only have a macOS machine, do **not** ask AI to write the equivalent GTK code, and vice versa — someone else with more expertise will eventually get to it and do it for you. -Even though using AI to generate responses on a PR is allowed when properly -disclosed, **we do not encourage you to do so**. Often, the positive impact -of genuine, responsive human interaction more than makes up for any language -barrier. ❤️ - An example disclosure: > This PR was written primarily by Claude Code. From 5e049e1b3af15db4878104b87eb7646caa1fd356 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:46:05 +0800 Subject: [PATCH 101/605] CONTRIBUTING: AI-assisted != AI-generated --- CONTRIBUTING.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5f9213c0..75aa42676 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,22 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. > [!IMPORTANT] > -> If you are using **any kind of AI assistance** to contribute to Ghostty, -> it must be disclosed in the pull request. +> The Ghostty project allows AI-**assisted** _code contributions_, which +> must be properly disclosed in the pull request. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -**We currently restrict AI assistance to code changes only.** +**Note that AI _assistance_ does not equal AI _generation_**. We require +a significant amount of human accountability, involvement and interaction +even within AI-assisted contributions. Contributors are required to be able +to understand the AI-assisted output, and be able to reason with it and +answer critical questions about it. Should a PR see no visible human +accountability and involvement, or it is so broken that it requires significant +rework to be acceptable, **we reserve the right to close it without hesitation**. + +**In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is allowed, as it goes against the methodology and ethos behind Ghostty. While AI-assisted code can help with productive prototyping, creative @@ -32,7 +40,7 @@ inspiration and even automated bugfinding, we have currently found zero benefit to AI-generated assets. Instead, we are far more interested and invested in funding professional work done by human designers and artists. If you intend to submit AI-generated assets to Ghostty, sorry, -**we are not interested**. +we are not interested. Likewise, all community interactions, including all comments on issues and discussions and all PR titles and descriptions **must be composed by a human**. @@ -85,13 +93,6 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect a fairly high level of accountability -and responsibility from contributors, and expect them to understand the code -that is produced and be able to answer critical questions about it. It -isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable, and we **reserve the right to close -these PRs without hesitation**. - Please be respectful to maintainers and disclose AI assistance. ## Quick Guide From 8a1bb215c13e27f16e46d74bf59a48fc730d9b1b Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:54:22 +0800 Subject: [PATCH 102/605] CONTRIBUTING: further clarifications --- CONTRIBUTING.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75aa42676..d5fb606b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,10 +27,10 @@ which AI assistance was used (e.g. docs only vs. code generation). **Note that AI _assistance_ does not equal AI _generation_**. We require a significant amount of human accountability, involvement and interaction even within AI-assisted contributions. Contributors are required to be able -to understand the AI-assisted output, and be able to reason with it and -answer critical questions about it. Should a PR see no visible human -accountability and involvement, or it is so broken that it requires significant -rework to be acceptable, **we reserve the right to close it without hesitation**. +to understand the AI-assisted output, reason with it and answer critical +questions about it. Should a PR see no visible human accountability and +involvement, or it is so broken that it requires significant rework to be +acceptable, **we reserve the right to close it without hesitation**. **In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is @@ -57,6 +57,9 @@ been repeatedly caught relying entirely on LLMs during interactions. > language barrier. > > Please write your responses yourself, to the best of your ability. +> If you do feel the need to polish your sentences, however, please use +> dedicated translation software rather than an LLM. +> > We greatly appreciate it. Thank you. ❤️ Minor exceptions to this policy include trivial AI-generated tab completion From 315c8852a8e4746dd352486486abf8ab982ad87d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:58:52 +0800 Subject: [PATCH 103/605] CONTRIBUTING: reorganize paragraphs --- CONTRIBUTING.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5fb606b4..8b8c4d7f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,20 @@ If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -**Note that AI _assistance_ does not equal AI _generation_**. We require -a significant amount of human accountability, involvement and interaction -even within AI-assisted contributions. Contributors are required to be able -to understand the AI-assisted output, reason with it and answer critical -questions about it. Should a PR see no visible human accountability and -involvement, or it is so broken that it requires significant rework to be -acceptable, **we reserve the right to close it without hesitation**. +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +> [!WARNING] +> **Note that AI _assistance_ does not equal AI _generation_**. We require +> a significant amount of human accountability, involvement and interaction +> even within AI-assisted contributions. Contributors are required to be able +> to understand the AI-assisted output, reason with it and answer critical +> questions about it. Should a PR see no visible human accountability and +> involvement, or it is so broken that it requires significant rework to be +> acceptable, **we reserve the right to close it without hesitation**. **In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is @@ -67,12 +74,6 @@ functionality, as it usually does not impact the quality of the code and do not need to be disclosed, and commit titles and messages, which are often generated by AI coding agents. -The submitter must have also tested the pull request on all impacted -platforms, and it's **highly discouraged** to code for an unfamiliar platform -with AI assistance alone: if you only have a macOS machine, do **not** ask AI -to write the equivalent GTK code, and vice versa — someone else with more -expertise will eventually get to it and do it for you. - An example disclosure: > This PR was written primarily by Claude Code. From 04fecd7c07fccad423ab1c33324a1997e142b6e2 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 11 Dec 2025 21:02:42 -0500 Subject: [PATCH 104/605] os/shell: introduce ShellCommandBuilder This builder is an efficient way to construct space-separated shell command strings. We use it in setupBash to avoid using an intermediate array of arguments to construct our bash command line. --- src/os/shell.zig | 77 ++++++++++++++++++++++++++++++++ src/termio/shell_integration.zig | 20 ++++----- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/os/shell.zig b/src/os/shell.zig index 9fce3e385..fe8f1b2fd 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -1,7 +1,84 @@ const std = @import("std"); const testing = std.testing; +const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; +/// Builder for constructing space-separated shell command strings. +/// Uses a caller-provided allocator (typically with stackFallback). +pub const ShellCommandBuilder = struct { + buffer: std.Io.Writer.Allocating, + + pub fn init(allocator: Allocator) ShellCommandBuilder { + return .{ .buffer = .init(allocator) }; + } + + pub fn deinit(self: *ShellCommandBuilder) void { + self.buffer.deinit(); + } + + /// Append an argument to the command with automatic space separation. + pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void { + if (arg.len == 0) return; + if (self.buffer.written().len > 0) { + try self.buffer.writer.writeByte(' '); + } + try self.buffer.writer.writeAll(arg); + } + + /// Get the final null-terminated command string, transferring ownership to caller. + /// Calling deinit() after this is safe but unnecessary. + pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 { + return try self.buffer.toOwnedSliceSentinel(0); + } +}; + +test ShellCommandBuilder { + // Empty command + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try testing.expectEqualStrings("", cmd.buffer.written()); + } + + // Single arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // Multiple args + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + try cmd.appendArg("-l"); + try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written()); + } + + // Empty arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg(""); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // toOwnedSlice + { + var cmd = ShellCommandBuilder.init(testing.allocator); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + const result = try cmd.toOwnedSlice(); + defer testing.allocator.free(result); + try testing.expectEqualStrings("bash --posix", result); + try testing.expectEqual(@as(u8, 0), result[result.len]); + } +} + /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index a79e38639..128b345ea 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,8 +259,9 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); - defer args.deinit(alloc); + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); // Iterator that yields each argument in the original command line. // This will allocate once proportionate to the command line length. @@ -269,9 +270,9 @@ fn setupBash( // Start accumulating arguments with the executable and initial flags. if (iter.next()) |exe| { - try args.append(alloc, try alloc.dupeZ(u8, exe)); + try cmd.appendArg(exe); } else return null; - try args.append(alloc, "--posix"); + try cmd.appendArg("--posix"); // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile @@ -304,17 +305,17 @@ fn setupBash( if (std.mem.indexOfScalar(u8, arg, 'c') != null) { return null; } - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { // All remaining arguments should be passed directly to the shell // command. We shouldn't perform any further option processing. - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); while (iter.next()) |remaining_arg| { - try args.append(alloc, try alloc.dupeZ(u8, remaining_arg)); + try cmd.appendArg(remaining_arg); } break; } else { - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } } try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); @@ -352,8 +353,7 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Join the accumulated arguments to form the final command string. - return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; + return .{ .shell = try cmd.toOwnedSlice() }; } test "bash" { From 075ef6980bfaa8f6c196bdc2124e78eaccd391bb Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Fri, 12 Dec 2025 09:27:45 -0500 Subject: [PATCH 105/605] Fix comment typo --- src/font/shaper/coretext.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 32b7ab77b..15ac5762b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -467,7 +467,7 @@ pub const Shaper = struct { const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - // Ford debugging positions, turn this on: + // For debugging positions, turn this on: //const advance_x_offset = run_offset.x - cell_offset.x; //const advance_y_offset = run_offset.y - cell_offset.y; //const x_offset_diff = x_offset - advance_x_offset; From 12bb2f3f4775fe1f203e7e0ec4c93ebc7c51062f Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Fri, 10 Oct 2025 12:30:55 -0600 Subject: [PATCH 106/605] feat: add readonly surface mode --- include/ghostty.h | 1 + src/Surface.zig | 31 ++++++++++++++++++++++++++++- src/apprt/action.zig | 6 ++++++ src/apprt/gtk/class/application.zig | 4 ++++ src/input/Binding.zig | 11 ++++++++++ src/input/command.zig | 6 ++++++ 6 files changed, 58 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..cd716e38f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,6 +797,7 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 8cd8d253b..951ef14ef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -145,6 +145,12 @@ focused: bool = true, /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + /// Used to send notifications that long running commands have finished. /// Requires that shell integration be active. Should represent a nanosecond /// precision timestamp. It does not necessarily need to correspond to the @@ -871,6 +877,9 @@ pub fn deactivateInspector(self: *Surface) void { /// True if the surface requires confirmation to quit. This should be called /// by apprt to determine if the surface should confirm before quitting. pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + // If the child has exited, then our process is certainly not alive. // We check this first to avoid the locking overhead below. if (self.child_exited) return false; @@ -2559,6 +2568,12 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; + // If the surface is in read-only mode, we consume the key event here + // without sending it to the PTY. + if (self.readonly) { + return .consumed; + } + // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); @@ -3267,7 +3282,9 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - if (self.io.terminal.screens.active_key == .alternate and + // Additionally, we don't send cursor keys if the surface is in read-only mode. + if (!self.readonly and + self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3393,6 +3410,9 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); + // If the surface is in read-only mode, do not send mouse reports to the PTY + if (self.readonly) return; + // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above @@ -5383,6 +5403,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_readonly => { + self.readonly = !self.readonly; + return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_readonly, + {}, + ); + }, + .reset_window_size => return try self.rt_app.performAction( .{ .surface = self }, .reset_window_size, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..83e2f5011 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,6 +139,11 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, + /// Toggle whether the surface is in read-only mode. When read-only, + /// no input is sent to the PTY but terminal-level operations like + /// selections, scrolling, and copy/paste keybinds still work. + toggle_readonly, + /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -335,6 +340,7 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, + toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..bbf408e02 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,6 +724,10 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), + .toggle_readonly => { + // The readonly state is managed in Surface.zig. + return true; + }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e1c636ab7..d368c48b2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -552,6 +552,16 @@ pub const Action = union(enum) { /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + /// 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`. @@ -1241,6 +1251,7 @@ pub const Action = union(enum) { .new_split, .goto_split, .toggle_split_zoom, + .toggle_readonly, .resize_split, .equalize_splits, .inspector, diff --git a/src/input/command.zig b/src/input/command.zig index 639fc6e39..ce218718f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -485,6 +485,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the zoom state of the current split.", }}, + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + .equalize_splits => comptime &.{.{ .action = .equalize_splits, .title = "Equalize Splits", From 547bcd261dcbd25bfab99d3fb00c2f93af994605 Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Tue, 21 Oct 2025 09:57:14 -0600 Subject: [PATCH 107/605] fix: removed apprt action for toggle_readonly --- include/ghostty.h | 1 - src/Surface.zig | 6 +----- src/apprt/action.zig | 6 ------ src/apprt/gtk/class/application.zig | 4 ---- 4 files changed, 1 insertion(+), 16 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cd716e38f..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,7 +797,6 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, - GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 951ef14ef..7bfdad665 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5405,11 +5405,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; - return try self.rt_app.performAction( - .{ .surface = self }, - .toggle_readonly, - {}, - ); + return true; }, .reset_window_size => return try self.rt_app.performAction( diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 83e2f5011..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,11 +139,6 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, - /// Toggle whether the surface is in read-only mode. When read-only, - /// no input is sent to the PTY but terminal-level operations like - /// selections, scrolling, and copy/paste keybinds still work. - toggle_readonly, - /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -340,7 +335,6 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, - toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index bbf408e02..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,10 +724,6 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), - .toggle_readonly => { - // The readonly state is managed in Surface.zig. - return true; - }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), From b58ac983cfeecded082c7f51fe9149062952907e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 07:29:42 -0800 Subject: [PATCH 108/605] docs changes --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index e4ba605f6..1ea7a7201 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -147,7 +147,7 @@ selection_scroll_active: bool = false, /// True if the surface is in read-only mode. When read-only, no input /// is sent to the PTY but terminal-level operations like selections, -/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// (native) scrolling, and copy keybinds still work. Warn before quit is /// always enabled in this state. readonly: bool = false, From 2d9c83dbb7ee50471f8326f3687651d2a944c350 Mon Sep 17 00:00:00 2001 From: Michael Bommarito Date: Fri, 12 Dec 2025 13:36:36 -0500 Subject: [PATCH 109/605] fix: bash shell integration use-after-free bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ShellCommandBuilder uses a stackFallback allocator, which means toOwnedSlice() may return memory allocated on the stack. When setupBash() returns, this stack memory becomes invalid, causing a use-after-free. This manifested as garbage data in the shell command string, often appearing as errors like "/bin/sh: 1: ically: not found" (where "ically" was part of nearby memory, likely from the comment "automatically"). The fix copies the command string to the arena allocator before returning, ensuring the memory remains valid for the lifetime of the command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/termio/shell_integration.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 128b345ea..71492230e 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -353,7 +353,11 @@ fn setupBash( ); try env.put("ENV", integ_dir); - return .{ .shell = try cmd.toOwnedSlice() }; + // Get the command string from the builder, then copy it to the arena + // allocator. The stackFallback allocator's memory becomes invalid after + // this function returns, so we must copy to the arena. + const cmd_str = try cmd.toOwnedSlice(); + return .{ .shell = try alloc.dupeZ(u8, cmd_str) }; } test "bash" { From 29fdb541d56f980afa53b6ea3ef7c8985317e1de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 12:00:20 -0800 Subject: [PATCH 110/605] make all IO message queueing go through queueIo so we can intercept --- src/Surface.zig | 85 ++++++++++++++++++++++++------------------- src/termio/Termio.zig | 5 ++- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1ea7a7201..819972509 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -818,6 +818,15 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } +/// Queue a message for the IO thread. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + self.io.queueMessage(msg, mutex); +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -849,7 +858,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = true }, .unlocked); + self.queueIo(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -866,7 +875,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = false }, .unlocked); + self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -938,7 +947,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We always use an allocating message because we don't know // the length of the title and this isn't a performance critical // path. - self.io.queueMessage(.{ + self.queueIo(.{ .write_alloc = .{ .alloc = self.alloc, .data = data, @@ -1130,7 +1139,7 @@ fn selectionScrollTick(self: *Surface) !void { // If our screen changed while this is happening, we stop our // selection scroll. if (self.mouse.left_click_screen != t.screens.active_key) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -1362,7 +1371,7 @@ fn reportColorScheme(self: *Surface, force: bool) void { .dark => "\x1B[?997;1n", }; - self.io.queueMessage(.{ .write_stable = output }, .unlocked); + self.queueIo(.{ .write_stable = output }, .unlocked); } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { @@ -1735,7 +1744,7 @@ pub fn updateConfig( errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - self.io.queueMessage(.{ + self.queueIo(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, @@ -2301,7 +2310,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { self.balancePaddingIfNeeded(); // Notify the terminal - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); // Update our terminal default size if necessary. self.recomputeInitialSize() catch |err| { @@ -2404,7 +2413,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { } // Mail the IO thread - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); } /// Recalculate the balanced padding if needed. @@ -2686,7 +2695,7 @@ pub fn keyCallback( } errdefer write_req.deinit(); - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2915,7 +2924,7 @@ fn endKeySequence( if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -3141,7 +3150,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; self.renderer_state.mutex.unlock(); - self.io.queueMessage(.{ .focused = focused }, .unlocked); + self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3307,7 +3316,7 @@ pub fn scrollCallback( }; }; for (0..y.magnitude()) |_| { - self.io.queueMessage(.{ .write_stable = seq }, .locked); + self.queueIo(.{ .write_stable = seq }, .locked); } } @@ -3532,7 +3541,7 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = 6, } }, .locked); @@ -3555,7 +3564,7 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(i), } }, .locked); @@ -3576,7 +3585,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3593,7 +3602,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3622,7 +3631,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3774,7 +3783,7 @@ pub fn mouseButtonCallback( // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .unlocked, ); @@ -4131,7 +4140,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -4141,7 +4150,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } } @@ -4414,7 +4423,7 @@ pub fn cursorPosCallback( // Stop selection scrolling when inside the viewport within a 1px buffer // for fullscreen windows, but only when selection scrolling is active. if (pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -4514,7 +4523,7 @@ pub fn cursorPosCallback( if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = true }, .locked, ); @@ -4890,7 +4899,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, full_data, ), .unlocked); @@ -4917,7 +4926,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, text, ), .unlocked); @@ -4950,9 +4959,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); } else { - self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); + self.queueIo(.{ .write_stable = ck.application }, .unlocked); } }, @@ -5225,19 +5234,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (self.io.terminal.screens.active_key == .alternate) return false; } - self.io.queueMessage(.{ + self.queueIo(.{ .clear_screen = .{ .history = true }, }, .unlocked); }, .scroll_to_top => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .top = {} }, }, .unlocked); }, .scroll_to_bottom => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .bottom = {} }, }, .unlocked); }, @@ -5267,14 +5276,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_up => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = -1 * rows }, }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = rows }, }, .unlocked); }, @@ -5282,19 +5291,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.size.grid().rows); const delta: isize = @intFromFloat(@trunc(fraction * rows)); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = delta }, }, .unlocked); }, .scroll_page_lines => |lines| { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = lines }, }, .unlocked); }, .jump_to_prompt => |delta| { - self.io.queueMessage(.{ + self.queueIo(.{ .jump_to_prompt = @intCast(delta), }, .unlocked); }, @@ -5514,7 +5523,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; }, - .io => self.io.queueMessage(.{ .crash = {} }, .unlocked), + .io => self.queueIo(.{ .crash = {} }, .unlocked), }, .adjust_selection => |direction| { @@ -5712,7 +5721,7 @@ fn writeScreenFile( }, .url = path, }), - .paste => self.io.queueMessage(try termio.Message.writeReq( + .paste => self.queueIo(try termio.Message.writeReq( self.alloc, path, ), .unlocked), @@ -5852,7 +5861,7 @@ fn completeClipboardPaste( }; for (vecs) |vec| if (vec.len > 0) { - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, vec, ), .unlocked); @@ -5898,7 +5907,7 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, buf, ), .unlocked); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 53df00433..7263418a7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -22,6 +22,9 @@ const configpkg = @import("../config.zig"); const log = std.log.scoped(.io_exec); +/// Mutex state argument for queueMessage. +pub const MutexState = enum { locked, unlocked }; + /// Allocator alloc: Allocator, @@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { pub fn queueMessage( self: *Termio, msg: termio.Message, - mutex: enum { locked, unlocked }, + mutex: MutexState, ) void { self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, From 6dd9a74e6e2318ca313638e96c8d3cd4df41bfd6 Mon Sep 17 00:00:00 2001 From: Michael Hazan Date: Fri, 12 Dec 2025 22:56:06 +0200 Subject: [PATCH 111/605] fix(docs): `window-decoration` is now `none` instead of `false` --- src/config/Config.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 20256e951..1deb3e532 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1329,7 +1329,7 @@ maximize: bool = false, /// new windows, not just the first one. /// /// On macOS, this setting does not work if window-decoration is set to -/// "false", because native fullscreen on macOS requires window decorations +/// "none", because native fullscreen on macOS requires window decorations /// to be set. fullscreen: bool = false, @@ -2825,7 +2825,7 @@ keybind: Keybinds = .{}, /// 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 +/// This setting has no effect when `window-decoration = none` or /// `macos-titlebar-style = hidden`, as the window buttons are always hidden in /// these modes. /// @@ -2866,7 +2866,7 @@ keybind: Keybinds = .{}, /// macOS 14 does not have this issue and any other macOS version has not /// been tested. /// -/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, +/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect /// existing windows in buggy ways. @@ -3205,7 +3205,7 @@ else /// manager's simple titlebar. The behavior of this option will vary with your /// window manager. /// -/// This option does nothing when `window-decoration` is false or when running +/// This option does nothing when `window-decoration` is none or when running /// under macOS. @"gtk-titlebar": bool = true, From 0bf3642939122bcc0beea45929f4d5d4ea14335a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:07:43 -0800 Subject: [PATCH 112/605] core: manage read-only through queueIo --- src/Surface.zig | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 819972509..19dc086dd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -819,11 +819,26 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { } /// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. fn queueIo( self: *Surface, msg: termio.Message, mutex: termio.Termio.MutexState, ) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + self.io.queueMessage(msg, mutex); } @@ -3291,9 +3306,7 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - // Additionally, we don't send cursor keys if the surface is in read-only mode. - if (!self.readonly and - self.io.terminal.screens.active_key == .alternate and + if (self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3402,10 +3415,9 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! const MouseReportAction = enum { press, release, motion }; /// Returns true if mouse reporting is enabled both in the config and -/// the terminal state, and the surface is not in read-only mode. +/// the terminal state. fn isMouseReporting(self: *const Surface) bool { - return !self.readonly and - self.config.mouse_reporting and + return self.config.mouse_reporting and self.io.terminal.flags.mouse_event != .none; } @@ -3420,9 +3432,6 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); - // Callers must verify the surface is not in read-only mode - assert(!self.readonly); - // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above From dc7bc3014e1ea4033f07af372e86f34d400182bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:13:53 -0800 Subject: [PATCH 113/605] add apprt action to notify apprt of surface readonly state --- include/ghostty.h | 8 ++++++++ src/Surface.zig | 5 +++++ src/apprt/action.zig | 9 +++++++++ src/apprt/gtk/class/application.zig | 1 + 4 files changed, 23 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..a75fdc245 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -573,6 +573,12 @@ typedef enum { GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + // apprt.action.DesktopNotification.C typedef struct { const char* title; @@ -837,6 +843,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, } ghostty_action_tag_e; typedef union { @@ -874,6 +881,7 @@ typedef union { ghostty_action_start_search_s start_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 19dc086dd..45b629865 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5424,6 +5424,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); return true; }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..608081a46 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -314,6 +314,9 @@ pub const Action = union(Key) { /// The currently selected search match index (1-based). search_selected: SearchSelected, + /// The readonly state of the surface has changed. + readonly: Readonly, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -375,6 +378,7 @@ pub const Action = union(Key) { end_search, search_total, search_selected, + readonly, }; /// Sync with: ghostty_action_u @@ -532,6 +536,11 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const Readonly = enum(c_int) { + off, + on, +}; + pub const MouseVisibility = enum(c_int) { visible, hidden, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..efca498b4 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -746,6 +746,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .readonly, => { log.warn("unimplemented action={}", .{action}); return false; From ec2638b3c6e3ceb870e459380fa0f91a46a392a2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:41:32 -0800 Subject: [PATCH 114/605] macos: readonly badge --- macos/Sources/Ghostty/Ghostty.App.swift | 28 +++++++++++ macos/Sources/Ghostty/Package.swift | 4 ++ macos/Sources/Ghostty/SurfaceView.swift | 47 +++++++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 13 +++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 ++ 5 files changed, 95 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index aff3edbc7..4788a4376 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -588,6 +588,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_READONLY: + setReadonly(app, target: target, v: action.action.readonly) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) @@ -1010,6 +1013,31 @@ extension Ghostty { } } + private static func setReadonly( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_readonly_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set readonly does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: .ghosttyDidChangeReadonly, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON, + ] + ) + + default: + assertionFailure() + } + } + private static func moveTab( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4b3eb60aa..258857e8e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -391,6 +391,10 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + + /// Readonly mode changed + static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") + static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") /// Toggle maximize of current window diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ba678db59..c027162ab 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -104,6 +104,11 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge() + } + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -757,6 +762,48 @@ extension Ghostty { } } + // MARK: Readonly Badge + + /// A badge overlay that indicates a surface is in readonly mode. + /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. + struct ReadonlyBadge: View { + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) + + var body: some View { + VStack { + HStack { + Spacer() + + HStack(spacing: 5) { + Image(systemName: "eye.fill") + .font(.system(size: 12)) + Text("Read-only") + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .foregroundStyle(badgeColor) + } + .padding(8) + + Spacer() + } + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Read-only terminal") + } + + private var badgeBackground: some View { + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5) + ) + } + } + #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 diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 130df6f44..d8670e644 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -123,6 +123,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -333,6 +336,11 @@ extension Ghostty { selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeReadonly(_:)), + name: .ghosttyDidChangeReadonly, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -703,6 +711,11 @@ extension Ghostty { bell = true } + @objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) { + guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return } + readonly = value + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 09c41c0b5..568a93314 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,6 +43,9 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil + + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From ceb1b5e587c7a769f33ca8e0d208ce3067cb2947 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:50:20 -0800 Subject: [PATCH 115/605] macos: add a read-only menu item in View --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 7 +++++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8baee3d89..e10547bbc 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? @IBOutlet private var menuChangeTabTitle: NSMenuItem? + @IBOutlet private var menuReadonly: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index d009b9c62..a321061dd 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -47,6 +47,7 @@ + @@ -328,6 +329,12 @@ + + + + + + diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d8670e644..853a6d51c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1512,6 +1512,14 @@ extension Ghostty { } } + @IBAction func toggleReadonly(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "toggle_readonly" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) @@ -1988,6 +1996,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { case #selector(findHide): return searchState != nil + case #selector(toggleReadonly): + item.state = readonly ? .on : .off + return true + default: return true } From 173d8efd90536afc53316cbc00f3628dae3fd3df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:55:02 -0800 Subject: [PATCH 116/605] macos: add to context menu --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e10547bbc..043d85e1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -545,6 +545,7 @@ class AppDelegate: NSObject, self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 853a6d51c..d26545ebc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1429,6 +1429,9 @@ extension Ghostty { item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") + item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "eye.fill") + item.state = readonly ? .on : .off menu.addItem(.separator()) item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") From 22b8809858088d8760c5601e4ec1658d6be964d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:01:35 -0800 Subject: [PATCH 117/605] macos: add a popover to the readonly badge with info --- macos/Sources/Ghostty/SurfaceView.swift | 54 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c027162ab..3bdcaafe6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -106,7 +106,9 @@ extension Ghostty { // Readonly indicator badge if surfaceView.readonly { - ReadonlyBadge() + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } } // Progress report @@ -767,6 +769,10 @@ extension Ghostty { /// A badge overlay that indicates a surface is in readonly mode. /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. struct ReadonlyBadge: View { + let onDisable: () -> Void + + @State private var showingPopover = false + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) var body: some View { @@ -784,12 +790,18 @@ extension Ghostty { .padding(.vertical, 4) .background(badgeBackground) .foregroundStyle(badgeColor) + .onTapGesture { + showingPopover = true + } + .backport.pointerStyle(.link) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover) + } } .padding(8) Spacer() } - .allowsHitTesting(false) .accessibilityElement(children: .ignore) .accessibilityLabel("Read-only terminal") } @@ -803,6 +815,44 @@ extension Ghostty { ) } } + + struct ReadonlyPopoverView: View { + let onDisable: () -> Void + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "eye.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Read-Only Mode") + .font(.system(size: 13, weight: .semibold)) + } + + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Disable") { + onDisable() + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + .frame(width: 280) + } + } #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view From ddaf307cf7a6304b4376fb98e94e614369c46f1d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:05:46 -0800 Subject: [PATCH 118/605] macos: more strict detection for tab context menu We were accidentally modifying the "View" menu. --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d04d7001c..160473328 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -708,8 +708,8 @@ extension TerminalWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ + // These selectors must all exist for it to be a tab context menu. + let requiredSelectors: Set = [ "performClose:", "performCloseOtherTabs:", "moveTabToNewWindow:", @@ -717,7 +717,7 @@ extension TerminalWindow { ] let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) + return requiredSelectors.isSubset(of: selectorNames) } private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { From 43b4ed5bc0c20d6a39d20260a924c308f065d43e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:12:02 -0800 Subject: [PATCH 119/605] macos: only show readonly badge on AppKit --- macos/Sources/Ghostty/SurfaceView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 3bdcaafe6..eaf935df9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,14 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - - // Readonly indicator badge - if surfaceView.readonly { - ReadonlyBadge { - surfaceView.toggleReadonly(nil) - } - } - + .allowsHitTesting(false) // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -123,6 +116,13 @@ extension Ghostty { } #if canImport(AppKit) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } + } + // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { From 19e0864688e0ce53d030d7d66eb474e9ccba816e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:14:14 -0800 Subject: [PATCH 120/605] macos: unintended change --- macos/Sources/Ghostty/SurfaceView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index eaf935df9..82232dd89 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,7 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - .allowsHitTesting(false) + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { From 182cb35bae0f7dc45cb6f98374a6babf42e73401 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:15:43 -0800 Subject: [PATCH 121/605] core: remove readonly check --- src/Surface.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 45b629865..a3b306fef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2592,12 +2592,6 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; - // If the surface is in read-only mode, we consume the key event here - // without sending it to the PTY. - if (self.readonly) { - return .consumed; - } - // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); From 4a04efaff1e1ece5a78131c09221ae3700392e06 Mon Sep 17 00:00:00 2001 From: definfo Date: Sat, 13 Dec 2025 16:55:41 +0800 Subject: [PATCH 122/605] fix: explicitly allow preservation for TERMINFO in shell-integration Due to security issues, `sudo` implementations may not preserve environment variables unless appended with `--preserve-env=list`. Signed-off-by: definfo --- src/shell-integration/bash/ghostty.bash | 2 +- src/shell-integration/elvish/lib/ghostty-integration.elv | 2 +- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 2 +- src/shell-integration/zsh/ghostty-integration | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index e910a9885..799d0cff6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 33473c8b0..e4b449ae5 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -97,7 +97,7 @@ if (not (has-value $arg =)) { break } } - if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] } + if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] } (external sudo) $@args } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 47af9be98..580e27f45 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -90,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test "$sudo_has_sudoedit_flags" = "yes" command sudo $argv else - command sudo TERMINFO="$TERMINFO" $argv + command sudo --preserve-env=TERMINFO $argv end end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 7ff43efd9..c87630c92 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -255,7 +255,7 @@ _ghostty_deferred_init() { if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi From 91b4a218cad2fd0c9c8fa448593e1ebb199d5013 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:45:18 +0100 Subject: [PATCH 123/605] macOS: change `window` to `new-window` for `macos-dock-drop-behavior` Matches current option references and Swift implementation --- src/config/Config.zig | 44 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 1deb3e532..1e3a79862 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -86,6 +86,10 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". // The semantics also changed slightly but this is the correct mapping. .{ "gtk-single-instance", compatGtkSingleInstance }, + + // Ghostty 1.3 rename the "window" option to "new-window". + // See: https://github.com/ghostty-org/ghostty/pull/9764 + .{ "macos-dock-drop-behavior", compatMacOSDockDropBehavior }, }); /// The font families to use. @@ -2911,7 +2915,7 @@ keybind: Keybinds = .{}, /// /// * `new-tab` - Create a new tab in the current window, or open /// a new window if none exist. -/// * `window` - Create a new window unconditionally. +/// * `new-window` - Create a new window unconditionally. /// /// The default value is `new-tab`. /// @@ -4445,6 +4449,23 @@ fn compatBoldIsBright( return true; } +fn compatMacOSDockDropBehavior( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "macos-dock-drop-behavior")); + + if (std.mem.eql(u8, value orelse "", "window")) { + self.@"macos-dock-drop-behavior" = .@"new-window"; + return true; + } + + return false; +} + /// Add a diagnostic message to the config with the given string. /// This is always added with a location of "none". pub fn addDiagnosticFmt( @@ -7875,7 +7896,7 @@ pub const WindowNewTabPosition = enum { /// See macos-dock-drop-behavior pub const MacOSDockDropBehavior = enum { @"new-tab", - window, + @"new-window", }; /// See window-show-tab-bar @@ -9491,3 +9512,22 @@ test "compatibility: removed bold-is-bright" { ); } } + +test "compatibility: window new-window" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--macos-dock-drop-behavior=window", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + try testing.expectEqual( + MacOSDockDropBehavior.@"new-window", + cfg.@"macos-dock-drop-behavior", + ); + } +} From c5d6b951e99dcff378c9b49f9f5fb56ab2874ec5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 07:06:06 -0800 Subject: [PATCH 124/605] input: shift+backspace in Kitty with only disambiguate should do CSIu Fixes #9868 (shift+backspace part only) --- src/input/key_encode.zig | 45 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index b63de6f6d..736df58a0 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -178,7 +178,7 @@ fn kitty( // Quote ("report all" mode): // Note that all keys are reported as escape codes, including Enter, // Tab, Backspace etc. - if (effective_mods.empty()) { + if (binding_mods.empty()) { switch (event.key) { .enter => return try writer.writeByte('\r'), .tab => return try writer.writeByte('\t'), @@ -1311,7 +1311,48 @@ test "kitty: enter, backspace, tab" { try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); } } -// + +test "kitty: shift+backspace emits CSI u" { + // Backspace with shift modifier should emit CSI u sequence, not raw 0x7F. + // This is important for programs that want to distinguish shift+backspace. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); +} + +test "kitty: shift+enter emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[13;2u", writer.buffered()); +} + +test "kitty: shift+tab emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + test "kitty: enter with all flags" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); From 1c1ef99fb1d7cdab5af3d058fc2ff51867eab26a Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Tue, 21 Oct 2025 22:13:42 +0200 Subject: [PATCH 125/605] Window switching initial --- include/ghostty.h | 6 +++++ src/Surface.zig | 11 +++++++++ src/apprt/action.zig | 11 +++++++++ src/apprt/gtk/class/application.zig | 36 +++++++++++++++++++++++++++++ src/input/Binding.zig | 10 ++++++++ src/input/command.zig | 14 +++++++++++ 6 files changed, 88 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index a75fdc245..82ac392e2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -512,6 +512,12 @@ typedef enum { GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + // apprt.action.ResizeSplit.Direction typedef enum { GHOSTTY_RESIZE_SPLIT_UP, diff --git a/src/Surface.zig b/src/Surface.zig index a3b306fef..69a390c2d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5390,6 +5390,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, ), + .goto_window => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_window, + switch (direction) { + inline else => |tag| @field( + apprt.action.GotoWindow, + @tagName(tag), + ), + }, + ), + .resize_split => |value| return try self.rt_app.performAction( .{ .surface = self }, .resize_split, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 608081a46..4bb590eee 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -129,6 +129,9 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, + /// Jump to next/previous window. + goto_window: GotoWindow, + /// Resize the split in the given direction. resize_split: ResizeSplit, @@ -335,6 +338,7 @@ pub const Action = union(Key) { move_tab, goto_tab, goto_split, + goto_window, resize_split, equalize_splits, toggle_split_zoom, @@ -474,6 +478,13 @@ pub const GotoSplit = enum(c_int) { right, }; +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoWindow = enum(c_int) { + previous, + next, +}; + /// The amount to resize the split by and the direction to resize it in. pub const ResizeSplit = extern struct { amount: u16, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index efca498b4..e53201c96 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -659,6 +659,8 @@ pub const Application = extern struct { .goto_split => return Action.gotoSplit(target, value), + .goto_window => return Action.gotoWindow(value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -2014,6 +2016,40 @@ const Action = struct { } } + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + // Check based on direction if we are at beginning or end of window list to loop around + // else just go to next/previous window + switch(direction) { + .next => { + const next_node = node.f_next orelse glist; + + const window: *gtk.Window = @ptrCast(@alignCast(next_node.f_data orelse return false)); + gtk.Window.present(window); + return true; + }, + .previous => { + const prev_node = node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }; + const window: *gtk.Window = @ptrCast(@alignCast(prev_node.f_data orelse return false)); + gtk.Window.present(window); + return true; + }, + } + return false; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d368c48b2..0a927b85f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,6 +545,10 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, + + /// Focus on either the previous window or the next one ('previous', 'next') + goto_window: WindowDirection, + /// Zoom in or out of the current split. /// /// When a split is zoomed into, it will take up the entire space in @@ -931,6 +935,11 @@ pub const Action = union(enum) { right, }; + pub const WindowDirection = enum { + previous, + next, + }; + pub const SplitResizeParameter = struct { SplitResizeDirection, u16, @@ -1250,6 +1259,7 @@ pub const Action = union(enum) { .toggle_tab_overview, .new_split, .goto_split, + .goto_window, .toggle_split_zoom, .toggle_readonly, .resize_split, diff --git a/src/input/command.zig b/src/input/command.zig index ce218718f..037b5317c 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -479,6 +479,20 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_window => comptime &.{ + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Previous", + .description = "Focus the previous window, if any.", + }, + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Next", + .description = "Focus the next window, if any.", + }, + }, + + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", From 3000136e6113c1c2f4b47f604803aaa6f76ca1a6 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Thu, 23 Oct 2025 22:30:27 +0200 Subject: [PATCH 126/605] Changed switching previous/next to have no duplication --- src/apprt/gtk/class/application.zig | 65 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index e53201c96..ed2044c4e 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,37 +2016,40 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { - const glist = gtk.Window.listToplevels(); - defer glist.free(); - - const node = glist.findCustom(null, findActiveWindow); - - // Check based on direction if we are at beginning or end of window list to loop around - // else just go to next/previous window - switch(direction) { - .next => { - const next_node = node.f_next orelse glist; - - const window: *gtk.Window = @ptrCast(@alignCast(next_node.f_data orelse return false)); - gtk.Window.present(window); - return true; - }, - .previous => { - const prev_node = node.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }; - const window: *gtk.Window = @ptrCast(@alignCast(prev_node.f_data orelse return false)); - gtk.Window.present(window); - return true; - }, - } + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + const target_node = switch (direction) { + .next => node.f_next orelse glist, + .previous => node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }, + }; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + gtk.Window.present(gtk_window); + + const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(?*Surface)); + ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + + const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); + if (surface) |s| { + s.grabFocus(); + return true; + } + + log.warn("window has no active surface, cannot grab focus", .{}); return false; } From 55ae4430b9fbf0c4556c07eb0f39649bbcc658ab Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Thu, 23 Oct 2025 23:33:27 +0200 Subject: [PATCH 127/605] Formatting --- src/apprt/action.zig | 2 +- src/apprt/gtk/class/application.zig | 68 ++++++++++++++--------------- src/input/Binding.zig | 3 +- src/input/command.zig | 1 - 4 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4bb590eee..af1c22552 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -129,7 +129,7 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, - /// Jump to next/previous window. + /// Jump to next/previous window. goto_window: GotoWindow, /// Resize the split in the given direction. diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index ed2044c4e..331fff4e9 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,40 +2016,40 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { - const glist = gtk.Window.listToplevels(); - defer glist.free(); - - const node = glist.findCustom(null, findActiveWindow); - - const target_node = switch (direction) { - .next => node.f_next orelse glist, - .previous => node.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }, - }; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); - gtk.Window.present(gtk_window); - - const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(?*Surface)); - ghostty_window.as(gobject.Object).getProperty("active-surface", &value); - - const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); - if (surface) |s| { - s.grabFocus(); - return true; - } - - log.warn("window has no active surface, cannot grab focus", .{}); + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + const target_node = switch (direction) { + .next => node.f_next orelse glist, + .previous => node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }, + }; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + gtk.Window.present(gtk_window); + + const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(?*Surface)); + ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + + const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); + if (surface) |s| { + s.grabFocus(); + return true; + } + + log.warn("window has no active surface, cannot grab focus", .{}); return false; } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0a927b85f..a3284c718 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,7 +545,6 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, - /// Focus on either the previous window or the next one ('previous', 'next') goto_window: WindowDirection, @@ -936,7 +935,7 @@ pub const Action = union(enum) { }; pub const WindowDirection = enum { - previous, + previous, next, }; diff --git a/src/input/command.zig b/src/input/command.zig index 037b5317c..deb6e8412 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -492,7 +492,6 @@ fn actionCommands(action: Action.Key) []const Command { }, }, - .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", From afbcfa9e3d4771cf3127cf4ce9ec7b19cec957da Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 09:45:37 +0200 Subject: [PATCH 128/605] Added GOTO_WINDOW to actions --- src/input/Binding.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a3284c718..31672bc1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -546,7 +546,7 @@ pub const Action = union(enum) { goto_split: SplitFocusDirection, /// Focus on either the previous window or the next one ('previous', 'next') - goto_window: WindowDirection, + goto_window: GotoWindow, /// Zoom in or out of the current split. /// @@ -934,7 +934,7 @@ pub const Action = union(enum) { right, }; - pub const WindowDirection = enum { + pub const GotoWindow = enum { previous, next, }; From 4f02e6c0965567ec8820732c8541fdfe1137ca59 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 14:10:20 +0200 Subject: [PATCH 129/605] Wrong action typo fix --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index deb6e8412..a377effa2 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -486,7 +486,7 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Focus the previous window, if any.", }, .{ - .action = .{ .goto_window = .previous }, + .action = .{ .goto_window = .next }, .title = "Focus Window: Next", .description = "Focus the next window, if any.", }, From b344c978d01651ac4efabb3ca185024843bc7150 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 15:26:17 +0200 Subject: [PATCH 130/605] Added GOTO_WINDOW to actions enum --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 82ac392e2..514e52c77 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -806,6 +806,7 @@ typedef enum { GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, From 6b8a7e1dd14bcd24b70cfced6976aed64afae194 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 15:32:43 +0200 Subject: [PATCH 131/605] Replaced direction switch, direclty handling next and previous now --- src/Surface.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 69a390c2d..4ff25992a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5394,10 +5394,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .surface = self }, .goto_window, switch (direction) { - inline else => |tag| @field( - apprt.action.GotoWindow, - @tagName(tag), - ), + .next => apprt.action.GotoWindow.next, + .previous => apprt.action.GotoWindow.previous, }, ), From 6230d134e18942cef23ad4821036b70b3c7d0bc3 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 19:22:44 +0200 Subject: [PATCH 132/605] Type-safe rework --- src/apprt/gtk/class/application.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 331fff4e9..0769a26df 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2034,18 +2034,18 @@ const Action = struct { break :last current; }, }; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + const data = target_node.f_data orelse return false; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); gtk.Window.present(gtk_window); - const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(?*Surface)); - ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + const ghostty_window = gobject.ext.cast(Window, gtk_window) orelse return false; + + var surface: ?*gobject.Object = null; + ghostty_window.as(gobject.Object).get("active-surface", &surface, @as(?*anyopaque, null)); - const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); if (surface) |s| { - s.grabFocus(); + const surface_obj = gobject.ext.cast(Surface, s) orelse return false; + surface_obj.grabFocus(); return true; } From 7e0dc09873095546362b6c951da0420b4da3f6dc Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 20:04:50 +0200 Subject: [PATCH 133/605] Just using decl literals --- src/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4ff25992a..19c2662c1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5394,8 +5394,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .surface = self }, .goto_window, switch (direction) { - .next => apprt.action.GotoWindow.next, - .previous => apprt.action.GotoWindow.previous, + .previous => .previous, + .next => .next, }, ), From bb246b2e0c9dc3139c68684f37c0caf826c6b3e6 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Tue, 28 Oct 2025 19:44:43 +0100 Subject: [PATCH 134/605] Added null handling for findCustom --- src/apprt/gtk/class/application.zig | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0769a26df..5b264fcce 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2022,18 +2022,19 @@ const Action = struct { const glist = gtk.Window.listToplevels(); defer glist.free(); - const node = glist.findCustom(null, findActiveWindow); + const node = @as(?*glib.List, glist.findCustom(null, findActiveWindow)); - const target_node = switch (direction) { - .next => node.f_next orelse glist, - .previous => node.f_prev orelse last: { + const target_node = if (node) |n| switch (direction) { + .next => n.f_next orelse glist, + .previous => n.f_prev orelse last: { var current = glist; while (current.f_next) |next| { current = next; } break :last current; }, - }; + } else glist; + const data = target_node.f_data orelse return false; const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); gtk.Window.present(gtk_window); From 4c2fb7ae0ebbbe28fd21f2d5fc4ee96bea168af7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 13:51:16 -0800 Subject: [PATCH 135/605] Update mirror for direct deps --- build.zig.zon | 12 ++-- build.zig.zon.bak | 124 ++++++++++++++++++++++++++++++++++++++ build.zig.zon.json | 12 ++-- build.zig.zon.nix | 12 ++-- build.zig.zon.txt | 12 ++-- flatpak/zig-packages.json | 12 ++-- 6 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 build.zig.zon.bak diff --git a/build.zig.zon b/build.zig.zon index 191ae7fa9..79c8c69c3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,13 +15,13 @@ }, .vaxis = .{ // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", .lazy = true, }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", .lazy = true, }, @@ -39,7 +39,7 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ @@ -50,14 +50,14 @@ }, .zf = .{ // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", .lazy = true, }, .gobject = .{ // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak new file mode 100644 index 000000000..191ae7fa9 --- /dev/null +++ b/build.zig.zon.bak @@ -0,0 +1,124 @@ +.{ + .name = .ghostty, + .version = "1.3.0-dev", + .paths = .{""}, + .fingerprint = 0x64407a2a0b4147e5, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + // Zig libs + + .libxev = .{ + // mitchellh/libxev + .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", + .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", + .lazy = true, + }, + .vaxis = .{ + // rockorager/libvaxis + .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + .lazy = true, + }, + .z2d = .{ + // vancluever/z2d + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + .lazy = true, + }, + .zig_objc = .{ + // mitchellh/zig-objc + .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", + .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", + .lazy = true, + }, + .zig_js = .{ + // mitchellh/zig-js + .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", + .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", + .lazy = true, + }, + .uucode = .{ + // jacobsandlund/uucode + .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + }, + .zig_wayland = .{ + // codeberg ifreund/zig-wayland + .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", + .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", + .lazy = true, + }, + .zf = .{ + // natecraddock/zf + .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + .lazy = true, + }, + .gobject = .{ + // https://github.com/ghostty-org/zig-gobject based on zig_gobject + // Temporary until we generate them at build time automatically. + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + .lazy = true, + }, + + // C libs + .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, + .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, + .freetype = .{ .path = "./pkg/freetype", .lazy = true }, + .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, + .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, + .highway = .{ .path = "./pkg/highway", .lazy = true }, + .libintl = .{ .path = "./pkg/libintl", .lazy = true }, + .libpng = .{ .path = "./pkg/libpng", .lazy = true }, + .macos = .{ .path = "./pkg/macos", .lazy = true }, + .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, + .opengl = .{ .path = "./pkg/opengl", .lazy = true }, + .sentry = .{ .path = "./pkg/sentry", .lazy = true }, + .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, + .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, + .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, + .zlib = .{ .path = "./pkg/zlib", .lazy = true }, + + // Shader translation + .glslang = .{ .path = "./pkg/glslang", .lazy = true }, + .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, + + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", + .lazy = true, + }, + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", + .lazy = true, + }, + .plasma_wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", + .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", + .lazy = true, + }, + + // Fonts + .jetbrains_mono = .{ + .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", + .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", + .lazy = true, + }, + .nerd_fonts_symbols_only = .{ + .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", + .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + .lazy = true, + }, + + // Other + .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .lazy = true, + }, + }, +} diff --git a/build.zig.zon.json b/build.zig.zon.json index e4171834d..cd807e67a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -26,7 +26,7 @@ }, "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { @@ -116,12 +116,12 @@ }, "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=" }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { @@ -141,12 +141,12 @@ }, "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=" }, "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c0f923145..e95b26960 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -126,7 +126,7 @@ in name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst"; + url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst"; hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } @@ -270,7 +270,7 @@ in name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } @@ -278,7 +278,7 @@ in name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { name = "vaxis"; - url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; + url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="; }; } @@ -310,7 +310,7 @@ in name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz"; hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; }; } @@ -318,7 +318,7 @@ in name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh"; path = fetchZigArtifact { name = "zf"; - url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; + url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index ceeb3aa3d..33a90a906 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,10 +6,12 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz +https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -19,17 +21,15 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz +https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz +https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz -https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz -https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz -https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index a6d431c8e..ddb6075b7 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,7 +31,7 @@ }, { "type": "archive", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, @@ -139,13 +139,13 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", "sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6" }, @@ -169,13 +169,13 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" }, { "type": "archive", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", "sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568" }, From dfb94cd55d404f81d34708130cf423dae6cc37b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:17:55 -0800 Subject: [PATCH 136/605] apprt/gtk: clean up gotoWindow --- src/apprt/gtk/class/application.zig | 83 +++++++++++++++++++---------- src/apprt/gtk/class/window.zig | 2 +- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 5b264fcce..d404304d0 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,44 +2016,69 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { + pub fn gotoWindow(direction: apprt.action.GotoWindow) bool { const glist = gtk.Window.listToplevels(); defer glist.free(); - const node = @as(?*glib.List, glist.findCustom(null, findActiveWindow)); + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; - const target_node = if (node) |n| switch (direction) { - .next => n.f_next orelse glist, - .previous => n.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }, - } else glist; - - const data = target_node.f_data orelse return false; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); - gtk.Window.present(gtk_window); - - const ghostty_window = gobject.ext.cast(Window, gtk_window) orelse return false; - - var surface: ?*gobject.Object = null; - ghostty_window.as(gobject.Object).get("active-surface", &surface, @as(?*anyopaque, null)); - - if (surface) |s| { - const surface_obj = gobject.ext.cast(Surface, s) orelse return false; - surface_obj.grabFocus(); - return true; + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; } - log.warn("window has no active surface, cannot grab focus", .{}); return false; } + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index c691b84a6..77fd2eea5 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -793,7 +793,7 @@ pub const Window = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - fn getActiveSurface(self: *Self) ?*Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { const tab = self.getSelectedTab() orelse return null; return tab.getActiveSurface(); } From 1a117c46e03f863a383dc9833f9950cf95bf59e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:29:09 -0800 Subject: [PATCH 137/605] macos: fix missing goto_window union entry --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 514e52c77..b0395b89e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -859,6 +859,7 @@ typedef union { ghostty_action_move_tab_s move_tab; ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; From 05ee9ae733f216408045d1f0d1a806412508be81 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:33:15 -0800 Subject: [PATCH 138/605] macos: implement goto_window:next/previousu --- macos/Sources/Ghostty/Ghostty.App.swift | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4788a4376..2cd0a362a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -501,6 +501,9 @@ extension Ghostty { case GHOSTTY_ACTION_GOTO_SPLIT: return gotoSplit(app, target: target, direction: action.action.goto_split) + case GHOSTTY_ACTION_GOTO_WINDOW: + return gotoWindow(app, target: target, direction: action.action.goto_window) + case GHOSTTY_ACTION_RESIZE_SPLIT: resizeSplit(app, target: target, resize: action.action.resize_split) @@ -1149,6 +1152,64 @@ extension Ghostty { } } + private static func gotoWindow( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_goto_window_e + ) -> Bool { + // Collect candidate windows: visible terminal windows that are either + // standalone or the currently selected tab in their tab group. This + // treats each native tab group as a single "window" for navigation + // purposes, since goto_tab handles per-tab navigation. + let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in + guard window.windowController is BaseTerminalController else { return false } + guard window.isVisible, !window.isMiniaturized else { return false } + // For native tabs, only include the selected tab in each group + if let group = window.tabGroup, group.selectedWindow !== window { + return false + } + return true + } + + // Need at least two windows to navigate between + guard candidates.count > 1 else { return false } + + // Find starting index from the current key/main window + let startIndex = candidates.firstIndex(where: { $0.isKeyWindow }) + ?? candidates.firstIndex(where: { $0.isMainWindow }) + ?? 0 + + let step: Int + switch direction { + case GHOSTTY_GOTO_WINDOW_NEXT: + step = 1 + case GHOSTTY_GOTO_WINDOW_PREVIOUS: + step = -1 + default: + return false + } + + // Iterate with wrap-around until we find a valid window or return to start + let count = candidates.count + var index = (startIndex + step + count) % count + + while index != startIndex { + let candidate = candidates[index] + if candidate.isVisible, !candidate.isMiniaturized { + candidate.makeKeyAndOrderFront(nil) + // Also focus the terminal surface within the window + if let controller = candidate.windowController as? BaseTerminalController, + let surface = controller.focusedSurface { + Ghostty.moveFocus(to: surface) + } + return true + } + index = (index + step + count) % count + } + + return false + } + private static func resizeSplit( _ app: ghostty_app_t, target: ghostty_target_s, From 3d5d170f8b81be29316395507cc977d44ec6851c Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 14 Dec 2025 00:15:58 +0000 Subject: [PATCH 139/605] deps: Update iTerm2 color schemes --- build.zig.zon | 2 +- build.zig.zon.json | 2 +- build.zig.zon.nix | 2 +- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 79c8c69c3..271428778 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd807e67a..c9a64ca5f 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index e95b26960..43a8efe46 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz"; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 33a90a906..24a2978d6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -11,7 +11,6 @@ https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz -https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -33,3 +32,4 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index ddb6075b7..21f79ec04 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, From 786dc9343876a28bd76f9979c59f574202b6be81 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 14 Dec 2025 16:24:50 -0500 Subject: [PATCH 140/605] macos: populate the sparkle:channel element This makes the update channel name available alongside the version, data, etc., which we can use in our update view (on the Released line). --- dist/macos/update_appcast_tag.py | 2 ++ dist/macos/update_appcast_tip.py | 2 ++ .../Sources/Features/Update/UpdatePopoverView.swift | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py index 2cb20dd5d..8c2ee8314 100644 --- a/dist/macos/update_appcast_tag.py +++ b/dist/macos/update_appcast_tag.py @@ -77,6 +77,8 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) +elem = ET.SubElement(item, "sparkle:channel") +elem.text = "stable" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py index ff1fb4be5..1876f0a17 100644 --- a/dist/macos/update_appcast_tip.py +++ b/dist/macos/update_appcast_tip.py @@ -75,6 +75,8 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) +elem = ET.SubElement(item, "sparkle:channel") +elem.text = "tip" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 87d76f801..2c56e5f4e 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -125,7 +125,15 @@ fileprivate struct UpdateAvailableView: View { let dismiss: DismissAction private let labelWidth: CGFloat = 60 - + + private func releaseDateString(date: Date, channel: String?) -> String { + let dateString = date.formatted(date: .abbreviated, time: .omitted) + if let channel, !channel.isEmpty { + return "\(dateString) (\(channel))" + } + return dateString + } + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -157,7 +165,7 @@ fileprivate struct UpdateAvailableView: View { Text("Released:") .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) - Text(date.formatted(date: .abbreviated, time: .omitted)) + Text(releaseDateString(date: date, channel: update.appcastItem.channel)) } .font(.system(size: 11)) } From 1fdc0c0b9f84f95abda54cffc8af1780fa6928ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Dec 2025 13:58:02 -0800 Subject: [PATCH 141/605] terminal: CSI S compatiblity improvements Fixes #9905 This fixes a major compatibility issues with the CSI S sequence: When our top margin is at the top (row 0) without left/right margins, we should be creating scrollback. Previously, we were only deleting. --- src/terminal/Terminal.zig | 195 ++++++++++++++++++++++++++++--- src/terminal/stream_readonly.zig | 2 +- src/termio/stream_handler.zig | 2 +- 3 files changed, 182 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9db6a8d..3d00abf74 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1219,7 +1219,7 @@ pub fn index(self: *Terminal) !void { // this check. !self.screens.active.blankCell().isZero()) { - self.scrollUp(1); + try self.scrollUp(1); return; } @@ -1398,7 +1398,7 @@ pub fn scrollDown(self: *Terminal, count: usize) void { /// The new lines are created according to the current SGR state. /// /// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) void { +pub fn scrollUp(self: *Terminal, count: usize) !void { // Preserve our x/y to restore. const old_x = self.screens.active.cursor.x; const old_y = self.screens.active.cursor.y; @@ -1408,6 +1408,32 @@ pub fn scrollUp(self: *Terminal, count: usize) void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + // Scrolling dirties the images because it updates their placements pins. + if (comptime build_options.kitty_graphics) { + self.screens.active.kitty_images.dirty = true; + } + + // Clamp count to the scroll region height. + const region_height = self.scrolling_region.bottom + 1; + const adjusted_count = @min(count, region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); + for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); + return; + } + // Move to the top of the scroll region self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.deleteLines(count); @@ -5635,14 +5661,16 @@ test "Terminal: scrollUp simple" { t.setCursorPos(2, 2); const cursor = t.screens.active.cursor; - t.clearDirty(); - t.scrollUp(1); + const viewport_before = t.screens.active.pages.getTopLeft(.viewport); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + const viewport_after = t.screens.active.pages.getTopLeft(.viewport); + try testing.expect(!viewport_before.eql(viewport_after)); { const str = try t.plainString(testing.allocator); @@ -5666,7 +5694,7 @@ test "Terminal: scrollUp moves hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5717,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5755,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" { t.setCursorPos(1, 1); t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5787,7 +5815,7 @@ test "Terminal: scrollUp left/right scroll region" { const cursor = t.screens.active.cursor; t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); @@ -5819,7 +5847,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5919,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" { try t.print('B'); t.setCursorPos(3, 5); try t.print('C'); - t.scrollUp(1); + try t.scrollUp(1); try t.print('X'); { @@ -5940,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" { t.setTopAndBottomMargin(2, 5); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5966,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { t.setLeftAndRightMargin(2, 4); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5982,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { } } +test "Terminal: scrollUp creates scrollback in primary screen" { + // When in primary screen with full-width scroll region at top, + // scrollUp (CSI S) should push lines into scrollback like xterm. + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 }); + defer t.deinit(alloc); + + // Fill the screen with content + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEE"); + + t.clearDirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + try t.scrollUp(1); + + // The cursor row (new empty row) should be dirty + try testing.expect(t.screens.active.cursor.page_row.dirty); + + // The active screen should now show BBBBB through EEEEE plus one blank line + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // Should see AAAAA in scrollback + try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero" { + // When max_scrollback is 0, scrollUp should still work but not retain history + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + + try t.scrollUp(1); + + // Active screen should show scrolled content + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } + + // Scroll to top - should be same as active since no scrollback + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and top margin" { + // When max_scrollback is 0 and top margin is set, should use deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + + // Set top margin (not at row 0) + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // First row preserved, rest scrolled + try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and left/right margin" { + // When max_scrollback is 0 with left/right margins, uses deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAABBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCCDDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 6); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // cols 1-5 scroll, col 0 and cols 6+ preserved + try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str); + } +} + test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 3b088e2b7..c33dba1bb 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -100,7 +100,7 @@ pub const Handler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .horizontal_tab => try self.horizontalTab(value), .horizontal_tab_back => try self.horizontalTabBack(value), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index eabfd6a4b..182770339 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -246,7 +246,7 @@ pub const StreamHandler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .tab_clear_current => self.terminal.tabClear(.current), .tab_clear_all => self.terminal.tabClear(.all), From bbda6c35e3c3acd5f87a26a0ba3e7c5f5efeb74f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:05:17 +0000 Subject: [PATCH 142/605] build(deps): bump actions/download-artifact from 6.0.0 to 7.0.0 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53...37930b1c2abaa49bbe596cd826c3c89aef350131) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 10 +++++----- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..a25b8659d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -286,7 +286,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -309,7 +309,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -357,17 +357,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 641bbcca6..6fc7e0fb4 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..d959fd6b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1075,7 +1075,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball From 7e5683ebfd780808347d0e3c76ae95dfe0375983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:05:24 +0000 Subject: [PATCH 143/605] build(deps): bump actions/upload-artifact from 5.0.0 to 6.0.0 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/330a01c490aca151604b8cf639adc76d48f6c5d4...b7c566a772e6b6bfb58ed0dc250532a479d7789f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 6 +++--- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..5adb90ea0 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -113,7 +113,7 @@ jobs: nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -269,7 +269,7 @@ jobs: zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/ - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos path: |- @@ -340,7 +340,7 @@ jobs: mv appcast_new.xml appcast.xml - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sparkle path: |- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..9720eb345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,7 +397,7 @@ jobs: - name: Upload artifact id: upload-artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- From b0c053cfb7a69e81da2f0eb9e411db31bd11c1b0 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 14 Dec 2025 19:21:45 -0500 Subject: [PATCH 144/605] zsh: document unsupported system-level ZDOTDIR We rely on temporarily setting ZDOTDIR to our `zsh` resource directory to implement automatic shell integration. Setting ZDOTDIR in a system file like /etc/zshenv overrides our ZDOTDIR value, preventing our shell integration from being loaded. The only way to prevent /etc/zshenv from being run is via the --no-rcs flag. (The --no-globalrcs only applies to system-level files _after_ /etc/zshenv is loaded.) Unfortunately, there doesn't appear to be a way to run a "bootstrap" script (to reimplement the Zsh startup sequence manually, similar to how our bash integration works) and then enter an interactive shell session. https://zsh.sourceforge.io/Doc/Release/Files.html Given all of the above, document this as an unsupported configuration for automatic shell integration and point affected users at our manual shell integration option. --- src/shell-integration/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 3f8543c68..9c422ef26 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -78,10 +78,16 @@ on the Fish startup process, see the ### Zsh -For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration -from the `zsh` directory. The existing `ZDOTDIR` is retained so that -after loading the Ghostty shell integration the normal Zsh loading -sequence occurs. +Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting +`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable +value will be retained and restored after our shell integration scripts are +run. + +However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will +override Ghostty's `ZDOTDIR` value, preventing the shell integration from being +loaded. In this case, the shell integration needs to be loaded manually. + +To load the Zsh shell integration manually: ```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then From a0a915a06f42b8add7ee1773666d81b877cd9989 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Mon, 15 Dec 2025 06:31:54 -0600 Subject: [PATCH 145/605] refactor(build): simplify dependency detection logic - Removes unnecessary marker constant from build.zig that existed solely to signal build root status - Uses filesystem check (@src().file access) instead of compile-time declaration lookup to detect when ghostty is a dependency - Same behavior with less indirection: file resolves from build root only when ghostty is the main project --- build.zig | 5 ----- src/build/Config.zig | 22 ++++++++-------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/build.zig b/build.zig index 472c3957a..fa68b91b4 100644 --- a/build.zig +++ b/build.zig @@ -318,8 +318,3 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } - -/// Marker used by Config.zig to detect if ghostty is the build root. -/// This avoids running logic such as Git tag checking when Ghostty -/// is used as a dependency. -pub const _ghostty_build_root = true; diff --git a/src/build/Config.zig b/src/build/Config.zig index 981cd7de5..3a8a4e0c7 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -219,20 +219,14 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { else version: { const app_version = try std.SemanticVersion.parse(appVersion); - // Detect if ghostty is being built as a dependency by checking if the - // build root has our marker. When used as a dependency, we skip git - // detection entirely to avoid reading the downstream project's git state. - const is_dependency = !@hasDecl( - @import("root"), - "_ghostty_build_root", - ); - if (is_dependency) { - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - }; - } + // Is ghostty a dependency? If so, skip git detection. + // @src().file won't resolve from b.build_root unless ghostty + // is the project being built. + b.build_root.handle.access(@src().file, .{}) catch break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { From b15f16995c4b198fa5ff4d9627ea8af57a8a2d69 Mon Sep 17 00:00:00 2001 From: James Baumgarten Date: Fri, 25 Jul 2025 22:47:12 -0600 Subject: [PATCH 146/605] Fix i3 window border disappearing after fullscreen toggle When toggling a Ghostty window between fullscreen and windowed mode in the i3 window manager, window borders would disappear and not return. Root cause was that syncAppearance() was updating X11 properties on every call during window transitions, even when values hadn't changed. These redundant property updates interfered with i3's border management. The fix adds caching to syncBlur() and syncDecorations() to only update X11 properties when values actually change, eliminating unnecessary property changes during fullscreen transitions. --- src/apprt/gtk/winproto/x11.zig | 51 ++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 9dc273563..c73d4d482 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,6 +173,10 @@ pub const Window = struct { blur_region: Region = .{}, + // Cache last applied values to avoid redundant X11 property updates + last_applied_blur_region: ?Region = null, + last_applied_decoration_hints: ?MotifWMHints = null, + pub fn init( alloc: Allocator, app: *App, @@ -255,19 +259,34 @@ pub const Window = struct { const gtk_widget = self.apprt_window.as(gtk.Widget); const config = if (self.apprt_window.getConfig()) |v| v.get() else return; + // When blur is disabled, remove the property if it was previously set + const blur = config.@"background-blur"; + if (!blur.enabled()) { + if (self.last_applied_blur_region != null) { + try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = null; + } + return; + } + // Transform surface coordinates to device coordinates. const scale = gtk_widget.getScaleFactor(); self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; - const blur = config.@"background-blur"; log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - if (blur.enabled()) { + // Only update X11 properties when the blur region actually changes + const region_changed = if (self.last_applied_blur_region) |last| + !std.meta.eql(self.blur_region, last) + else + true; + + if (region_changed) { try self.changeProperty( Region, self.app.atoms.kde_blur, @@ -276,8 +295,7 @@ pub const Window = struct { .{ .mode = .replace }, &self.blur_region, ); - } else { - try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = self.blur_region; } } @@ -307,14 +325,23 @@ pub const Window = struct { .auto, .client, .none => false, }; - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); + // Only update decoration hints when they actually change + const hints_changed = if (self.last_applied_decoration_hints) |last| + !std.meta.eql(hints, last) + else + true; + + if (hints_changed) { + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + self.last_applied_decoration_hints = hints; + } } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { From 4ec8adc1b99149db1b10641049dedceb44714043 Mon Sep 17 00:00:00 2001 From: James Baumgarten Date: Fri, 25 Jul 2025 22:58:42 -0600 Subject: [PATCH 147/605] trailing whitespace From 47462ccc954e191506efac1f77389166ba1dcee3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 09:38:36 -0800 Subject: [PATCH 148/605] clean up some blurring code --- src/apprt/gtk/winproto/x11.zig | 63 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index c73d4d482..1e73c6139 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,7 +173,9 @@ pub const Window = struct { blur_region: Region = .{}, - // Cache last applied values to avoid redundant X11 property updates + // Cache last applied values to avoid redundant X11 property updates. + // Redundant property updates seem to cause some visual glitches + // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 last_applied_blur_region: ?Region = null, last_applied_decoration_hints: ?MotifWMHints = null, @@ -266,6 +268,7 @@ pub const Window = struct { try self.deleteProperty(self.app.atoms.kde_blur); self.last_applied_blur_region = null; } + return; } @@ -274,29 +277,26 @@ pub const Window = struct { self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; + // Only update X11 properties when the blur region actually changes + if (self.last_applied_blur_region) |last| { + if (std.meta.eql(self.blur_region, last)) return; + } + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - // Only update X11 properties when the blur region actually changes - const region_changed = if (self.last_applied_blur_region) |last| - !std.meta.eql(self.blur_region, last) - else - true; - - if (region_changed) { - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - self.last_applied_blur_region = self.blur_region; - } + try self.changeProperty( + Region, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + &self.blur_region, + ); + self.last_applied_blur_region = self.blur_region; } fn syncDecorations(self: *Window) !void { @@ -326,22 +326,19 @@ pub const Window = struct { }; // Only update decoration hints when they actually change - const hints_changed = if (self.last_applied_decoration_hints) |last| - !std.meta.eql(hints, last) - else - true; - - if (hints_changed) { - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); - self.last_applied_decoration_hints = hints; + if (self.last_applied_decoration_hints) |last| { + if (std.meta.eql(hints, last)) return; } + + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + self.last_applied_decoration_hints = hints; } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { From 07578d5e3f12e4fe20c899b1472a21bc768671dc Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Mon, 15 Dec 2025 18:59:34 +0100 Subject: [PATCH 149/605] nix: replace deprecated system with stdenv.hostPlatform.system --- nix/devShell.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index 4aaf4ef5c..d37107133 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -70,7 +70,6 @@ wayland-scanner, wayland-protocols, zon2nix, - system, pkgs, # needed by GTK for loading SVG icons while running from within the # developer shell @@ -100,7 +99,7 @@ in scdoc zig zip - zon2nix.packages.${system}.zon2nix + zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix # For web and wasm stuff nodejs From a02364cbefe0cb718679ec49543c979aa1a134cc Mon Sep 17 00:00:00 2001 From: Justy Null Date: Fri, 19 Sep 2025 22:23:32 -0700 Subject: [PATCH 150/605] feat: add liquid glass background effect support --- .../Window Styles/TerminalWindow.swift | 61 +++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 13 +++- src/config/Config.zig | 25 ++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 160473328..69b4b3f1a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -44,6 +44,9 @@ class TerminalWindow: NSWindow { true } + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController @@ -476,6 +479,11 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) + // Add liquid glass behind terminal content + if #available(macOS 26.0, *), derivedConfig.backgroundGlassStyle != "off" { + setupGlassLayer() + } + if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, @@ -484,6 +492,11 @@ class TerminalWindow: NSWindow { } else { isOpaque = true + // Remove liquid glass when not transparent + if #available(macOS 26.0, *) { + removeGlassLayer() + } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -562,6 +575,51 @@ class TerminalWindow: NSWindow { } } + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + guard let contentView = contentView else { return } + + // Remove existing glass effect view + glassEffectView?.removeFromSuperview() + + // Get the window content view (parent of the NSHostingView) + guard let windowContentView = contentView.superview else { return } + + // Create NSGlassEffectView for native glass effect + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + let glassStyle = derivedConfig.backgroundGlassStyle + switch glassStyle { + case "regular": + effectView.style = NSGlassEffectView.Style.regular + case "clear": + effectView.style = NSGlassEffectView.Style.clear + default: + // Should not reach here since we check for "off" before calling setupGlassLayer() + return + } + + effectView.cornerRadius = 18 + effectView.tintColor = preferredBackgroundColor + + effectView.frame = windowContentView.bounds + effectView.autoresizingMask = [.width, .height] + + // Position BELOW the terminal content to act as background + windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) + glassEffectView = effectView + } + + @available(macOS 26.0, *) + private func removeGlassLayer() { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + } + +>>>>>>> Conflict 4 of 4 ends // MARK: Config struct DerivedConfig { @@ -569,12 +627,14 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons + let backgroundGlassStyle: String init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible + self.backgroundGlassStyle = "off" } init(_ config: Ghostty.Config) { @@ -582,6 +642,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons + self.backgroundGlassStyle = config.backgroundGlassStyle } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 2df0a8656..20629c58f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,6 +261,17 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } + var backgroundGlassStyle: String { + let defaultValue = "off" + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "background-glass-style" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + return String(cString: ptr) + } + + var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -635,7 +646,7 @@ extension Ghostty.Config { static let title = BellFeatures(rawValue: 1 << 3) static let border = BellFeatures(rawValue: 1 << 4) } - + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" diff --git a/src/config/Config.zig b/src/config/Config.zig index 1deb3e532..b542bcb1d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -950,6 +950,24 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, +/// The style of the glass effect when `background-opacity` is less than 1 +/// and the terminal is using a modern glass effect (macOS 26.0+ only). +/// +/// Valid values are: +/// +/// * `off` - No glass effect +/// * `regular` - Standard glass effect with some opacity +/// * `clear` - Highly transparent glass effect +/// +/// This setting only takes effect on macOS 26.0+ when transparency is enabled +/// (`background-opacity` < 1). On older macOS versions or when transparency +/// is disabled, this setting has no effect. +/// +/// The default value is `off`. +/// +/// Available since: 1.2.2 +@"background-glass-style": BackgroundGlassStyle = .off, + /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see /// which split is focused. To disable this feature, set this value to 1. @@ -8383,6 +8401,13 @@ pub const BackgroundBlur = union(enum) { } }; +/// See background-glass-style +pub const BackgroundGlassStyle = enum { + off, + regular, + clear, +}; + /// See window-decoration pub const WindowDecoration = enum(c_int) { auto, From d40af61960b41652d11e45429cb41b42f40b50a9 Mon Sep 17 00:00:00 2001 From: Justy Null Date: Sat, 20 Sep 2025 11:44:04 -0700 Subject: [PATCH 151/605] refactor: migrate background glass effect to new macos-background-style config --- .../Window Styles/TerminalWindow.swift | 38 ++++++++----- macos/Sources/Ghostty/Ghostty.Config.swift | 10 ++-- macos/Sources/Ghostty/Package.swift | 17 ++++-- src/config/Config.zig | 54 ++++++++++--------- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 69b4b3f1a..51f4d5b1c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,11 +480,9 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.backgroundGlassStyle != "off" { + if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .blur { setupGlassLayer() - } - - if let appDelegate = NSApp.delegate as? AppDelegate { + } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -576,7 +574,7 @@ class TerminalWindow: NSWindow { } // MARK: Glass - + @available(macOS 26.0, *) private func setupGlassLayer() { guard let contentView = contentView else { return } @@ -591,18 +589,18 @@ class TerminalWindow: NSWindow { let effectView = NSGlassEffectView() // Map Ghostty config to NSGlassEffectView style - let glassStyle = derivedConfig.backgroundGlassStyle - switch glassStyle { - case "regular": + let backgroundStyle = derivedConfig.macosBackgroundStyle + switch backgroundStyle { + case .regularGlass: effectView.style = NSGlassEffectView.Style.regular - case "clear": + case .clearGlass: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for "off" before calling setupGlassLayer() + // Should not reach here since we check for "default" before calling setupGlassLayer() return } - effectView.cornerRadius = 18 + effectView.cornerRadius = derivedConfig.windowCornerRadius effectView.tintColor = preferredBackgroundColor effectView.frame = windowContentView.bounds @@ -627,14 +625,16 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let backgroundGlassStyle: String + let macosBackgroundStyle: Ghostty.MacBackgroundStyle + let windowCornerRadius: CGFloat init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.backgroundGlassStyle = "off" + self.macosBackgroundStyle = .blur + self.windowCornerRadius = 16 } init(_ config: Ghostty.Config) { @@ -642,7 +642,17 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons - self.backgroundGlassStyle = config.backgroundGlassStyle + self.macosBackgroundStyle = config.macosBackgroundStyle + + // Set corner radius based on macos-titlebar-style + // Native, transparent, and hidden styles use 16pt radius + // Tabs style uses 20pt radius + switch config.macosTitlebarStyle { + case "tabs": + self.windowCornerRadius = 20 + default: + self.windowCornerRadius = 16 + } } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 20629c58f..1488b0790 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,17 +261,17 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var backgroundGlassStyle: String { - let defaultValue = "off" + var macosBackgroundStyle: MacBackgroundStyle { + let defaultValue = MacBackgroundStyle.blur guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil - let key = "background-glass-style" + let key = "macos-background-style" guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } guard let ptr = v else { return defaultValue } - return String(cString: ptr) + let str = String(cString: ptr) + return MacBackgroundStyle(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 258857e8e..e769b814e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -56,7 +56,7 @@ extension Ghostty { case app case zig_run } - + /// Returns the mechanism that launched the app. This is based on an env var so /// its up to the env var being set in the correct circumstance. static var launchSource: LaunchSource { @@ -65,7 +65,7 @@ extension Ghostty { // source. If its unset we assume we're in a CLI environment. return .cli } - + // If the env var is set but its unknown then we default back to the app. return LaunchSource(rawValue: envValue) ?? .app } @@ -76,17 +76,17 @@ extension Ghostty { extension Ghostty { class AllocatedString { private let cString: ghostty_string_s - + init(_ c: ghostty_string_s) { self.cString = c } - + var string: String { guard let ptr = cString.ptr else { return "" } let data = Data(bytes: ptr, count: Int(cString.len)) return String(data: data, encoding: .utf8) ?? "" } - + deinit { ghostty_string_free(cString) } @@ -352,6 +352,13 @@ extension Ghostty { case hidden } + /// Enum for the macos-background-style config option + enum MacBackgroundStyle: String { + case blur + case regularGlass = "regular-glass" + case clearGlass = "clear-glass" + } + /// Enum for auto-update-channel config option enum AutoUpdateChannel: String { case tip diff --git a/src/config/Config.zig b/src/config/Config.zig index b542bcb1d..287efa89d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -950,24 +950,6 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, -/// The style of the glass effect when `background-opacity` is less than 1 -/// and the terminal is using a modern glass effect (macOS 26.0+ only). -/// -/// Valid values are: -/// -/// * `off` - No glass effect -/// * `regular` - Standard glass effect with some opacity -/// * `clear` - Highly transparent glass effect -/// -/// This setting only takes effect on macOS 26.0+ when transparency is enabled -/// (`background-opacity` < 1). On older macOS versions or when transparency -/// is disabled, this setting has no effect. -/// -/// The default value is `off`. -/// -/// Available since: 1.2.2 -@"background-glass-style": BackgroundGlassStyle = .off, - /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see /// which split is focused. To disable this feature, set this value to 1. @@ -3124,6 +3106,28 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, +/// The background style for macOS windows when `background-opacity` is less than 1. +/// This controls the visual effect applied behind the terminal background. +/// +/// Valid values are: +/// +/// * `blur` - Uses the standard background behavior. The `background-blur` +/// configuration will control whether blur is applied (available on all macOS versions) +/// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) +/// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) +/// +/// The `blur` option does not force any blur effect - it simply respects the +/// `background-blur` configuration. The glass options override `background-blur` +/// and apply their own visual effects. +/// +/// On macOS versions prior to 26.0, only `blur` has an effect. The glass +/// options will fall back to `blur` behavior on older versions. +/// +/// The default value is `blur`. +/// +/// Available since: 1.2.2 +@"macos-background-style": MacBackgroundStyle = .blur, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -7708,6 +7712,13 @@ pub const MacShortcuts = enum { ask, }; +/// See macos-background-style +pub const MacBackgroundStyle = enum { + blur, + @"regular-glass", + @"clear-glass", +}; + /// See gtk-single-instance pub const GtkSingleInstance = enum { false, @@ -8401,13 +8412,6 @@ pub const BackgroundBlur = union(enum) { } }; -/// See background-glass-style -pub const BackgroundGlassStyle = enum { - off, - regular, - clear, -}; - /// See window-decoration pub const WindowDecoration = enum(c_int) { auto, From 45aceace726656e49e145c2f0fa504eb97e80e2e Mon Sep 17 00:00:00 2001 From: Justy Null Date: Sat, 20 Sep 2025 16:05:05 -0700 Subject: [PATCH 152/605] fix: disable renderer background when macOS effects are enabled --- .../Window Styles/TerminalWindow.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- macos/Sources/Ghostty/Package.swift | 2 +- src/config/Config.zig | 15 +++---------- src/renderer/generic.zig | 22 +++++++++++++++++-- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 51f4d5b1c..07deb6ded 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,7 +480,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .blur { + if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .defaultStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( @@ -633,7 +633,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.macosBackgroundStyle = .blur + self.macosBackgroundStyle = .defaultStyle self.windowCornerRadius = 16 } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1488b0790..4a80f2af8 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -262,7 +262,7 @@ extension Ghostty { } var macosBackgroundStyle: MacBackgroundStyle { - let defaultValue = MacBackgroundStyle.blur + let defaultValue = MacBackgroundStyle.defaultStyle guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-background-style" diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e769b814e..4279cc012 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -354,7 +354,7 @@ extension Ghostty { /// Enum for the macos-background-style config option enum MacBackgroundStyle: String { - case blur + case defaultStyle = "default" case regularGlass = "regular-glass" case clearGlass = "clear-glass" } diff --git a/src/config/Config.zig b/src/config/Config.zig index 287efa89d..09731b13d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3111,22 +3111,13 @@ keybind: Keybinds = .{}, /// /// Valid values are: /// -/// * `blur` - Uses the standard background behavior. The `background-blur` +/// * `default` - Uses the standard background behavior. The `background-blur` /// configuration will control whether blur is applied (available on all macOS versions) /// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) /// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) /// -/// The `blur` option does not force any blur effect - it simply respects the -/// `background-blur` configuration. The glass options override `background-blur` -/// and apply their own visual effects. -/// -/// On macOS versions prior to 26.0, only `blur` has an effect. The glass -/// options will fall back to `blur` behavior on older versions. -/// -/// The default value is `blur`. -/// /// Available since: 1.2.2 -@"macos-background-style": MacBackgroundStyle = .blur, +@"macos-background-style": MacBackgroundStyle = .default, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// @@ -7714,7 +7705,7 @@ pub const MacShortcuts = enum { /// See macos-background-style pub const MacBackgroundStyle = enum { - blur, + default, @"regular-glass", @"clear-glass", }; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8c55da602..013761f1e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, + macos_background_style: configpkg.Config.MacBackgroundStyle, pub fn init( alloc_gpa: Allocator, @@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", + .macos_background_style = config.@"macos-background-style", .arena = arena, }; } @@ -644,6 +646,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } }; + /// Determines if the terminal background should be disabled based on platform and config. + /// On macOS, when background effects are enabled (background style != default), the effect + /// layer handles the background rendering instead of the terminal renderer. + fn shouldDisableBackground(config: DerivedConfig) bool { + return switch (builtin.os.tag) { + .macos => config.macos_background_style != .default, + else => false, + }; + } + pub fn init(alloc: Allocator, options: renderer.Options) !Self { // Initialize our graphics API wrapper, this will prepare the // surface provided by the apprt and set up any API-specific @@ -716,7 +728,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - @intFromFloat(@round(options.config.background_opacity * 255.0)), + if (shouldDisableBackground(options.config)) + 0 + else + @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ .cursor_wide = false, @@ -1293,7 +1308,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, self.terminal_state.colors.background.b, - @intFromFloat(@round(self.config.background_opacity * 255.0)), + if (shouldDisableBackground(self.config)) + 0 + else + @intFromFloat(@round(self.config.background_opacity * 255.0)), }; } } From d5c378cd6bb8541b7e6d914e1fc7900a257171f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 12 Oct 2025 13:18:15 -0700 Subject: [PATCH 153/605] minor style tweaks --- .../Terminal/Window Styles/TerminalWindow.swift | 5 +++-- src/config/Config.zig | 13 ++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 07deb6ded..444ce28bd 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -573,6 +573,7 @@ class TerminalWindow: NSWindow { } } +#if compiler(>=6.2) // MARK: Glass @available(macOS 26.0, *) @@ -616,8 +617,8 @@ class TerminalWindow: NSWindow { glassEffectView?.removeFromSuperview() glassEffectView = nil } - ->>>>>>> Conflict 4 of 4 ends +#endif // compiler(>=6.2) + // MARK: Config struct DerivedConfig { diff --git a/src/config/Config.zig b/src/config/Config.zig index 09731b13d..0e7b51c4f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3106,17 +3106,20 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, -/// The background style for macOS windows when `background-opacity` is less than 1. -/// This controls the visual effect applied behind the terminal background. +/// The background style for macOS windows when `background-opacity` is less +/// than 1. This controls the visual effect applied behind the terminal +/// background. /// /// Valid values are: /// /// * `default` - Uses the standard background behavior. The `background-blur` -/// configuration will control whether blur is applied (available on all macOS versions) -/// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) +/// configuration will control whether blur is applied (available on +/// all macOS versions) +/// * `regular-glass` - Standard glass effect with some opacity (macOS +/// 26.0+ only) /// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) /// -/// Available since: 1.2.2 +/// Available since: 1.3.0 @"macos-background-style": MacBackgroundStyle = .default, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. From 42493de0989d1a2c27f5c27deb471c6e08d66ad6 Mon Sep 17 00:00:00 2001 From: Justy Null Date: Fri, 17 Oct 2025 18:39:11 -0700 Subject: [PATCH 154/605] fix: make titlebar transparent when using glass background style --- .../Terminal/Window Styles/TerminalWindow.swift | 3 +++ .../TransparentTitlebarTerminalWindow.swift | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 444ce28bd..6105cac53 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -627,6 +627,7 @@ class TerminalWindow: NSWindow { let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons let macosBackgroundStyle: Ghostty.MacBackgroundStyle + let macosTitlebarStyle: String let windowCornerRadius: CGFloat init() { @@ -635,6 +636,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.macosBackgroundStyle = .defaultStyle + self.macosTitlebarStyle = "transparent" self.windowCornerRadius = 16 } @@ -644,6 +646,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons self.macosBackgroundStyle = config.macosBackgroundStyle + self.macosTitlebarStyle = config.macosTitlebarStyle // Set corner radius based on macos-titlebar-style // Native, transparent, and hidden styles use 16pt radius diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 08d56c83d..eea1956fc 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -88,7 +88,17 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // color of the titlebar in native fullscreen view. if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { titlebarView.wantsLayer = true - titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + + // For glass background styles, use a transparent titlebar to let the glass effect show through + // Only apply this for transparent and tabs titlebar styles + let isGlassStyle = derivedConfig.macosBackgroundStyle == .regularGlass || + derivedConfig.macosBackgroundStyle == .clearGlass + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || + derivedConfig.macosTitlebarStyle == "tabs" + + titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) + ? NSColor.clear.cgColor + : preferredBackgroundColor?.cgColor } // In all cases, we have to hide the background view since this has multiple subviews From bb2307116662bdf778906f48260524482fba2312 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:29:21 -0800 Subject: [PATCH 155/605] config: change macos-background-style to be enums on background-blur --- macos/Sources/Ghostty/Ghostty.Config.swift | 52 +++++++++++++++++++-- src/config/Config.zig | 54 ++++++++++++++++++---- src/config/c_get.zig | 18 ++++++-- src/renderer/generic.zig | 5 +- 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 4a80f2af8..5a622d19c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -413,12 +413,12 @@ extension Ghostty { return v; } - var backgroundBlurRadius: Int { - guard let config = self.config else { return 1 } - var v: Int = 0 + var backgroundBlur: BackgroundBlur { + guard let config = self.config else { return .disabled } + var v: Int16 = 0 let key = "background-blur" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v; + return BackgroundBlur(fromCValue: v) } var unfocusedSplitOpacity: Double { @@ -637,6 +637,50 @@ extension Ghostty.Config { case download } + /// Background blur configuration that maps from the C API values. + /// Positive values represent blur radius, special negative values + /// represent macOS-specific glass effects. + enum BackgroundBlur: Equatable { + case disabled + case radius(Int) + case macosGlassRegular + case macosGlassClear + + init(fromCValue value: Int16) { + switch value { + case 0: + self = .disabled + case -1: + self = .macosGlassRegular + case -2: + self = .macosGlassClear + default: + self = .radius(Int(value)) + } + } + + var isEnabled: Bool { + switch self { + case .disabled: + return false + default: + return true + } + } + + /// Returns the blur radius if applicable, nil for glass effects. + var radius: Int? { + switch self { + case .disabled: + return nil + case .radius(let r): + return r + case .macosGlassRegular, .macosGlassClear: + return nil + } + } + } + struct BellFeatures: OptionSet { let rawValue: CUnsignedInt diff --git a/src/config/Config.zig b/src/config/Config.zig index 0e7b51c4f..4a3810901 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8338,6 +8338,8 @@ pub const AutoUpdate = enum { pub const BackgroundBlur = union(enum) { false, true, + @"macos-glass-regular", + @"macos-glass-clear", radius: u8, pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { @@ -8347,14 +8349,35 @@ pub const BackgroundBlur = union(enum) { return; }; - self.* = if (cli.args.parseBool(input_)) |b| - if (b) .true else .false - else |_| - .{ .radius = std.fmt.parseInt( - u8, - input_, - 0, - ) catch return error.InvalidValue }; + // Try to parse normal bools + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + return; + } else |_| {} + + // Try to parse enums + if (std.meta.stringToEnum( + std.meta.Tag(BackgroundBlur), + input_, + )) |v| switch (v) { + inline else => |tag| tag: { + // We can only parse void types + const info = std.meta.fieldInfo(BackgroundBlur, tag); + if (info.type != void) break :tag; + self.* = @unionInit( + BackgroundBlur, + @tagName(tag), + {}, + ); + return; + }, + }; + + self.* = .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; } pub fn enabled(self: BackgroundBlur) bool { @@ -8365,11 +8388,16 @@ pub const BackgroundBlur = union(enum) { }; } - pub fn cval(self: BackgroundBlur) u8 { + pub fn cval(self: BackgroundBlur) i16 { return switch (self) { .false => 0, .true => 20, .radius => |v| v, + // I hate sentinel values like this but this is only for + // our macOS application currently. We can switch to a proper + // tagged union if we ever need to. + .@"macos-glass-regular" => -1, + .@"macos-glass-clear" => -2, }; } @@ -8381,6 +8409,8 @@ pub const BackgroundBlur = union(enum) { .false => try formatter.formatEntry(bool, false), .true => try formatter.formatEntry(bool, true), .radius => |v| try formatter.formatEntry(u8, v), + .@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"), + .@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"), } } @@ -8400,6 +8430,12 @@ pub const BackgroundBlur = union(enum) { try v.parseCLI("42"); try testing.expectEqual(42, v.radius); + try v.parseCLI("macos-glass-regular"); + try testing.expectEqual(.@"macos-glass-regular", v); + + try v.parseCLI("macos-glass-clear"); + try testing.expectEqual(.@"macos-glass-clear", v); + try testing.expectError(error.InvalidValue, v.parseCLI("")); try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); try testing.expectError(error.InvalidValue, v.parseCLI("420")); diff --git a/src/config/c_get.zig b/src/config/c_get.zig index f235f596a..0f8f897a2 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -193,20 +193,32 @@ test "c_get: background-blur" { { c.@"background-blur" = .false; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(0, cval); } { c.@"background-blur" = .true; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(20, cval); } { c.@"background-blur" = .{ .radius = 42 }; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(42, cval); } + { + c.@"background-blur" = .@"macos-glass-regular"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-1, cval); + } + { + c.@"background-blur" = .@"macos-glass-clear"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-2, cval); + } } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 013761f1e..e3db3cd93 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -728,10 +728,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - if (shouldDisableBackground(options.config)) - 0 - else - @intFromFloat(@round(options.config.background_opacity * 255.0)), + @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ .cursor_wide = false, From a6ddf03a2ee5aec49bb4a4488e3061d8bd737839 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:48:20 -0800 Subject: [PATCH 156/605] remove the macos-background-style config --- .../Window Styles/TerminalWindow.swift | 17 +++++----- .../TransparentTitlebarTerminalWindow.swift | 3 +- macos/Sources/Ghostty/Ghostty.Config.swift | 21 ++++++------ macos/Sources/Ghostty/Package.swift | 7 ---- src/config/Config.zig | 29 ++++------------ src/renderer/generic.zig | 33 ++++++++++--------- 6 files changed, 42 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6105cac53..7066f7bd6 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,7 +480,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .defaultStyle { + if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( @@ -590,14 +590,13 @@ class TerminalWindow: NSWindow { let effectView = NSGlassEffectView() // Map Ghostty config to NSGlassEffectView style - let backgroundStyle = derivedConfig.macosBackgroundStyle - switch backgroundStyle { - case .regularGlass: + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: effectView.style = NSGlassEffectView.Style.regular - case .clearGlass: + case .macosGlassClear: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for "default" before calling setupGlassLayer() + // Should not reach here since we check for glass style before calling setupGlassLayer() return } @@ -623,10 +622,10 @@ class TerminalWindow: NSWindow { struct DerivedConfig { let title: String? + let backgroundBlur: Ghostty.Config.BackgroundBlur let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosBackgroundStyle: Ghostty.MacBackgroundStyle let macosTitlebarStyle: String let windowCornerRadius: CGFloat @@ -635,7 +634,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.macosBackgroundStyle = .defaultStyle + self.backgroundBlur = .disabled self.macosTitlebarStyle = "transparent" self.windowCornerRadius = 16 } @@ -645,7 +644,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons - self.macosBackgroundStyle = config.macosBackgroundStyle + self.backgroundBlur = config.backgroundBlur self.macosTitlebarStyle = config.macosTitlebarStyle // Set corner radius based on macos-titlebar-style diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index eea1956fc..57b889b82 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -91,8 +91,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // For glass background styles, use a transparent titlebar to let the glass effect show through // Only apply this for transparent and tabs titlebar styles - let isGlassStyle = derivedConfig.macosBackgroundStyle == .regularGlass || - derivedConfig.macosBackgroundStyle == .clearGlass + let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || derivedConfig.macosTitlebarStyle == "tabs" diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 5a622d19c..47826a104 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,17 +261,6 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var macosBackgroundStyle: MacBackgroundStyle { - let defaultValue = MacBackgroundStyle.defaultStyle - guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil - let key = "macos-background-style" - 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 MacBackgroundStyle(rawValue: str) ?? defaultValue - } - var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -668,6 +657,16 @@ extension Ghostty.Config { } } + /// Returns true if this is a macOS glass style (regular or clear). + var isGlassStyle: Bool { + switch self { + case .macosGlassRegular, .macosGlassClear: + return true + default: + return false + } + } + /// Returns the blur radius if applicable, nil for glass effects. var radius: Int? { switch self { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4279cc012..b834ea31f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -352,13 +352,6 @@ extension Ghostty { case hidden } - /// Enum for the macos-background-style config option - enum MacBackgroundStyle: String { - case defaultStyle = "default" - case regularGlass = "regular-glass" - case clearGlass = "clear-glass" - } - /// Enum for auto-update-channel config option enum AutoUpdateChannel: String { case tip diff --git a/src/config/Config.zig b/src/config/Config.zig index 4a3810901..18224a3cd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -927,6 +927,12 @@ palette: Palette = .{}, /// reasonable for a good looking blur. Higher blur intensities may /// cause strange rendering and performance issues. /// +/// On macOS 26.0 and later, there are additional special values that +/// can be set to use the native macOS glass effects: +/// +/// * `macos-glass-regular` - Standard glass effect with some opacity +/// * `macos-glass-clear` - Highly transparent glass effect +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -3106,22 +3112,6 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, -/// The background style for macOS windows when `background-opacity` is less -/// than 1. This controls the visual effect applied behind the terminal -/// background. -/// -/// Valid values are: -/// -/// * `default` - Uses the standard background behavior. The `background-blur` -/// configuration will control whether blur is applied (available on -/// all macOS versions) -/// * `regular-glass` - Standard glass effect with some opacity (macOS -/// 26.0+ only) -/// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) -/// -/// Available since: 1.3.0 -@"macos-background-style": MacBackgroundStyle = .default, - /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -7706,13 +7696,6 @@ pub const MacShortcuts = enum { ask, }; -/// See macos-background-style -pub const MacBackgroundStyle = enum { - default, - @"regular-glass", - @"clear-glass", -}; - /// See gtk-single-instance pub const GtkSingleInstance = enum { false, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e3db3cd93..39eec7b43 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,7 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, - macos_background_style: configpkg.Config.MacBackgroundStyle, + background_blur: configpkg.Config.BackgroundBlur, pub fn init( alloc_gpa: Allocator, @@ -634,7 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", - .macos_background_style = config.@"macos-background-style", + .background_blur = config.@"background-blur", .arena = arena, }; } @@ -646,16 +646,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } }; - /// Determines if the terminal background should be disabled based on platform and config. - /// On macOS, when background effects are enabled (background style != default), the effect - /// layer handles the background rendering instead of the terminal renderer. - fn shouldDisableBackground(config: DerivedConfig) bool { - return switch (builtin.os.tag) { - .macos => config.macos_background_style != .default, - else => false, - }; - } - pub fn init(alloc: Allocator, options: renderer.Options) !Self { // Initialize our graphics API wrapper, this will prepare the // surface provided by the apprt and set up any API-specific @@ -728,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, + // Note that if we're on macOS with glass effects + // we'll disable background opacity but we handle + // that in updateFrame. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -1305,10 +1298,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, self.terminal_state.colors.background.b, - if (shouldDisableBackground(self.config)) - 0 - else - @intFromFloat(@round(self.config.background_opacity * 255.0)), + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; + + // If we're on macOS and have glass styles, we remove + // the background opacity because the glass effect handles + // it. + if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, }; } } From 8482e0777db9f675641c5567d562f5f4d43b5fc4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:58:35 -0800 Subject: [PATCH 157/605] macos: remove glass view on syncAppearance with blur --- .../Window Styles/TerminalWindow.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 7066f7bd6..0c0ac0646 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -483,6 +483,11 @@ class TerminalWindow: NSWindow { if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { + // If we had a prior glass layer we should remove it + if #available(macOS 26.0, *) { + removeGlassLayer() + } + ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -578,12 +583,11 @@ class TerminalWindow: NSWindow { @available(macOS 26.0, *) private func setupGlassLayer() { - guard let contentView = contentView else { return } - // Remove existing glass effect view - glassEffectView?.removeFromSuperview() - + removeGlassLayer() + // Get the window content view (parent of the NSHostingView) + guard let contentView else { return } guard let windowContentView = contentView.superview else { return } // Create NSGlassEffectView for native glass effect @@ -596,13 +600,13 @@ class TerminalWindow: NSWindow { case .macosGlassClear: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for glass style before calling setupGlassLayer() - return + // Should not reach here since we check for glass style before calling + // setupGlassLayer() + assertionFailure() } effectView.cornerRadius = derivedConfig.windowCornerRadius effectView.tintColor = preferredBackgroundColor - effectView.frame = windowContentView.bounds effectView.autoresizingMask = [.width, .height] From 4e10f27be4fbd1d0c8c2dc84dd9bc3deab339e0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 11:00:53 -0800 Subject: [PATCH 158/605] config: macos blur settings enable blur on non-Mac --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 18224a3cd..409e35516 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -933,6 +933,9 @@ palette: Palette = .{}, /// * `macos-glass-regular` - Standard glass effect with some opacity /// * `macos-glass-clear` - Highly transparent glass effect /// +/// If the macOS values are set, then this implies `background-blur = true` +/// on non-macOS platforms. +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -8368,6 +8371,11 @@ pub const BackgroundBlur = union(enum) { .false => false, .true => true, .radius => |v| v > 0, + + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + .@"macos-glass-regular", .@"macos-glass-clear" => true, }; } From f8c03bb6f6ff7cf71c7e04077059173859496cd2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 21 Sep 2025 00:21:14 -0500 Subject: [PATCH 159/605] logging: document GHOSTTY_LOG and make it more flexible --- HACKING.md | 30 ++++++++++++++++++ src/Surface.zig | 3 ++ src/apprt/gtk/class/application.zig | 6 +++- src/build/GhosttyXcodebuild.zig | 2 +- src/build/mdgen/ghostty_1_footer.md | 13 ++++++++ src/build/mdgen/ghostty_5_header.md | 39 +++++++++++++++++++---- src/cli/args.zig | 2 +- src/global.zig | 23 ++++++-------- src/main_ghostty.zig | 49 ++++++++++++++++------------- 9 files changed, 124 insertions(+), 43 deletions(-) diff --git a/HACKING.md b/HACKING.md index 0a4bbef20..bde50ec99 100644 --- a/HACKING.md +++ b/HACKING.md @@ -93,6 +93,36 @@ produced. > may ask you to fix it and close the issue. It isn't a maintainers job to > review a PR so broken that it requires significant rework to be acceptable. +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + ## Linting ### Prettier diff --git a/src/Surface.zig b/src/Surface.zig index 19c2662c1..96aaf84d8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -607,6 +607,9 @@ pub fn init( }; errdefer env.deinit(); + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d404304d0..c951cc6ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -8,6 +8,8 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const build_config = @import("../../../build_config.zig"); +const state = &@import("../../../global.zig").state; const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); @@ -2677,7 +2679,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { /// disable it. @"vulkan-disable": bool = false, } = .{ - .opengl = config.@"gtk-opengl-debug", + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and config.@"gtk-opengl-debug", }; var gdk_disable: struct { diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 27691d744..5ca4c5e9a 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -151,7 +151,7 @@ pub fn init( // This overrides our default behavior and forces logs to show // up on stderr (in addition to the centralized macOS log). - open.setEnvironmentVariable("GHOSTTY_LOG", "1"); + open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos"); // Configure how we're launching open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run"); diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 88aa16273..a63a85fd4 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -37,6 +37,19 @@ precedence over the XDG environment locations. : **WINDOWS ONLY:** alternate location to search for configuration files. +**GHOSTTY_LOG** + +: The `GHOSTTY_LOG` environment variable can be used to control which +destinations receive logs. Ghostty currently defines two destinations: + +: - `stderr` - logging to `stderr`. +: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +: Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + # BUGS See GitHub issues: diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index b9d4cb751..2b12f546a 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration. ## Configuration Errors If your configuration file has any errors, Ghostty does its best to ignore -them and move on. Configuration errors currently show up in the log. The log -is written directly to stderr, so it is up to you to figure out how to access -that for your system (for now). On macOS, you can also use the system `log` CLI -utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. +them and move on. Configuration errors will be logged. ## Debugging Configuration You can verify that configuration is being properly loaded by looking at the -debug output of Ghostty. Documentation for how to view the debug output is in -the "building Ghostty" section at the end of the README. +debug output of Ghostty. In the debug output, you should see in the first 20 lines or so messages about loading (or not loading) a configuration file, as well as any errors it may have @@ -93,3 +89,34 @@ will fall back to default values for erroneous keys. You can also view the full configuration Ghostty is loading using `ghostty +show-config` from the command-line. Use the `--help` flag to additional options for that command. + +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug +--predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. diff --git a/src/cli/args.zig b/src/cli/args.zig index 43a15ca06..bd5060d69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -604,7 +604,7 @@ pub fn parseAutoStruct( return result; } -fn parsePackedStruct(comptime T: type, v: []const u8) !T { +pub fn parsePackedStruct(comptime T: type, v: []const u8) !T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); diff --git a/src/global.zig b/src/global.zig index 8034fabe0..29eaf5f36 100644 --- a/src/global.zig +++ b/src/global.zig @@ -39,9 +39,13 @@ pub const GlobalState = struct { resources_dir: internal_os.ResourcesDir, /// Where logging should go - pub const Logging = union(enum) { - disabled: void, - stderr: void, + pub const Logging = packed struct { + /// Whether to log to stderr. For lib mode we always disable stderr + /// logging by default. Otherwise it's enabled by default. + stderr: bool = build_config.app_runtime != .none, + /// Whether to log to macOS's unified logging. Enabled by default + /// on macOS. + macos: bool = builtin.os.tag.isDarwin(), }; /// Initialize the global state. @@ -61,7 +65,7 @@ pub const GlobalState = struct { .gpa = null, .alloc = undefined, .action = null, - .logging = .{ .stderr = {} }, + .logging = .{}, .rlimits = .{}, .resources_dir = .{}, }; @@ -100,12 +104,7 @@ pub const GlobalState = struct { // If we have an action executing, we disable logging by default // since we write to stderr we don't want logs messing up our // output. - if (self.action != null) self.logging = .{ .disabled = {} }; - - // For lib mode we always disable stderr logging by default. - if (comptime build_config.app_runtime == .none) { - self.logging = .{ .disabled = {} }; - } + if (self.action != null) self.logging.stderr = false; // I don't love the env var name but I don't have it in my heart // to parse CLI args 3 times (once for actions, once for config, @@ -114,9 +113,7 @@ pub const GlobalState = struct { // easy to set. if ((try internal_os.getenv(self.alloc, "GHOSTTY_LOG"))) |v| { defer v.deinit(self.alloc); - if (v.value.len > 0) { - self.logging = .{ .stderr = {} }; - } + self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{}; } // Setup our signal handlers before logging diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 261e0ad7d..72d602989 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -118,19 +118,17 @@ fn logFn( comptime format: []const u8, args: anytype, ) void { - // Stuff we can do before the lock - const level_txt = comptime level.asText(); - const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; - - // Lock so we are thread-safe - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - // On Mac, we use unified logging. To view this: // // sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"' // - if (builtin.target.os.tag.isDarwin()) { + // macOS logging is thread safe so no need for locks/mutexes + macos: { + if (comptime !builtin.target.os.tag.isDarwin()) break :macos; + if (!state.logging.macos) break :macos; + + const prefix = if (scope == .default) "" else @tagName(scope) ++ ": "; + // Convert our levels to Mac levels const mac_level: macos.os.LogType = switch (level) { .debug => .debug, @@ -143,26 +141,35 @@ fn logFn( // but we shouldn't be logging too much. const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, format, args); + logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); } - switch (state.logging) { - .disabled => {}, + stderr: { + // don't log debug messages to stderr unless we are a debug build + if (comptime builtin.mode != .Debug and level == .debug) break :stderr; - .stderr => { - // Always try default to send to stderr - var buffer: [1024]u8 = undefined; - var stderr = std.fs.File.stderr().writer(&buffer); - const writer = &stderr.interface; - nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return; - // TODO: Do we want to use flushless stderr in the future? - writer.flush() catch {}; - }, + // skip if we are not logging to stderr + if (!state.logging.stderr) break :stderr; + + // Lock so we are thread-safe + var buf: [64]u8 = undefined; + const stderr = std.debug.lockStderrWriter(&buf); + defer std.debug.unlockStderrWriter(); + + const level_txt = comptime level.asText(); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr; + nosuspend stderr.flush() catch break :stderr; } } pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. + // + // Note, we don't lower this to debug even with conditional logging + // via GHOSTTY_LOG because our debug logs are very expensive to + // calculate and we want to make sure they're optimized out in + // builds. .log_level = switch (builtin.mode) { .Debug => .debug, else => .info, From 78e539d68453fcedb29b31c7a296a9a816c7858e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 12:28:40 -0800 Subject: [PATCH 160/605] Revert "macos: populate the sparkle:channel element" --- dist/macos/update_appcast_tag.py | 2 -- dist/macos/update_appcast_tip.py | 2 -- .../Sources/Features/Update/UpdatePopoverView.swift | 12 ++---------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py index 8c2ee8314..2cb20dd5d 100644 --- a/dist/macos/update_appcast_tag.py +++ b/dist/macos/update_appcast_tag.py @@ -77,8 +77,6 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) -elem = ET.SubElement(item, "sparkle:channel") -elem.text = "stable" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py index 1876f0a17..ff1fb4be5 100644 --- a/dist/macos/update_appcast_tip.py +++ b/dist/macos/update_appcast_tip.py @@ -75,8 +75,6 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) -elem = ET.SubElement(item, "sparkle:channel") -elem.text = "tip" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 2c56e5f4e..87d76f801 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -125,15 +125,7 @@ fileprivate struct UpdateAvailableView: View { let dismiss: DismissAction private let labelWidth: CGFloat = 60 - - private func releaseDateString(date: Date, channel: String?) -> String { - let dateString = date.formatted(date: .abbreviated, time: .omitted) - if let channel, !channel.isEmpty { - return "\(dateString) (\(channel))" - } - return dateString - } - + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -165,7 +157,7 @@ fileprivate struct UpdateAvailableView: View { Text("Released:") .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) - Text(releaseDateString(date: date, channel: update.appcastItem.channel)) + Text(date.formatted(date: .abbreviated, time: .omitted)) } .font(.system(size: 11)) } From 32395fd83837913a2d4a43998bfd76da744ec887 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Tue, 16 Dec 2025 10:09:07 +0200 Subject: [PATCH 161/605] Fix cmd-click opening of relative/local paths --- src/Surface.zig | 24 +++++++++++++++++++++++- src/config/url.zig | 20 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 19c2662c1..6c00af575 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2034,6 +2034,23 @@ pub fn pwd( return try alloc.dupe(u8, terminal_pwd); } +/// Resolves a relative file path to an absolute path using the terminal's pwd. +fn resolvePathForOpening( + self: *Surface, + path: []const u8, +) Allocator.Error!?[]const u8 { + if (!std.fs.path.isAbsolute(path)) { + const terminal_pwd = self.io.terminal.getPwd() orelse { + return null; + }; + + const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); + return resolved; + } + + return null; +} + /// Returns the x/y coordinate of where the IME (Input Method Editor) /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { @@ -4262,7 +4279,12 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); defer self.alloc.free(str); - try self.openUrl(.{ .kind = .unknown, .url = str }); + + const resolved_path = try self.resolvePathForOpening(str); + defer if (resolved_path) |p| self.alloc.free(p); + + const url_to_open = resolved_path orelse str; + try self.openUrl(.{ .kind = .unknown, .url = url_to_open }); }, ._open_osc8 => { diff --git a/src/config/url.zig b/src/config/url.zig index da3928aff..1901cb6f0 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: Tue, 16 Dec 2025 10:17:54 +0200 Subject: [PATCH 162/605] Add a description to the test section comment --- src/config/url.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/url.zig b/src/config/url.zig index 1901cb6f0..fdbc964d7 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -253,6 +253,7 @@ test "url regex" { .input = "IPv6 in markdown [link](http://[2001:db8::1]/docs)", .expect = "http://[2001:db8::1]/docs", }, + // File paths with spaces .{ .input = "./spaces-end. ", .expect = "./spaces-end. ", From c4cd2ca81d93c4af8d75cd930a6e8691ee36018c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 16 Dec 2025 08:24:18 -0500 Subject: [PATCH 163/605] zsh: removed unused self_dir variable This came from the original Kitty script on which ours is based, but we don't use it. --- src/shell-integration/zsh/ghostty-integration | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index c87630c92..febf3e59c 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -93,9 +93,6 @@ _entrypoint() { _ghostty_deferred_init() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # The directory where ghostty-integration is located: /../shell-integration/zsh. - builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}" - # Enable semantic markup with OSC 133. _ghostty_precmd() { builtin local -i cmd_status=$? From 3f504f33e540b35f772505f6fd5f1a8702ac0c5b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:47:28 -0800 Subject: [PATCH 164/605] ci: color scheme GHA uploads to mirror This changes our GHA that updates our color schemes to also upload it to our dependency mirror at the same time. This prevents issues where the upstream disappears, which we've had many times. --- .github/workflows/update-colorschemes.yml | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index dc3ebb2b6..4ca4d2901 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -37,16 +37,33 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Run zig fetch - id: zig_fetch + - name: Download colorschemes + id: download env: GH_TOKEN: ${{ github.token }} run: | # Get the latest release from iTerm2-Color-Schemes RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest) TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name') - nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" + FILENAME="ghostty-themes-${TAG_NAME}.tgz" + mkdir -p upload + curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "filename=$FILENAME" >> $GITHUB_OUTPUT + + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }} + r2-bucket: ghostty-deps + source-dir: upload + destination-dir: ./ + + - name: Run zig fetch + run: | + nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}" - name: Update zig cache hash run: | @@ -75,5 +92,5 @@ jobs: build.zig.zon.json flatpak/zig-packages.json body: | - Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }} + Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }} labels: dependencies From 1a8eb52e998921aaf3d7f7233fe3d9996fad67e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:52:28 -0800 Subject: [PATCH 165/605] ci: disable many macOS builds we don't use This disables a bunch of configurations that we don't need to actually test for. The main one we want to keep building is Freetype because we sometimes use this to compare behaviors, but Coretext is the default. This is one of the primary drivers of CI CPU time. --- .github/workflows/test.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 854458c09..3a5bf58df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - build-linux-libghostty - build-nix - build-macos - - build-macos-matrix + - build-macos-freetype - build-snap - build-windows - test @@ -464,7 +464,7 @@ jobs: cd macos xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-matrix: + build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test steps: @@ -493,18 +493,10 @@ jobs: - name: Test All run: | nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape - name: Build All run: | nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape build-windows: runs-on: windows-2022 From ef0fec473ae2478c03f3eedd8c6310457809964f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:59:49 -0800 Subject: [PATCH 166/605] ci: move flatpak out to a triggered build similar to snap --- .github/workflows/flatpak.yml | 50 +++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 46 +++++++++++++------------------- 2 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/flatpak.yml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..7c4256e0e --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,50 @@ +on: + workflow_dispatch: + inputs: + source-run-id: + description: run id of the workflow that generated the artifact + required: true + type: string + source-artifact-id: + description: source tarball built during build-dist + required: true + type: string + +name: Flatpak + +jobs: + build: + if: github.repository == 'ghostty-org/ghostty' + name: "Flatpak" + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 + options: --privileged + strategy: + fail-fast: false + matrix: + variant: + - arch: x86_64 + runner: namespace-profile-ghostty-md + - arch: aarch64 + runner: namespace-profile-ghostty-md-arm64 + runs-on: ${{ matrix.variant.runner }} + steps: + - name: Download Source Tarball Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + run-id: ${{ inputs.source-run-id }} + artifact-ids: ${{ inputs.source-artifact-id }} + github-token: ${{ github.token }} + + - name: Extract tarball + run: | + mkdir dist + tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz + + - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + with: + bundle: com.mitchellh.ghostty + manifest-path: dist/flatpak/com.mitchellh.ghostty.yml + cache-key: flatpak-builder-${{ github.sha }} + arch: ${{ matrix.variant.arch }} + verbose: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a5bf58df..30f34120a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: - test-debian-13 - valgrind - zig-fmt - - flatpak + steps: - id: status name: Determine status @@ -421,6 +421,24 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + trigger-flatpak: + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm + needs: [build-dist, build-flatpak] + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Trigger Flatpak workflow + run: | + gh workflow run \ + flatpak.yml \ + --ref ${{ github.ref_name || 'main' }} \ + --field source-run-id=${{ github.run_id }} \ + --field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test @@ -1084,32 +1102,6 @@ jobs: build-args: | DISTRO_VERSION=13 - flatpak: - if: github.repository == 'ghostty-org/ghostty' - name: "Flatpak" - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 - options: --privileged - strategy: - fail-fast: false - matrix: - variant: - - arch: x86_64 - runner: namespace-profile-ghostty-md - - arch: aarch64 - runner: namespace-profile-ghostty-md-arm64 - runs-on: ${{ matrix.variant.runner }} - needs: test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 - with: - bundle: com.mitchellh.ghostty - manifest-path: flatpak/com.mitchellh.ghostty.yml - cache-key: flatpak-builder-${{ github.sha }} - arch: ${{ matrix.variant.arch }} - verbose: true - valgrind: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-lg From d680404fae55b0dc99f1f66c7dbaaf97677fc002 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:33:06 +0100 Subject: [PATCH 167/605] macOS: save&restore quick terminal state --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/App/macOS/AppDelegate.swift | 63 +++++++++++++- .../QuickTerminalController.swift | 83 +++++++++++++------ .../QuickTerminalRestorableState.swift | 26 ++++++ .../QuickTerminalScreenStateCache.swift | 11 ++- .../Terminal/TerminalRestorable.swift | 75 +++++++++++------ 6 files changed, 200 insertions(+), 59 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index eb5d706c3..562166c87 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, Features/QuickTerminal/QuickTerminalPosition.swift, + Features/QuickTerminal/QuickTerminalRestorableState.swift, Features/QuickTerminal/QuickTerminalScreen.swift, Features/QuickTerminal/QuickTerminalScreenStateCache.swift, Features/QuickTerminal/QuickTerminalSize.swift, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 043d85e1e..1697f7438 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -99,11 +99,35 @@ class AppDelegate: NSObject, /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() + /// The current state of the quick terminal. + private var quickTerminalControllerState: QuickTerminalState = .uninitialized + /// Our quick terminal. This starts out uninitialized and only initializes if used. - private(set) lazy var quickController = QuickTerminalController( - ghostty, - position: derivedConfig.quickTerminalPosition - ) + var quickController: QuickTerminalController { + switch quickTerminalControllerState { + case .initialized(let controller): + return controller + + case .pendingRestore(let state): + let controller = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition, + baseConfig: state.baseConfig, + restorationState: state + ) + quickTerminalControllerState = .initialized(controller) + return controller + + case .uninitialized: + let controller = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition, + restorationState: nil + ) + quickTerminalControllerState = .initialized(controller) + return controller + } + } /// Manages updates let updateController = UpdateController() @@ -996,10 +1020,31 @@ class AppDelegate: NSObject, func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) { Self.logger.debug("application will save window state") + + guard ghostty.config.windowSaveState != "never" else { return } + + // Encode our quick terminal state if we have it. + switch quickTerminalControllerState { + case .initialized(let controller) where controller.restorable: + let data = QuickTerminalRestorableState(from: controller) + data.encode(with: coder) + + case .pendingRestore(let state): + state.encode(with: coder) + + default: + break + } } func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) { Self.logger.debug("application will restore window state") + + // Decode our quick terminal state. + if ghostty.config.windowSaveState != "never", + let state = QuickTerminalRestorableState(coder: coder) { + quickTerminalControllerState = .pendingRestore(state) + } } //MARK: - UNUserNotificationCenterDelegate @@ -1273,6 +1318,16 @@ extension AppDelegate: NSMenuItemValidation { } } +/// Represents the state of the quick terminal controller. +private enum QuickTerminalState { + /// Controller has not been initialized and has no pending restoration state. + case uninitialized + /// Restoration state is pending; controller will use this when first accessed. + case pendingRestore(QuickTerminalRestorableState) + /// Controller has been initialized. + case initialized(QuickTerminalController) +} + @globalActor fileprivate actor AppIconActor: GlobalActor { static let shared = AppIconActor() diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 201289736..4377b6510 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -22,7 +22,7 @@ class QuickTerminalController: BaseTerminalController { private var previousActiveSpace: CGSSpace? = nil /// Cache for per-screen window state. - private let screenStateCache = QuickTerminalScreenStateCache() + let screenStateCache: QuickTerminalScreenStateCache /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -32,15 +32,27 @@ class QuickTerminalController: BaseTerminalController { /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false - + + /// This is set to false by init if the window managed by this controller should not be restorable. + /// For example, terminals executing custom scripts are not restorable. + let restorable: Bool + private var restorationState: QuickTerminalRestorableState? + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, - surfaceTree tree: SplitTree? = nil + restorationState: QuickTerminalRestorableState? = nil, ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - + // 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 + // time of writing this: it'd just restore to a shell in the same directory + // as the script. We may want to revisit this behavior when we have scrollback + // restoration. + restorable = (base?.command ?? "") == "" + self.restorationState = restorationState + self.screenStateCache = QuickTerminalScreenStateCache(stateByDisplay: restorationState?.screenStateEntries ?? [:]) // Important detail here: we initialize with an empty surface tree so // that we don't start a terminal process. This gets started when the // first terminal is shown in `animateIn`. @@ -104,8 +116,8 @@ class QuickTerminalController: BaseTerminalController { // window close so we can animate out. window.delegate = self - // The quick window is not restorable (yet!). "Yet" because in theory we can - // make this restorable, but it isn't currently implemented. + // The quick window is restored by `screenStateCache`. + // We disable this for better control window.isRestorable = false // Setup our configured appearance that we support. @@ -342,16 +354,33 @@ class QuickTerminalController: BaseTerminalController { // animate out. if surfaceTree.isEmpty, let ghostty_app = ghostty.app { - var config = Ghostty.SurfaceConfiguration() - config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" - - let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) - surfaceTree = SplitTree(view: view) - focusedSurface = view + if let tree = restorationState?.surfaceTree, !tree.isEmpty { + surfaceTree = tree + let view = tree.first(where: { $0.id.uuidString == restorationState?.focusedSurface }) ?? tree.first! + focusedSurface = view + // Add a short delay to check if the correct surface is focused. + // Each SurfaceWrapper defaults its FocusedValue to itself; without this delay, + // the tree often focuses the first surface instead of the intended one. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if !view.focused { + self.focusedSurface = view + self.makeWindowKey(window) + } + } + } else { + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + surfaceTree = SplitTree(view: view) + focusedSurface = view + } } // Animate the window in animateWindowIn(window: window, from: position) + // Clear the restoration state after first use + restorationState = nil } func animateOut() { @@ -370,6 +399,22 @@ class QuickTerminalController: BaseTerminalController { animateWindowOut(window: window, to: position) } + func saveScreenState(exitFullscreen: Bool) { + // If we are in fullscreen, then we exit fullscreen. We do this immediately so + // we have th correct window.frame for the save state below. + if exitFullscreen, let fullscreenStyle, fullscreenStyle.isFullscreen { + fullscreenStyle.exit() + } + guard let window else { return } + // 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. + if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { + screenStateCache.save(frame: window.frame, for: screen) + } + } + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } @@ -494,19 +539,7 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { - // If we are in fullscreen, then we exit fullscreen. We do this immediately so - // we have th correct window.frame for the save state below. - if let fullscreenStyle, fullscreenStyle.isFullscreen { - fullscreenStyle.exit() - } - - // 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. - if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { - screenStateCache.save(frame: window.frame, for: screen) - } + saveScreenState(exitFullscreen: true) // If we hid the dock then we unhide it. hiddenDock = nil diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift new file mode 100644 index 000000000..1fd8642d8 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalRestorableState.swift @@ -0,0 +1,26 @@ +import Cocoa + +struct QuickTerminalRestorableState: TerminalRestorable { + static var version: Int { 1 } + + let focusedSurface: String? + let surfaceTree: SplitTree + let screenStateEntries: QuickTerminalScreenStateCache.Entries + + init(from controller: QuickTerminalController) { + controller.saveScreenState(exitFullscreen: true) + self.focusedSurface = controller.focusedSurface?.id.uuidString + self.surfaceTree = controller.surfaceTree + self.screenStateEntries = controller.screenStateCache.stateByDisplay + } + + init(copy other: QuickTerminalRestorableState) { + self = other + } + + var baseConfig: Ghostty.SurfaceConfiguration? { + var config = Ghostty.SurfaceConfiguration() + config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" + return config + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift index 7dc53816c..a1c17abb9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -7,6 +7,8 @@ import Cocoa /// to restore to its previous size and position when reopened. It uses stable display UUIDs /// to survive NSScreen garbage collection and automatically prunes stale entries. class QuickTerminalScreenStateCache { + typealias Entries = [UUID: DisplayEntry] + /// The maximum number of saved screen states we retain. This is to avoid some kind of /// pathological memory growth in case we get our screen state serializing wrong. I don't /// know anyone with more than 10 screens, so let's just arbitrarily go with that. @@ -16,9 +18,10 @@ class QuickTerminalScreenStateCache { private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60 /// Keyed by display UUID to survive NSScreen garbage collection. - private var stateByDisplay: [UUID: DisplayEntry] = [:] - - init() { + private(set) var stateByDisplay: Entries = [:] + + init(stateByDisplay: Entries = [:]) { + self.stateByDisplay = stateByDisplay NotificationCenter.default.addObserver( self, selector: #selector(onScreensChanged(_:)), @@ -96,7 +99,7 @@ class QuickTerminalScreenStateCache { } } - private struct DisplayEntry { + struct DisplayEntry: Codable { var frame: NSRect var screenSize: CGSize var scale: CGFloat diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 425f7ffb1..fd0f4eab5 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -1,10 +1,47 @@ import Cocoa +protocol TerminalRestorable: Codable { + static var selfKey: String { get } + static var versionKey: String { get } + static var version: Int { get } + init(copy other: Self) + + /// Returns a base configuration to use when restoring terminal surfaces. + /// Override this to provide custom environment variables or other configuration. + var baseConfig: Ghostty.SurfaceConfiguration? { get } +} + +extension TerminalRestorable { + static var selfKey: String { "state" } + static var versionKey: String { "version" } + + /// Default implementation returns nil (no custom base config). + var baseConfig: Ghostty.SurfaceConfiguration? { nil } + + init?(coder aDecoder: NSCoder) { + // If the version doesn't match then we can't decode. In the future we can perform + // version upgrading or something but for now we only have one version so we + // don't bother. + guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { + return nil + } + + guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { + return nil + } + + self.init(copy: v.value) + } + + func encode(with coder: NSCoder) { + coder.encode(Self.version, forKey: Self.versionKey) + coder.encode(CodableBridge(self), forKey: Self.selfKey) + } +} + /// The state stored for terminal window restoration. -class TerminalRestorableState: Codable { - static let selfKey = "state" - static let versionKey = "version" - static let version: Int = 7 +class TerminalRestorableState: TerminalRestorable { + class var version: Int { 7 } let focusedSurface: String? let surfaceTree: SplitTree @@ -20,28 +57,12 @@ class TerminalRestorableState: Codable { self.titleOverride = controller.titleOverride } - init?(coder aDecoder: NSCoder) { - // If the version doesn't match then we can't decode. In the future we can perform - // version upgrading or something but for now we only have one version so we - // don't bother. - guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else { - return nil - } - - guard let v = aDecoder.decodeObject(of: CodableBridge.self, forKey: Self.selfKey) else { - return nil - } - - self.surfaceTree = v.value.surfaceTree - self.focusedSurface = v.value.focusedSurface - self.effectiveFullscreenMode = v.value.effectiveFullscreenMode - self.tabColor = v.value.tabColor - self.titleOverride = v.value.titleOverride - } - - func encode(with coder: NSCoder) { - coder.encode(Self.version, forKey: Self.versionKey) - coder.encode(CodableBridge(self), forKey: Self.selfKey) + required init(copy other: TerminalRestorableState) { + self.surfaceTree = other.surfaceTree + self.focusedSurface = other.focusedSurface + self.effectiveFullscreenMode = other.effectiveFullscreenMode + self.tabColor = other.tabColor + self.titleOverride = other.titleOverride } } @@ -170,3 +191,5 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } } } + + From f7d0d72f19cb8c6d6bd510a2f77e18e82d560794 Mon Sep 17 00:00:00 2001 From: greathongtu Date: Wed, 17 Dec 2025 00:31:39 +0800 Subject: [PATCH 168/605] remove auto theme include in config-template --- src/cli/list_themes.zig | 12 ++++++++---- src/config/config-template | 7 ------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 716d662b6..42aff9d56 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -2,6 +2,7 @@ const std = @import("std"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config/Config.zig"); +const configpkg = @import("../config.zig"); const themepkg = @import("../config/theme.zig"); const tui = @import("tui.zig"); const global_state = &@import("../global.zig").state; @@ -197,7 +198,7 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { } fn resolveAutoThemePath(alloc: std.mem.Allocator) ![]u8 { - const main_cfg_path = try Config.preferredDefaultFilePath(alloc); + const main_cfg_path = try configpkg.preferredDefaultFilePath(alloc); defer alloc.free(main_cfg_path); const base_dir = std.fs.path.dirname(main_cfg_path) orelse return error.BadPathName; @@ -815,8 +816,8 @@ const Preview = struct { .save => { const theme = self.themes[self.filtered.items[self.current]]; - const width = 90; - const height = 12; + const width = 92; + const height = 17; const child = win.child( .{ .x_off = win.width / 2 -| width / 2, @@ -839,7 +840,10 @@ const Preview = struct { "", "Save the configuration file and then reload it to apply the new theme.", "", - "Or press 'w' to write an auto theme file.", + "Or press 'w' to write an auto theme file to your system's preferred default config path.", + "Then add the following line to your Ghostty configuration and reload:", + "", + "config-file = ?auto/theme.ghostty", "", "For more details on configuration and themes, visit the Ghostty documentation:", "", diff --git a/src/config/config-template b/src/config/config-template index d71c36a9e..63309137a 100644 --- a/src/config/config-template +++ b/src/config/config-template @@ -24,13 +24,6 @@ # reloaded while running; some only apply to new windows and others may require # a full restart to take effect. -# Auto theme include -# ================== -# This include makes it easy to pick a theme via `ghostty +list-themes`: -# press Enter on a theme, then 'w' to write the auto theme file. -# This path is relative to this config file. -config-file = ?auto/theme.ghostty - # Config syntax crash course # ========================== # # The config file consists of simple key-value pairs, From d364e421a84de0af7002a237e9930f91237f2af1 Mon Sep 17 00:00:00 2001 From: lorenries Date: Wed, 8 Oct 2025 12:04:42 -0400 Subject: [PATCH 169/605] introduce split-preserve-zoom config to maintain zoomed splits during navigation --- .../Terminal/BaseTerminalController.swift | 12 ++++++-- macos/Sources/Ghostty/Ghostty.Config.swift | 14 +++++++++ src/apprt/gtk/class/split_tree.zig | 29 +++++++++++++++++++ src/config/Config.zig | 12 ++++++++ src/config/c_get.zig | 16 ++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6336f0f55..98f1bcbf8 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -621,9 +621,14 @@ class BaseTerminalController: NSWindowController, return } - // Remove the zoomed state for this surface tree. if surfaceTree.zoomed != nil { - surfaceTree = .init(root: surfaceTree.root, zoomed: nil) + if derivedConfig.splitPreserveZoom.contains(.navigation) { + surfaceTree = SplitTree( + root: surfaceTree.root, + zoomed: surfaceTree.root?.node(view: nextSurface)) + } else { + surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) + } } // Move focus to the next surface @@ -1188,17 +1193,20 @@ class BaseTerminalController: NSWindowController, let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool let focusFollowsMouse: Bool + let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom init() { self.macosTitlebarProxyIcon = .visible self.windowStepResize = false self.focusFollowsMouse = false + self.splitPreserveZoom = .init() } init(_ config: Ghostty.Config) { self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize self.focusFollowsMouse = config.focusFollowsMouse + self.splitPreserveZoom = config.splitPreserveZoom } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 47826a104..7ea545f7a 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -124,6 +124,14 @@ extension Ghostty { return .init(rawValue: v) } + var splitPreserveZoom: SplitPreserveZoom { + guard let config = self.config else { return .init() } + var v: CUnsignedInt = 0 + let key = "split-preserve-zoom" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + return .init(rawValue: v) + } + var initialWindow: Bool { guard let config = self.config else { return true } var v = true; @@ -690,6 +698,12 @@ extension Ghostty.Config { static let border = BellFeatures(rawValue: 1 << 4) } + struct SplitPreserveZoom: OptionSet { + let rawValue: CUnsignedInt + + static let navigation = SplitPreserveZoom(rawValue: 1 << 0) + } + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 48656c951..46b3268d9 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -340,6 +340,35 @@ pub const SplitTree = extern struct { const surface = tree.nodes[target.idx()].leaf; surface.grabFocus(); + // We also need to setup our last_focused to this because if we + // trigger a tree change like below, the grab focus above never + // actually triggers in time to set this and this ensures we + // grab focus to the right thing. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + if (tree.zoomed != null) { + const app = Application.default(); + const config_obj = app.getConfig(); + defer config_obj.unref(); + const config = config_obj.get(); + + if (!config.@"split-preserve-zoom".navigation) { + tree.zoomed = null; + } else { + tree.zoom(target); + } + + // When the zoom state changes our tree state changes and + // we need to send the proper notifications to trigger + // relayout. + const object = self.as(gobject.Object); + object.notifyByPspec(properties.tree.impl.param_spec); + object.notifyByPspec(properties.@"is-zoomed".impl.param_spec); + } + return true; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 409e35516..7ced916fe 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -985,6 +985,14 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, +/// Control when Ghostty preserves the zoomed state of a split. This is a packed +/// struct so more options can be added in the future. The `navigation` option +/// keeps the current split zoomed when split navigation (`goto_split`) changes +/// the focused split. +/// +/// Example: `split-preserve-zoom = navigation` +@"split-preserve-zoom": SplitPreserveZoom = .{}, + /// The foreground and background color for search matches. This only applies /// to non-focused search matches, also known as candidate matches. /// @@ -7423,6 +7431,10 @@ pub const ShellIntegrationFeatures = packed struct { path: bool = true, }; +pub const SplitPreserveZoom = packed struct { + navigation: bool = false, +}; + pub const RepeatableCommand = struct { value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index 0f8f897a2..dcfdc6716 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -222,3 +222,19 @@ test "c_get: background-blur" { try testing.expectEqual(-2, cval); } } + +test "c_get: split-preserve-zoom" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + + var bits: c_uint = undefined; + try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits))); + try testing.expectEqual(@as(c_uint, 0), bits); + + c.@"split-preserve-zoom".navigation = true; + try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits))); + try testing.expectEqual(@as(c_uint, 1), bits); +} From 4883fd938e90c4f88cc66cb0b24da8eb2d9304fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 11:27:51 -0800 Subject: [PATCH 170/605] config: better docs for split-preserve-zoom --- src/config/Config.zig | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7ced916fe..9b4c1ed94 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -985,12 +985,20 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, -/// Control when Ghostty preserves the zoomed state of a split. This is a packed -/// struct so more options can be added in the future. The `navigation` option -/// keeps the current split zoomed when split navigation (`goto_split`) changes -/// the focused split. +/// Control when Ghostty preserves a zoomed split. Under normal circumstances, +/// any operation that changes focus or layout of the split tree in a window +/// will unzoom any zoomed split. This configuration allows you to control +/// this behavior. +/// +/// This can be set to `navigation` to preserve the zoomed split state +/// when navigating to another split (e.g. via `goto_split`). This will +/// change the zoomed split to the newly focused split instead of unzooming. +/// +/// Any options can also be prefixed with `no-` to disable that option. /// /// Example: `split-preserve-zoom = navigation` +/// +/// Available since: 1.3.0 @"split-preserve-zoom": SplitPreserveZoom = .{}, /// The foreground and background color for search matches. This only applies From 4c6d3f8ed2d53f5881ca525750650066c55c5c9f Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 11:01:41 +0900 Subject: [PATCH 171/605] macos: add `toggle_background_opacity` keybind action --- include/ghostty.h | 1 + .../QuickTerminalController.swift | 10 +++++++- .../Terminal/BaseTerminalController.swift | 19 +++++++++++++++ .../Terminal/TerminalController.swift | 8 +++++++ .../Window Styles/TerminalWindow.swift | 3 +++ macos/Sources/Ghostty/Ghostty.App.swift | 24 +++++++++++++++++++ src/Surface.zig | 6 +++++ src/apprt/action.zig | 4 ++++ src/input/Binding.zig | 11 +++++++++ src/input/command.zig | 6 +++++ 10 files changed, 91 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index b0395b89e..47db34e71 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -803,6 +803,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, GHOSTTY_ACTION_TOGGLE_VISIBILITY, + GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4377b6510..d2db44d2d 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -313,6 +313,13 @@ class QuickTerminalController: BaseTerminalController { animateOut() } + override func toggleBackgroundOpacity() { + super.toggleBackgroundOpacity() + + // Sync the window appearance with the new opacity state + syncAppearance() + } + // MARK: Methods func toggle() { @@ -608,7 +615,8 @@ class QuickTerminalController: BaseTerminalController { guard window.isVisible else { return } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (self.derivedConfig.backgroundOpacity < 1) { + // Also check if the user has overridden transparency to be fully opaque. + if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 98f1bcbf8..892bef555 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -78,6 +78,9 @@ class BaseTerminalController: NSWindowController, /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig + /// Track whether background is forced opaque (true) or using config transparency (false) + var isBackgroundOpaque: Bool = false + /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] @@ -812,6 +815,22 @@ class BaseTerminalController: NSWindowController, } } + // MARK: Background Opacity + + /// Toggle the background opacity between transparent and opaque states. + /// If the configured background-opacity is already opaque (>= 1), this resets + /// the override flag to false so that future config changes take effect. + /// Subclasses should override this to sync their appearance after toggling. + func toggleBackgroundOpacity() { + // If config is already opaque, just ensure override is disabled + if ghostty.config.backgroundOpacity >= 1 { + isBackgroundOpaque = false + } else { + // Otherwise toggle between transparent and opaque + isBackgroundOpaque.toggle() + } + } + // MARK: Fullscreen /// Toggle fullscreen for the given mode. diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a980723ba..29b856cdb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -176,6 +176,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr syncAppearance(focusedSurface.derivedConfig) } + override func toggleBackgroundOpacity() { + super.toggleBackgroundOpacity() + + // Sync the window appearance with the new opacity state + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } + // MARK: Terminal Creation /// Returns all the available terminal controllers present in the app currently. diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 0c0ac0646..730cdea65 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -469,7 +469,10 @@ class TerminalWindow: NSWindow { // 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. + // Also check if the user has overridden transparency to be fully opaque. + let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && + !forceOpaque && surfaceConfig.backgroundOpacity < 1 { isOpaque = false diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2cd0a362a..4e9d039d4 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -573,6 +573,9 @@ extension Ghostty { case GHOSTTY_ACTION_TOGGLE_VISIBILITY: toggleVisibility(app, target: target) + case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY: + toggleBackgroundOpacity(app, target: target) + case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) @@ -1375,6 +1378,27 @@ extension Ghostty { } } + private static func toggleBackgroundOpacity( + _ app: ghostty_app_t, + target: ghostty_target_s + ) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle background opacity does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface, + let surfaceView = self.surfaceView(from: surface), + let controller = surfaceView.window?.windowController as? BaseTerminalController else { return } + + controller.toggleBackgroundOpacity() + + default: + assertionFailure() + } + } + private static func toggleSecureInput( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/Surface.zig b/src/Surface.zig index 4786e0b86..d84e786f3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5518,6 +5518,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_background_opacity => return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_background_opacity, + {}, + ), + .show_on_screen_keyboard => return try self.rt_app.performAction( .{ .surface = self }, .show_on_screen_keyboard, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index af1c22552..7b9e9d222 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -115,6 +115,9 @@ pub const Action = union(Key) { /// Toggle the visibility of all Ghostty terminal windows. toggle_visibility, + /// Toggle the window background opacity. This currently only works on macOS. + toggle_background_opacity, + /// Moves a tab by a relative offset. /// /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 @@ -335,6 +338,7 @@ pub const Action = union(Key) { toggle_quick_terminal, toggle_command_palette, toggle_visibility, + toggle_background_opacity, move_tab, goto_tab, goto_split, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 31672bc1a..9f3ad8a2a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -755,6 +755,16 @@ pub const Action = union(enum) { /// Only implemented on macOS. toggle_visibility, + /// Toggle the window background opacity between transparent and opaque. + /// + /// This does nothing when `background-opacity` is set to 1 or above. + /// + /// When `background-opacity` is less than 1, this action will either make + /// the window transparent or not depending on its current transparency state. + /// + /// Only implemented on macOS. + toggle_background_opacity, + /// Check for updates. /// /// Only implemented on macOS. @@ -1240,6 +1250,7 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_mouse_reporting, .toggle_command_palette, + .toggle_background_opacity, .show_on_screen_keyboard, .reset_window_size, .crash, diff --git a/src/input/command.zig b/src/input/command.zig index a377effa2..d5daafd7d 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -618,6 +618,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle whether mouse events are reported to terminal applications.", }}, + .toggle_background_opacity => comptime &.{.{ + .action = .toggle_background_opacity, + .title = "Toggle Background Opacity", + .description = "Toggle the window background between transparent and opaque.", + }}, + .check_for_updates => comptime &.{.{ .action = .check_for_updates, .title = "Check for Updates", From ded3dd4cbcf84e0156c3cdd4eb43ae0a2d6c2e89 Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 12:56:20 +0900 Subject: [PATCH 172/605] refactor(macos): do nothing if `background-opacity >= 1` --- .../Terminal/BaseTerminalController.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 892bef555..f8e0cc8e9 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -818,17 +818,14 @@ class BaseTerminalController: NSWindowController, // MARK: Background Opacity /// Toggle the background opacity between transparent and opaque states. - /// If the configured background-opacity is already opaque (>= 1), this resets - /// the override flag to false so that future config changes take effect. - /// Subclasses should override this to sync their appearance after toggling. + /// Do nothing if the configured background-opacity is >= 1 (already opaque). + /// Subclasses should override this to add platform-specific checks and sync appearance. func toggleBackgroundOpacity() { - // If config is already opaque, just ensure override is disabled - if ghostty.config.backgroundOpacity >= 1 { - isBackgroundOpaque = false - } else { - // Otherwise toggle between transparent and opaque - isBackgroundOpaque.toggle() - } + // Do nothing if config is already fully opaque + guard ghostty.config.backgroundOpacity < 1 else { return } + + // Toggle between transparent and opaque + isBackgroundOpaque.toggle() } // MARK: Fullscreen From 8d49c698e47519a889269cbdcdef33f705135767 Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 12:56:52 +0900 Subject: [PATCH 173/605] refactor(macos): do nothing if in fullscreen --- macos/Sources/Features/Terminal/TerminalController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 29b856cdb..cc5b48700 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -177,6 +177,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } override func toggleBackgroundOpacity() { + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) + guard let window = self.window, !window.styleMask.contains(.fullScreen) else { return } + super.toggleBackgroundOpacity() // Sync the window appearance with the new opacity state From ba2cbef1f1d3effcfbc672dbea52ac9b0b01bdcf Mon Sep 17 00:00:00 2001 From: himura467 Date: Fri, 10 Oct 2025 14:56:15 +0900 Subject: [PATCH 174/605] apprt/gtk: list `toggle_background_opacity` as unimplemented --- src/apprt/gtk/class/application.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index c951cc6ac..be0f3f2c8 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -741,6 +741,7 @@ pub const Application = extern struct { .close_all_windows, .float_window, .toggle_visibility, + .toggle_background_opacity, .cell_size, .key_sequence, .render_inspector, From f9a1f526c897a3f8c94c697f3624de0e1c250fcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 11:38:25 -0800 Subject: [PATCH 175/605] update some copy for the background opacity toggle --- src/apprt/action.zig | 4 +++- src/input/command.zig | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 7b9e9d222..8e0a9d018 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -115,7 +115,9 @@ pub const Action = union(Key) { /// Toggle the visibility of all Ghostty terminal windows. toggle_visibility, - /// Toggle the window background opacity. This currently only works on macOS. + /// Toggle the window background opacity. This only has an effect + /// if the window started as transparent (non-opaque), and toggles + /// it between fully opaque and the configured background opacity. toggle_background_opacity, /// Moves a tab by a relative offset. diff --git a/src/input/command.zig b/src/input/command.zig index d5daafd7d..6ac4312a9 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -621,7 +621,7 @@ fn actionCommands(action: Action.Key) []const Command { .toggle_background_opacity => comptime &.{.{ .action = .toggle_background_opacity, .title = "Toggle Background Opacity", - .description = "Toggle the window background between transparent and opaque.", + .description = "Toggle the background opacity of a window that started transparent.", }}, .check_for_updates => comptime &.{.{ From 95f4093e96f98dc963575a043ece13778c339cd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 12:59:51 -0800 Subject: [PATCH 176/605] macos: make syncAppearance a virtual method on BaseTerminalController --- .../QuickTerminalController.swift | 9 +----- .../Terminal/BaseTerminalController.swift | 21 +++++++++++++- .../Terminal/TerminalController.swift | 29 +++++-------------- .../Window Styles/TerminalWindow.swift | 1 + 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index d2db44d2d..8a642034f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -313,13 +313,6 @@ class QuickTerminalController: BaseTerminalController { animateOut() } - override func toggleBackgroundOpacity() { - super.toggleBackgroundOpacity() - - // Sync the window appearance with the new opacity state - syncAppearance() - } - // MARK: Methods func toggle() { @@ -603,7 +596,7 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance() { + override func syncAppearance() { guard let window else { return } defer { updateColorSchemeForSurfaceTree() } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f8e0cc8e9..5f067c128 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -815,7 +815,7 @@ class BaseTerminalController: NSWindowController, } } - // MARK: Background Opacity + // MARK: Appearance /// Toggle the background opacity between transparent and opaque states. /// Do nothing if the configured background-opacity is >= 1 (already opaque). @@ -823,9 +823,25 @@ class BaseTerminalController: NSWindowController, func toggleBackgroundOpacity() { // Do nothing if config is already fully opaque guard ghostty.config.backgroundOpacity < 1 else { return } + + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) + guard let window, !window.styleMask.contains(.fullScreen) else { return } // Toggle between transparent and opaque isBackgroundOpaque.toggle() + + // Update our appearance + syncAppearance() + } + + /// Override this to resync any appearance related properties. This will be called automatically + /// when certain window properties change that affect appearance. The list below should be updated + /// as we add new things: + /// + /// - ``toggleBackgroundOpacity`` + func syncAppearance() { + // Purposely a no-op. This lets subclasses override this and we can call + // it virtually from here. } // MARK: Fullscreen @@ -888,6 +904,9 @@ class BaseTerminalController: NSWindowController, } else { updateOverlayIsVisible = defaultUpdateOverlayVisibility() } + + // Always resync our appearance + syncAppearance() } // MARK: Clipboard Confirmation diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cc5b48700..8a0c5f46d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -165,28 +165,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - 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 } - - syncAppearance(focusedSurface.derivedConfig) - } - - override func toggleBackgroundOpacity() { - // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) - guard let window = self.window, !window.styleMask.contains(.fullScreen) else { return } - - super.toggleBackgroundOpacity() - - // Sync the window appearance with the new opacity state - guard let focusedSurface else { return } - syncAppearance(focusedSurface.derivedConfig) - } - // MARK: Terminal Creation /// Returns all the available terminal controllers present in the app currently. @@ -500,6 +478,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tabWindowsHash = v self.relabelTabs() } + + override func syncAppearance() { + // When our focus changes, we update our window appearance based on the + // currently focused surface. + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 730cdea65..4196df97f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -469,6 +469,7 @@ class TerminalWindow: NSWindow { // 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. + // // Also check if the user has overridden transparency to be fully opaque. let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && From ccc2d32aa55021d967162fa3545017e6eb398b10 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Wed, 17 Dec 2025 05:16:27 +0800 Subject: [PATCH 177/605] Fix macOS log command for Ghostty Corrected the command for viewing Ghostty logs on macOS. --- src/build/mdgen/ghostty_5_header.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index 2b12f546a..ce3196eb6 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -101,7 +101,7 @@ On Linux if Ghostty is launched by the default `systemd` user service, you can u `journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. On macOS logging to the macOS unified log is available and enabled by default. ---Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. Ghostty's logging can be configured in two ways. The first is by what From a25a5360f3f8788a16ec7a5185fa8548a9f8a34e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 13:11:26 -0800 Subject: [PATCH 178/605] ai: add /review-branch command This is a subcommand I've been using for some time. It takes an optional issue/PR number as context and produces a prompt to review a branch for how well it addresses the issue along with any isolated issues it spots. Example: https://ampcode.com/threads/T-019b2877-475f-758d-ae88-93c722561576 --- .agents/commands/review-branch | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 .agents/commands/review-branch diff --git a/.agents/commands/review-branch b/.agents/commands/review-branch new file mode 100755 index 000000000..edd8bcbd8 --- /dev/null +++ b/.agents/commands/review-branch @@ -0,0 +1,75 @@ +#!/usr/bin/env nu + +# A command to review the changes made in the current Git branch. +# +# IMPORTANT: This command is prompted to NOT write any code and to ONLY +# produce a review summary. You should still be vigilant when running this +# but that is the expected behavior. +# +# The optional `` parameter can be an issue number, PR number, +# or a full GitHub URL to provide additional context. +def main [ + issue?: any, # Optional GitHub issue/PR number or URL for context +] { + let issueContext = if $issue != null { + let data = gh issue view $issue --json author,title,number,body,comments | from json + let comments = if ($data.comments? != null) { + $data.comments | each { |comment| + let author = if ($comment.author?.login? != null) { $comment.author.login } else { "unknown" } + $" +### Comment by ($author) +($comment.body) +" | str trim + } | str join "\n\n" + } else { + "" + } + + $" +## Source Issue: ($data.title) \(#($data.number)\) + +### Description +($data.body) + +### Comments +($comments) +" + } else { + "" + } + + $" +# Branch Review + +Inspect the changes made in this Git branch. Identify any possible issues +and suggest improvements. Do not write code. Explain the problems clearly +and propose a brief plan for addressing them. +($issueContext) +## Your Tasks + +You are an experienced software developer with expertise in code review. + +Review the change history between the current branch and its +base branch. Analyze all relevant code for possible issues, including but +not limited to: + +- Code quality and readability +- Code style that matches or mimics the rest of the codebase +- Potential bugs or logical errors +- Edge cases that may not be handled +- Performance considerations +- Security vulnerabilities +- Backwards compatibility \(if applicable\) +- Test coverage and effectiveness + +For test coverage, consider if the changes are in an area of the codebase +that is testable. If so, check if there are appropriate tests added or +modified. Consider if the code itself should be modified to be more +testable. + +Think deeply about the implications of the changes here and proposed. +Consult the oracle if you have access to it. + +**ONLY CREATE A SUMMARY. DO NOT WRITE ANY CODE.** +" | str trim +} From f37acdf6a0935d30ea0dbb7ea3a26d5cc55bba4f Mon Sep 17 00:00:00 2001 From: Dominique Martinet Date: Fri, 14 Nov 2025 13:22:36 +0900 Subject: [PATCH 179/605] gtk/opengl: print an error when OpenGL version is too old #1123 added a warning when the OpenGL version is too old, but it is never used because GTK enforces the version set with gl_area.setRequiredVersion() before prepareContext() is called: we end up with a generic "failed to make GL context" error: warning(gtk_ghostty_surface): failed to make GL context current: Unable to create a GL context warning(gtk_ghostty_surface): this error is almost always due to a library, driver, or GTK issue warning(gtk_ghostty_surface): this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context This patch removes the requirement at the GTK level and lets the ghostty renderer check, now failing as follow: info(opengl): loaded OpenGL 4.2 error(opengl): OpenGL version is too old. Ghostty requires OpenGL 4.3 warning(gtk_ghostty_surface): failed to initialize surface err=error.OpenGLOutdated warning(gtk_ghostty_surface): surface failed to initialize err=error.SurfaceError (Note that this does not render a ghostty window, unlike the previous error which rendered the "Unable to acquire an OpenGL context for rendering." view, so while the error itself is easier to understand it might be harder to view) --- src/apprt/gtk/class/surface.zig | 8 +------- src/renderer/OpenGL.zig | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 548ae1a6a..93d1beeb2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1658,13 +1658,7 @@ pub const Surface = extern struct { }; priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); - // Initialize our GLArea. We only set the values we can't set - // in our blueprint file. - const gl_area = priv.gl_area; - gl_area.setRequiredVersion( - renderer.OpenGL.MIN_VERSION_MAJOR, - renderer.OpenGL.MIN_VERSION_MINOR, - ); + // Setup properties we can't set from our Blueprint file. self.as(gtk.Widget).setCursorFromName("text"); // Initialize our config diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index da577f957..4b01da0c5 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -137,15 +137,7 @@ fn prepareContext(getProcAddress: anytype) !void { errdefer gl.glad.unload(); log.info("loaded OpenGL {}.{}", .{ major, minor }); - // Enable debug output for the context. - try gl.enable(gl.c.GL_DEBUG_OUTPUT); - - // Register our debug message callback with the OpenGL context. - gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); - - // Enable SRGB framebuffer for linear blending support. - try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); - + // Need to check version before trying to enable it if (major < MIN_VERSION_MAJOR or (major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR)) { @@ -155,6 +147,15 @@ fn prepareContext(getProcAddress: anytype) !void { ); return error.OpenGLOutdated; } + + // Enable debug output for the context. + try gl.enable(gl.c.GL_DEBUG_OUTPUT); + + // Register our debug message callback with the OpenGL context. + gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); + + // Enable SRGB framebuffer for linear blending support. + try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); } /// This is called early right after surface creation. From 67f9bb9e8a67ccbf38c1523e036291f304828da2 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Wed, 17 Dec 2025 15:13:47 +0200 Subject: [PATCH 180/605] Fix link opening by resolving existing relative paths --- src/Surface.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index d84e786f3..fc5a239ab 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2048,6 +2048,12 @@ fn resolvePathForOpening( }; const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); + + std.fs.accessAbsolute(resolved, .{}) catch { + self.alloc.free(resolved); + return null; + }; + return resolved; } From 139a23a0a2f4879c884dfab075c45ca93eb5ae64 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 17 Dec 2025 09:57:32 -0500 Subject: [PATCH 181/605] Pull out debugging into a separate function. --- src/font/shaper/coretext.zig | 136 +++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 54 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 15ac5762b..6b01d79aa 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -103,6 +103,17 @@ pub const Shaper = struct { } }; + const RunOffset = struct { + x: f64 = 0, + y: f64 = 0, + }; + + const CellOffset = struct { + cluster: u32 = 0, + x: f64 = 0, + y: f64 = 0, + }; + /// Create a CoreFoundation Dictionary suitable for /// settings the font features of a CoreText font. fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary { @@ -378,21 +389,14 @@ pub const Shaper = struct { self.cf_release_pool.appendAssumeCapacity(line); // This keeps track of the current offsets within a run. - var run_offset: struct { - x: f64 = 0, - y: f64 = 0, - } = .{}; + var run_offset: RunOffset = .{}; // This keeps track of the current offsets within a cell. - var cell_offset: struct { - cluster: u32 = 0, - x: f64 = 0, - y: f64 = 0, + var cell_offset: CellOffset = .{}; - // For debugging positions, turn this on: - //start_index: usize = 0, - //end_index: usize = 0, - } = .{}; + // For debugging positions, turn this on: + //var start_index: usize = 0; + //var end_index: usize = 0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -448,59 +452,26 @@ pub const Shaper = struct { .cluster = cluster, .x = run_offset.x, .y = run_offset.y, - - // For debugging positions, turn this on: - //.start_index = index, - //.end_index = index, }; // For debugging positions, turn this on: + // start_index = index; + // end_index = index; //} else { - // if (index < cell_offset.start_index) { - // cell_offset.start_index = index; + // if (index < start_index) { + // start_index = index; // } - // if (index > cell_offset.end_index) { - // cell_offset.end_index = index; + // if (index > end_index) { + // end_index = index; // } } + // For debugging positions, turn this on: + //try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index); + const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - // For debugging positions, turn this on: - //const advance_x_offset = run_offset.x - cell_offset.x; - //const advance_y_offset = run_offset.y - cell_offset.y; - //const x_offset_diff = x_offset - advance_x_offset; - //const y_offset_diff = y_offset - advance_y_offset; - - //if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { - // var allocating = std.Io.Writer.Allocating.init(alloc); - // const writer = &allocating.writer; - // const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; - // for (codepoints) |cp| { - // if (cp.codepoint == 0) continue; // Skip surrogate pair padding - // try writer.print("\\u{{{x}}}", .{cp.codepoint}); - // } - // try writer.writeAll(" → "); - // for (codepoints) |cp| { - // if (cp.codepoint == 0) continue; // Skip surrogate pair padding - // try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); - // } - // const formatted_cps = try allocating.toOwnedSlice(); - - // log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - // cluster, - // x_offset, - // y_offset, - // advance_x_offset, - // advance_y_offset, - // x_offset_diff, - // y_offset_diff, - // state.codepoints.items[index].codepoint, - // formatted_cps, - // }); - //} - self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), .x_offset = @intFromFloat(@round(x_offset)), @@ -680,6 +651,63 @@ pub const Shaper = struct { _ = self; } }; + + fn debugPositions( + self: *Shaper, + alloc: Allocator, + run_offset: RunOffset, + cell_offset: CellOffset, + position: macos.graphics.Point, + start_index: usize, + end_index: usize, + index: usize, + ) !void { + const state = &self.run_state; + const x_offset = position.x - cell_offset.x; + const y_offset = position.y - cell_offset.y; + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y - cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + var allocating = std.Io.Writer.Allocating.init(alloc); + const writer = &allocating.writer; + const codepoints = state.codepoints.items[start_index .. end_index + 1]; + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + const formatted_cps = try allocating.toOwnedSlice(); + + // Note that the codepoints from `start_index .. end_index + 1` + // might not include all the codepoints being shaped. Sometimes a + // codepoint gets represented in a glyph with a later codepoint + // such that the index for the former codepoint is skipped and just + // the index for the latter codepoint is used. Additionally, this + // gets called as we iterate through the glyphs, so it won't + // include the codepoints that come later that might be affecting + // positions for the current glyph. Usually though, for that case + // the positions of the later glyphs will also be affected and show + // up in the logs. + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cell_offset.cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); + } + } }; test "run iterator" { From d820a633eeb293d8da7052a0d31097a7c0023d18 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 08:34:30 -0800 Subject: [PATCH 182/605] fix up typos --- typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typos.toml b/typos.toml index 26876aef9..27ec9d684 100644 --- a/typos.toml +++ b/typos.toml @@ -56,6 +56,8 @@ DECID = "DECID" flate = "flate" typ = "typ" kend = "kend" +# Tai Tham is a script/writing system +Tham = "Tham" # GTK GIR = "GIR" # terminfo From 7e46200d318cc18c67deccb33f3cb3a9ca80cb1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 08:56:21 -0800 Subject: [PATCH 183/605] macos: command palette entries support leading color --- .../Features/Command Palette/CommandPalette.swift | 9 +++++++++ .../Command Palette/TerminalCommandPalette.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 79c3ca756..70b444827 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -6,6 +6,7 @@ struct CommandOption: Identifiable, Hashable { let description: String? let symbols: [String]? let leadingIcon: String? + let leadingColor: Color? let badge: String? let emphasis: Bool let action: () -> Void @@ -15,6 +16,7 @@ struct CommandOption: Identifiable, Hashable { description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, + leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, action: @escaping () -> Void @@ -23,6 +25,7 @@ struct CommandOption: Identifiable, Hashable { self.description = description self.symbols = symbols self.leadingIcon = leadingIcon + self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis self.action = action @@ -283,6 +286,12 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: action) { HStack(spacing: 8) { + if let color = option.leadingColor { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + if let icon = option.leadingIcon { Image(systemName: icon) .foregroundStyle(option.emphasis ? Color.accentColor : .secondary) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 96ff3d0c1..95e5e6a01 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -62,7 +62,7 @@ struct TerminalCommandPaletteView: View { return CommandOption( title: c.title, description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, ) { onAction(c.action) } From 5d11bdddc3e81011b4543f19f6ea563a0e515ed6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:04:51 -0800 Subject: [PATCH 184/605] macos: implement the present terminal action so we can use that --- .../Terminal/BaseTerminalController.swift | 14 +++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 29 +++++++++++++++++-- macos/Sources/Ghostty/Package.swift | 3 ++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5f067c128..b70fd2c56 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -195,6 +195,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidResizeSplit(_:)), name: Ghostty.Notification.didResizeSplit, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidPresentTerminal(_:)), + name: Ghostty.Notification.ghosttyPresentTerminal, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -700,6 +705,15 @@ class BaseTerminalController: NSWindowController, } } + @objc private func ghosttyDidPresentTerminal(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + + // Bring the window to front and focus the surface without activating the app + window?.orderFrontRegardless() + Ghostty.moveFocus(to: target) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9d039d4..3348ab714 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -627,12 +627,13 @@ extension Ghostty { case GHOSTTY_ACTION_SEARCH_SELECTED: searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_PRESENT_TERMINAL: + return presentTerminal(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: fallthrough - case GHOSTTY_ACTION_PRESENT_TERMINAL: - fallthrough case GHOSTTY_ACTION_SIZE_LIMIT: fallthrough case GHOSTTY_ACTION_QUIT_TIMER: @@ -845,6 +846,30 @@ extension Ghostty { } } + private static func presentTerminal( + _ app: ghostty_app_t, + target: ghostty_target_s + ) -> Bool { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + return false + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + + NotificationCenter.default.post( + name: Notification.ghosttyPresentTerminal, + object: surfaceView + ) + return true + + default: + assertionFailure() + return false + } + } + private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b834ea31f..375e5c37b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -435,6 +435,9 @@ extension Ghostty.Notification { /// New window. Has base surface config requested in userinfo. static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow") + /// Present terminal. Bring the surface's window to focus without activating the app. + static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal") + /// Toggle fullscreen of current window static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen") static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue From e1e782c617ac311d6cb535de23fd23d5c687b437 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:10:46 -0800 Subject: [PATCH 185/605] macos: clean up the way command options are built up --- .../TerminalCommandPalette.swift | 120 +++++++++--------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 95e5e6a01..8150dbdbb 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View { /// The callback when an action is submitted. var onAction: ((String) -> Void) - // The commands available to the command palette. - private var commandOptions: [CommandOption] { - var options: [CommandOption] = [] - - // Add update command if an update is installable. This must always be the first so - // it is at the top. - if let updateViewModel, updateViewModel.state.isInstallable { - // We override the update available one only because we want to properly - // convey it'll go all the way through. - let title: String - if case .updateAvailable = updateViewModel.state { - title = "Update Ghostty and Restart" - } else { - title = updateViewModel.text - } - - options.append(CommandOption( - title: title, - description: updateViewModel.description, - leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", - badge: updateViewModel.badge, - emphasis: true - ) { - (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() - }) - } - - // Add cancel/skip update command if the update is installable - if let updateViewModel, updateViewModel.state.isInstallable { - options.append(CommandOption( - title: "Cancel or Skip Update", - description: "Dismiss the current update process" - ) { - updateViewModel.state.cancel() - }) - } - - // Add terminal commands - guard let surface = surfaceView.surfaceModel else { return options } - do { - let terminalCommands = try surface.commands().map { c in - return CommandOption( - title: c.title, - description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, - ) { - onAction(c.action) - } - } - options.append(contentsOf: terminalCommands) - } catch { - return options - } - - return options - } - var body: some View { ZStack { if isPresented { @@ -116,6 +59,69 @@ struct TerminalCommandPaletteView: View { } } } + + /// All commands available in the command palette, combining update and terminal options. + private var commandOptions: [CommandOption] { + var options: [CommandOption] = [] + options.append(contentsOf: updateOptions) + options.append(contentsOf: terminalOptions) + return options + } + + /// Commands for installing or canceling available updates. + private var updateOptions: [CommandOption] { + var options: [CommandOption] = [] + + guard let updateViewModel, updateViewModel.state.isInstallable else { + return options + } + + // We override the update available one only because we want to properly + // convey it'll go all the way through. + let title: String + if case .updateAvailable = updateViewModel.state { + title = "Update Ghostty and Restart" + } else { + title = updateViewModel.text + } + + options.append(CommandOption( + title: title, + description: updateViewModel.description, + leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", + badge: updateViewModel.badge, + emphasis: true + ) { + (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() + }) + + options.append(CommandOption( + title: "Cancel or Skip Update", + description: "Dismiss the current update process" + ) { + updateViewModel.state.cancel() + }) + + return options + } + + /// Commands exposed by the terminal surface. + private var terminalOptions: [CommandOption] { + guard let surface = surfaceView.surfaceModel else { return [] } + do { + return try surface.commands().map { c in + CommandOption( + title: c.title, + description: c.description, + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, + ) { + onAction(c.action) + } + } + } catch { + return [] + } + } } /// This is done to ensure that the given view is in the responder chain. From 835fe3dd0fce241bc249ebc9e7a33b78d0ffe32e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:17:09 -0800 Subject: [PATCH 186/605] macos: add the active terminals to our command palette to jump --- .../TerminalCommandPalette.swift | 29 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 9 ++++-- .../Helpers/Extensions/String+Extension.swift | 9 ++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 8150dbdbb..e3da9ff56 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,6 +64,7 @@ struct TerminalCommandPaletteView: View { private var commandOptions: [CommandOption] { var options: [CommandOption] = [] options.append(contentsOf: updateOptions) + options.append(contentsOf: jumpOptions) options.append(contentsOf: terminalOptions) return options } @@ -122,6 +123,34 @@ struct TerminalCommandPaletteView: View { return [] } } + + /// Commands for jumping to other terminal surfaces. + private var jumpOptions: [CommandOption] { + TerminalController.all.flatMap { controller -> [CommandOption] in + guard let window = controller.window else { return [] } + + let color = (window as? TerminalWindow)?.tabColor + let displayColor = color != TerminalTabColor.none ? color : nil + + return controller.surfaceTree.map { surface in + let title = surface.title.isEmpty ? window.title : surface.title + let displayTitle = title.isEmpty ? "Untitled" : title + + return CommandOption( + title: "Focus: \(displayTitle)", + description: surface.pwd?.abbreviatedPath, + leadingIcon: "rectangle.on.rectangle", + leadingColor: displayColor?.displayColor.map { Color($0) } + ) { + NotificationCenter.default.post( + name: Ghostty.Notification.ghosttyPresentTerminal, + object: surface + ) + } + } + } + } + } /// This is done to ensure that the given view is in the responder chain. diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b70fd2c56..9e8aece2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -709,9 +709,12 @@ class BaseTerminalController: NSWindowController, guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } - // Bring the window to front and focus the surface without activating the app - window?.orderFrontRegardless() - Ghostty.moveFocus(to: target) + // Bring the window to front and focus the surface. + window?.makeKeyAndOrderFront(nil) + + // We use a small delay to ensure this runs after any UI cleanup + // (e.g., command palette restoring focus to its original surface). + Ghostty.moveFocus(to: target, delay: 0.1) } // MARK: Local Events diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 0c1c4fe91..a8d93091a 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -17,4 +17,13 @@ extension String { return url } #endif + + /// Returns the path with the home directory abbreviated as ~. + var abbreviatedPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + if hasPrefix(home) { + return "~" + dropFirst(home.count) + } + return self + } } From b30e94c0ece807b2a8af006758842db446ba8722 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:32:39 -0800 Subject: [PATCH 187/605] macos: sort in the focus jumps in other commands --- .../Command Palette/TerminalCommandPalette.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index e3da9ff56..6d6a89162 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -63,9 +63,16 @@ struct TerminalCommandPaletteView: View { /// All commands available in the command palette, combining update and terminal options. private var commandOptions: [CommandOption] { var options: [CommandOption] = [] + // Updates always appear first options.append(contentsOf: updateOptions) - options.append(contentsOf: jumpOptions) - options.append(contentsOf: terminalOptions) + + // Sort the rest. We replace ":" with a character that sorts before space + // so that "Foo:" sorts before "Foo Bar:". + options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in + let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") + let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") + return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending + }) return options } From 1fd3f27e26ca17ac3f299ade8f534f099d43f0e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:44:01 -0800 Subject: [PATCH 188/605] macos: show pwd for jump options --- .../Command Palette/CommandPalette.swift | 20 ++++++++++++++++--- .../TerminalCommandPalette.swift | 8 +++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 70b444827..3cb4e3480 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -3,6 +3,7 @@ import SwiftUI struct CommandOption: Identifiable, Hashable { let id = UUID() let title: String + let subtitle: String? let description: String? let symbols: [String]? let leadingIcon: String? @@ -13,6 +14,7 @@ struct CommandOption: Identifiable, Hashable { init( title: String, + subtitle: String? = nil, description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, @@ -22,6 +24,7 @@ struct CommandOption: Identifiable, Hashable { action: @escaping () -> Void ) { self.title = title + self.subtitle = subtitle self.description = description self.symbols = symbols self.leadingIcon = leadingIcon @@ -55,7 +58,10 @@ struct CommandPaletteView: View { if query.isEmpty { return options } else { - return options.filter { $0.title.localizedCaseInsensitiveContains(query) } + return options.filter { + $0.title.localizedCaseInsensitiveContains(query) || + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) + } } } @@ -298,8 +304,16 @@ fileprivate struct CommandRow: View { .font(.system(size: 14, weight: .medium)) } - Text(option.title) - .fontWeight(option.emphasis ? .medium : .regular) + VStack(alignment: .leading, spacing: 2) { + Text(option.title) + .fontWeight(option.emphasis ? .medium : .regular) + + if let subtitle = option.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } Spacer() diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 6d6a89162..ecd301208 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -142,10 +142,16 @@ struct TerminalCommandPaletteView: View { return controller.surfaceTree.map { surface in let title = surface.title.isEmpty ? window.title : surface.title let displayTitle = title.isEmpty ? "Untitled" : title + let pwd = surface.pwd?.abbreviatedPath + let subtitle: String? = if let pwd, !displayTitle.contains(pwd) { + pwd + } else { + nil + } return CommandOption( title: "Focus: \(displayTitle)", - description: surface.pwd?.abbreviatedPath, + subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", leadingColor: displayColor?.displayColor.map { Color($0) } ) { From d23f7e051fb207c7d7f20666e888d33818374c7a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:52:05 -0800 Subject: [PATCH 189/605] macos: stable sort for surfaces --- .../Command Palette/CommandPalette.swift | 14 +++++++++++ .../TerminalCommandPalette.swift | 16 +++++++++--- macos/Sources/Helpers/AnySortKey.swift | 25 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Helpers/AnySortKey.swift diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 3cb4e3480..333c69fec 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -1,15 +1,27 @@ import SwiftUI struct CommandOption: Identifiable, Hashable { + /// Unique identifier for this option. let id = UUID() + /// The primary text displayed for this command. let title: String + /// Secondary text displayed below the title. let subtitle: String? + /// Tooltip text shown on hover. let description: String? + /// Keyboard shortcut symbols to display. let symbols: [String]? + /// SF Symbol name for the leading icon. let leadingIcon: String? + /// Color for the leading indicator circle. let leadingColor: Color? + /// Badge text displayed as a pill. let badge: String? + /// Whether to visually emphasize this option. let emphasis: Bool + /// Sort key for stable ordering when titles are equal. + let sortKey: AnySortKey? + /// The action to perform when this option is selected. let action: () -> Void init( @@ -21,6 +33,7 @@ struct CommandOption: Identifiable, Hashable { leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, + sortKey: AnySortKey? = nil, action: @escaping () -> Void ) { self.title = title @@ -31,6 +44,7 @@ struct CommandOption: Identifiable, Hashable { self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis + self.sortKey = sortKey self.action = action } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index ecd301208..19bedd4ee 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -67,11 +67,20 @@ struct TerminalCommandPaletteView: View { options.append(contentsOf: updateOptions) // Sort the rest. We replace ":" with a character that sorts before space - // so that "Foo:" sorts before "Foo Bar:". + // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker + // for stable ordering when titles are equal. options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") - return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending + let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + // Tie-breaker: use sortKey if both have one + if let aSortKey = a.sortKey, let bSortKey = b.sortKey { + return aSortKey < bSortKey + } + return false }) return options } @@ -153,7 +162,8 @@ struct TerminalCommandPaletteView: View { title: "Focus: \(displayTitle)", subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", - leadingColor: displayColor?.displayColor.map { Color($0) } + leadingColor: displayColor?.displayColor.map { Color($0) }, + sortKey: AnySortKey(ObjectIdentifier(surface)) ) { NotificationCenter.default.post( name: Ghostty.Notification.ghosttyPresentTerminal, diff --git a/macos/Sources/Helpers/AnySortKey.swift b/macos/Sources/Helpers/AnySortKey.swift new file mode 100644 index 000000000..6813ccf45 --- /dev/null +++ b/macos/Sources/Helpers/AnySortKey.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Type-erased wrapper for any Comparable type to use as a sort key. +struct AnySortKey: Comparable { + private let value: Any + private let comparator: (Any, Any) -> ComparisonResult + + init(_ value: T) { + self.value = value + self.comparator = { lhs, rhs in + guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame } + if l < r { return .orderedAscending } + if l > r { return .orderedDescending } + return .orderedSame + } + } + + static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool { + lhs.comparator(lhs.value, rhs.value) == .orderedAscending + } + + static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool { + lhs.comparator(lhs.value, rhs.value) == .orderedSame + } +} From e1356538ac70b876bc55bffe0b191465dfe2db62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:57:33 -0800 Subject: [PATCH 190/605] macos: show a highlight animation when a terminal is presented --- .../Terminal/BaseTerminalController.swift | 3 + macos/Sources/Ghostty/SurfaceView.swift | 62 ++++++++++++++++++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 11 ++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9e8aece2d..5129351a1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -715,6 +715,9 @@ class BaseTerminalController: NSWindowController, // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). Ghostty.moveFocus(to: target, delay: 0.1) + + // Show a brief highlight to help the user locate the presented terminal. + target.highlight() } // MARK: Local Events diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 82232dd89..49c6a4982 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -49,7 +49,7 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false - + #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared @@ -219,6 +219,9 @@ extension Ghostty { BellBorderOverlay(bell: surfaceView.bell) } + // Show a highlight effect when this surface needs attention + HighlightOverlay(highlighted: surfaceView.highlighted) + // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { Rectangle().fill(ghostty.config.backgroundColor) @@ -242,6 +245,7 @@ extension Ghostty { } } } + } } @@ -764,6 +768,62 @@ extension Ghostty { } } + /// Visual overlay that briefly highlights a surface to draw attention to it. + /// Uses a soft, soothing highlight with a pulsing border effect. + struct HighlightOverlay: View { + let highlighted: Bool + + @State private var borderPulse: Bool = false + + var body: some View { + ZStack { + Rectangle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.12), + Color.accentColor.opacity(0.03), + Color.clear + ]), + center: .center, + startRadius: 0, + endRadius: 2000 + ) + ) + + Rectangle() + .strokeBorder( + LinearGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.8), + Color.accentColor.opacity(0.5), + Color.accentColor.opacity(0.8) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: borderPulse ? 4 : 2 + ) + .shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0) + .shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0) + } + .allowsHitTesting(false) + .opacity(highlighted ? 1.0 : 0.0) + .animation(.easeOut(duration: 0.4), value: highlighted) + .onChange(of: highlighted) { newValue in + if newValue { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) { + borderPulse = true + } + } else { + withAnimation(.easeOut(duration: 0.4)) { + borderPulse = false + } + } + } + } + } + // MARK: Readonly Badge /// A badge overlay that indicates a surface is in readonly mode. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d26545ebc..88a0bb6e8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -126,6 +126,9 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). + @Published private(set) var highlighted: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -1523,6 +1526,14 @@ extension Ghostty { } } + /// Triggers a brief highlight animation on this surface. + func highlight() { + highlighted = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.highlighted = false + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) From 829dd1b9b23683e5e6bd583b7d1724d1fa69de52 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:13:53 -0800 Subject: [PATCH 191/605] macos: focus shenangians --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5129351a1..d79c89d2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -714,6 +714,7 @@ class BaseTerminalController: NSWindowController, // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). + Ghostty.moveFocus(to: target) Ghostty.moveFocus(to: target, delay: 0.1) // Show a brief highlight to help the user locate the presented terminal. From 842583b628538c4eb6232d9e4de8d23a55404016 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:25:59 -0800 Subject: [PATCH 192/605] macos: fix uikit build --- macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ macos/Sources/Helpers/Extensions/String+Extension.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 568a93314..b2e429455 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -46,6 +46,9 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). + @Published private(set) var highlighted: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index a8d93091a..139a7892c 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -7,7 +7,7 @@ extension String { return self.prefix(maxLength) + trailing } - #if canImport(AppKit) +#if canImport(AppKit) func temporaryFile(_ filename: String = "temp") -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent(filename) @@ -16,7 +16,6 @@ extension String { try? string.write(to: url, atomically: true, encoding: .utf8) return url } - #endif /// Returns the path with the home directory abbreviated as ~. var abbreviatedPath: String { @@ -26,4 +25,5 @@ extension String { } return self } +#endif } From e1d0b2202947f9c497fe521a93c24d57907d97ef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:48:39 -0800 Subject: [PATCH 193/605] macos: allow searching sessions by color too --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Command Palette/CommandPalette.swift | 41 +++++++++++++++++-- .../Extensions/NSColor+Extension.swift | 39 ++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSColor+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 562166c87..1a810e621 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -157,6 +157,7 @@ "Helpers/Extensions/KeyboardShortcut+Extension.swift", "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", + "Helpers/Extensions/NSColor+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 333c69fec..235881dde 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -67,14 +67,23 @@ struct CommandPaletteView: View { @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from - // the query. + // the query. Options with matching leadingColor are ranked higher. var filteredOptions: [CommandOption] { if query.isEmpty { return options } else { - return options.filter { + // Filter by title/subtitle match OR color match + let filtered = options.filter { $0.title.localizedCaseInsensitiveContains(query) || - ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) || + colorMatchScore(for: $0.leadingColor, query: query) > 0 + } + + // Sort by color match score (higher scores first), then maintain original order + return filtered.sorted { a, b in + let scoreA = colorMatchScore(for: a.leadingColor, query: query) + let scoreB = colorMatchScore(for: b.leadingColor, query: query) + return scoreA > scoreB } } } @@ -191,6 +200,32 @@ struct CommandPaletteView: View { isTextFieldFocused = isPresented } } + + /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. + /// Returns 0 if no color name in the query matches, or if the color is nil. + private func colorMatchScore(for color: Color?, query: String) -> Double { + guard let color = color else { return 0 } + + let queryLower = query.lowercased() + let nsColor = NSColor(color) + + var bestScore: Double = 0 + for name in NSColor.colorNames { + guard queryLower.contains(name), + let systemColor = NSColor(named: name) else { continue } + + let distance = nsColor.distance(to: systemColor) + // Max distance in weighted RGB space is ~3.0, so normalize and invert + // Use a threshold to determine "close enough" matches + let maxDistance: Double = 1.5 + if distance < maxDistance { + let score = 1.0 - (distance / maxDistance) + bestScore = max(bestScore, score) + } + } + + return bestScore + } } /// The text field for building the query for the command palette. diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift new file mode 100644 index 000000000..63cf02ed4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -0,0 +1,39 @@ +import AppKit + +extension NSColor { + /// Using a color list let's us get localized names. + private static let appleColorList: NSColorList? = NSColorList(named: "Apple") + + convenience init?(named name: String) { + guard let colorList = Self.appleColorList, + let color = colorList.color(withKey: name.capitalized) else { + return nil + } + guard let components = color.usingColorSpace(.sRGB) else { + return nil + } + self.init( + red: components.redComponent, + green: components.greenComponent, + blue: components.blueComponent, + alpha: components.alphaComponent + ) + } + + static var colorNames: [String] { + appleColorList?.allKeys.map { $0.lowercased() } ?? [] + } + + /// Calculates the perceptual distance to another color in RGB space. + func distance(to other: NSColor) -> Double { + guard let a = self.usingColorSpace(.sRGB), + let b = other.usingColorSpace(.sRGB) else { return .infinity } + + let dr = a.redComponent - b.redComponent + let dg = a.greenComponent - b.greenComponent + let db = a.blueComponent - b.blueComponent + + // Weighted Euclidean distance (human eye is more sensitive to green) + return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db) + } +} From 377bcddb4660370186c65795af76793cfb453ed1 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Thu, 18 Dec 2025 12:52:13 +1100 Subject: [PATCH 194/605] fix(macOS): re-apply icon after app update --- macos/Sources/App/macOS/AppDelegate.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1697f7438..7f6005dd8 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -982,9 +982,15 @@ class AppDelegate: NSObject, appIconName = (colorStrings + [config.macosIconFrame.rawValue]) .joined(separator: "_") } - // Only change the icon if it has actually changed - // from the current one - guard UserDefaults.standard.string(forKey: "CustomGhosttyIcon") != appIconName else { + + // Only change the icon if it has actually changed from the current one, + // or if the app build has changed (e.g. after an update that reset the icon) + let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon") + let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild") + let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + let buildChanged = cachedIconBuild != currentBuild + + guard cachedIconName != appIconName || buildChanged else { #if DEBUG if appIcon == nil { await MainActor.run { @@ -1001,14 +1007,16 @@ class AppDelegate: NSObject, let newIcon = appIcon let appPath = Bundle.main.bundlePath - NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) + guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return } NSWorkspace.shared.noteFileSystemChanged(appPath) await MainActor.run { self.appIcon = newIcon NSApplication.shared.applicationIconImage = newIcon } + UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon") + UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild") } //MARK: - Restorable State From 5a2f5a6b9e87d2d3324dbcb4022cb02df4db8d6e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Dec 2025 13:53:38 -0800 Subject: [PATCH 195/605] terminal: RenderState linkCells needs to use Page y not Viewport y Fixes #9957 Our `Page.getRowAndCell` uses a _page-relative_ x/y coordinate system and we were passing in viewport x/y. This has the possibility to leading to all sorts of bugs, including the crash found in #9957 but also simply reading the wrong cell even in single-page scenarios. --- src/terminal/render.zig | 49 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b6430ea34..093476f2c 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -817,11 +817,12 @@ pub const RenderState = struct { const row_cells = row_slice.items(.cells); // Grab our link ID - const link_page: *page.Page = &row_pins[viewport_point.y].node.data; + const link_pin: PageList.Pin = row_pins[viewport_point.y]; + const link_page: *page.Page = &link_pin.node.data; const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, - viewport_point.y, + link_pin.y, ); // The likely scenario is that our mouse isn't even over a link. @@ -848,7 +849,7 @@ pub const RenderState = struct { const other_page: *page.Page = &pin.node.data; const other = link: { - const rac = other_page.getRowAndCell(x, y); + const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; break :link other_page.hyperlink_set.get( other_page.memory, @@ -1317,6 +1318,48 @@ test "string" { try testing.expectEqualStrings(expected, result); } +test "linkCells with scrollback spanning pages" { + const testing = std.testing; + const alloc = testing.allocator; + + const viewport_rows: size.CellCountInt = 10; + const tail_rows: size.CellCountInt = 5; + + var t = try Terminal.init(alloc, .{ + .cols = page.std_capacity.cols, + .rows = viewport_rows, + .max_scrollback = 10_000, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_cap = pages.pages.first.?.data.capacity.rows; + + // Fill first page + for (0..first_page_cap - 1) |_| try s.nextSlice("\r\n"); + + // Create second page with hyperlink + try s.nextSlice("\r\n"); + try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + for (0..(tail_rows - 1)) |_| try s.nextSlice("\r\n"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const expected_viewport_y: usize = viewport_rows - tail_rows; + // BUG: This crashes without the fix + var cells = try state.linkCells(alloc, .{ + .x = 0, + .y = expected_viewport_y, + }); + defer cells.deinit(alloc); + try testing.expectEqual(@as(usize, 4), cells.count()); +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; From 0ace736f46148216c7d2d5b91d3d786ca7760ec5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Dec 2025 15:35:48 -0800 Subject: [PATCH 196/605] macos: remove the command palette appear/disappear animation Lots of people complained about this and I don't see value in it. --- .../Features/Command Palette/TerminalCommandPalette.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 19bedd4ee..902186ad3 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -39,13 +39,8 @@ struct TerminalCommandPaletteView: View { } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) } - .transition( - .move(edge: .top) - .combined(with: .opacity) - ) } } - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented) .onChange(of: isPresented) { newValue in // When the command palette disappears we need to send focus back to the // surface view we were overlaid on top of. There's probably a better way From 86a0eb1a75892c2a7fb986d00c40a0b7a597c574 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 07:13:43 -0800 Subject: [PATCH 197/605] macos: hide tab overview on `escape` This hides the macOS tab overview when the `escape` key is pressed. Our solution is a bit blunt here and I don't think its right. I think we have a first responder problem somewhere but I haven't been able to find it and find the proper place to implement `cancel` (or equivalent) to hide the overview. I tried implementing `cancel` in all the places I expect the responder chain to go through but none worked. For now let's do this since it is pretty tightly scoped! --- macos/Sources/App/macOS/AppDelegate.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7f6005dd8..57bfba828 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -685,6 +685,18 @@ class AppDelegate: NSObject, } private func localEventKeyDown(_ event: NSEvent) -> NSEvent? { + // If the tab overview is visible and escape is pressed, close it. + // This can't POSSIBLY be right and is probably a FirstResponder problem + // that we should handle elsewhere in our program. But this works and it + // is guarded by the tab overview currently showing. + if event.keyCode == 0x35, // Escape key + let window = NSApp.keyWindow, + let tabGroup = window.tabGroup, + tabGroup.isOverviewVisible { + window.toggleTabOverview(nil) + return nil + } + // If we have a main window then we don't process any of the keys // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } From 07b47b87fa26390c697e4e78292d4cf991031ae2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 07:27:57 -0800 Subject: [PATCH 198/605] renderer/metal: clamp texture sizes to the maximum allowed by the device This prevents a crash in our renderer when it is larger. I will pair this with apprt changes so that our mac app won't ever allow a default window larger than the screen but we should be resilient at the renderer level as well. --- src/renderer/Metal.zig | 44 ++++++++++++++++++++++++++++++++++++-- src/renderer/metal/api.zig | 21 ++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 168f54c2b..f1d912152 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -55,6 +55,9 @@ blending: configpkg.Config.AlphaBlending, /// the "shared" storage mode, instead we have to use the "managed" mode. default_storage_mode: mtl.MTLResourceOptions.StorageMode, +/// The maximum 2D texture width and height supported by the device. +max_texture_size: u32, + /// We start an AutoreleasePool before `drawFrame` and end it afterwards. autorelease_pool: ?*objc.AutoreleasePool = null, @@ -72,8 +75,14 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); errdefer queue.release(); + // Grab metadata about the device. const default_storage_mode: mtl.MTLResourceOptions.StorageMode = if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed; + const max_texture_size = queryMaxTextureSize(device); + log.debug( + "device properties default_storage_mode={} max_texture_size={}", + .{ default_storage_mode, max_texture_size }, + ); const ViewInfo = struct { view: objc.Object, @@ -138,6 +147,7 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { .queue = queue, .blending = opts.config.blending, .default_storage_mode = default_storage_mode, + .max_texture_size = max_texture_size, }; } @@ -202,9 +212,19 @@ pub fn initShaders( pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } { const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds"); const scale = self.layer.layer.getProperty(f64, "contentsScale"); + + // We need to clamp our runtime surface size to the maximum + // possible texture size since we can't create a screen buffer (texture) + // larger than that. return .{ - .width = @intFromFloat(bounds.size.width * scale), - .height = @intFromFloat(bounds.size.height * scale), + .width = @min( + @as(u32, @intFromFloat(bounds.size.width * scale)), + self.max_texture_size, + ), + .height = @min( + @as(u32, @intFromFloat(bounds.size.height * scale)), + self.max_texture_size, + ), }; } @@ -412,3 +432,23 @@ fn chooseDevice() error{NoMetalDevice}!objc.Object { const device = chosen_device orelse return error.NoMetalDevice; return device.retain(); } + +/// Determines the maximum 2D texture size supported by the device. +/// We need to clamp our frame size to this if its larger. +fn queryMaxTextureSize(device: objc.Object) u32 { + // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf + + if (device.msgSend( + bool, + objc.sel("supportsFamily:"), + .{mtl.MTLGPUFamily.apple10}, + )) return 32768; + + if (device.msgSend( + bool, + objc.sel("supportsFamily:"), + .{mtl.MTLGPUFamily.apple3}, + )) return 16384; + + return 8192; +} diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index e1daa6848..a2d8a1356 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -391,6 +391,27 @@ pub const MTLRenderStage = enum(c_ulong) { mesh = 16, }; +/// https://developer.apple.com/documentation/metal/mtlgpufamily?language=objc +pub const MTLGPUFamily = enum(c_long) { + apple1 = 1001, + apple2 = 1002, + apple3 = 1003, + apple4 = 1004, + apple5 = 1005, + apple6 = 1006, + apple7 = 1007, + apple8 = 1008, + apple9 = 1009, + apple10 = 1010, + + common1 = 3001, + common2 = 3002, + common3 = 3003, + + metal3 = 5001, + metal4 = 5002, +}; + pub const MTLClearColor = extern struct { red: f64, green: f64, From 594195963d650d09ed6cb3245671f282aad4b8a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 09:31:10 -0800 Subject: [PATCH 199/605] Update src/renderer/Metal.zig Co-authored-by: Jon Parise --- src/renderer/Metal.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f1d912152..2aac285c6 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -434,7 +434,7 @@ fn chooseDevice() error{NoMetalDevice}!objc.Object { } /// Determines the maximum 2D texture size supported by the device. -/// We need to clamp our frame size to this if its larger. +/// We need to clamp our frame size to this if it's larger. fn queryMaxTextureSize(device: objc.Object) u32 { // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf From d1bea9d737eef15cfb0a2a6a3f8e33f132158a48 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 10:28:51 -0800 Subject: [PATCH 200/605] macos: window width/height should be clamped, work with position Fixes #9952 Fixes #9969 This fixes our `constrainToScreen` implementation to properly clamp the window size to the visible screen its coming on as documented. Further, this addresses the positioning problem, too. --- .../Helpers/Extensions/NSWindow+Extension.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index d834f5e63..f8df803db 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -16,19 +16,23 @@ extension NSWindow { return firstWindow === self } - /// Adjusts the window origin if necessary to ensure the window remains visible on screen. + /// Adjusts the window frame if necessary to ensure the window remains visible on screen. + /// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen). func constrainToScreen() { guard let screen = screen ?? NSScreen.main else { return } let visibleFrame = screen.visibleFrame var windowFrame = frame + windowFrame.size.width = min(windowFrame.size.width, visibleFrame.size.width) + windowFrame.size.height = min(windowFrame.size.height, visibleFrame.size.height) + windowFrame.origin.x = max(visibleFrame.minX, min(windowFrame.origin.x, visibleFrame.maxX - windowFrame.width)) windowFrame.origin.y = max(visibleFrame.minY, min(windowFrame.origin.y, visibleFrame.maxY - windowFrame.height)) - if windowFrame.origin != frame.origin { - setFrameOrigin(windowFrame.origin) + if windowFrame != frame { + setFrame(windowFrame, display: true) } } } From 63422f4d4e10a9c78fe7162bf784689cf226bada Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 11:56:04 -0800 Subject: [PATCH 201/605] add the catch_all binding key Part of #9963 This adds a new special key `catch_all` that can be used in keybinding definitions to match any key that is not explicitly bound. For example: `keybind = catch_all=new_window` (chaos!). `catch_all` can be used in combination with modifiers, so if you want to catch any non-bound key with Ctrl held down, you can do: `keybind = ctrl+catch_all=new_window`. `catch_all` can also be used with trigger sequences, so you can do: `keybind = ctrl+a>catch_all=new_window` to catch any key pressed after `ctrl+a` that is not explicitly bound and make a new window! And if you want to remove the catch all binding, it is like any other: `keybind = catch_all=unbind`. --- include/ghostty.h | 2 + macos/Sources/Ghostty/Ghostty.Input.swift | 4 + src/apprt/gtk/key.zig | 2 + src/cli/list_keybinds.zig | 5 + src/config/Config.zig | 7 ++ src/input/Binding.zig | 129 +++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 47db34e71..736c7546b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -317,12 +317,14 @@ typedef struct { typedef enum { GHOSTTY_TRIGGER_PHYSICAL, GHOSTTY_TRIGGER_UNICODE, + GHOSTTY_TRIGGER_CATCH_ALL, } ghostty_input_trigger_tag_e; typedef union { ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; + // catch_all has no payload } ghostty_input_trigger_key_u; typedef struct { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index e05911c06..6b4eb0ae4 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -32,6 +32,10 @@ extension Ghostty { guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } key = KeyEquivalent(Character(scalar)) + case GHOSTTY_TRIGGER_CATCH_ALL: + // catch_all matches any key, so it can't be represented as a KeyboardShortcut + return nil + default: return nil } diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 19bdc8315..35c9390b2 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -74,6 +74,8 @@ fn writeTriggerKey( try writer.print("{u}", .{cp}); } }, + + .catch_all => return false, } return true; diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index a8899a4f5..e463f55b9 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -166,16 +166,19 @@ const ChordBinding = struct { var r_trigger = rhs.triggers.first; while (l_trigger != null and r_trigger != null) { + // We want catch_all to sort last. const lhs_key: c_int = blk: { switch (TriggerNode.get(l_trigger.?).data.key) { .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), + .catch_all => break :blk std.math.maxInt(c_int), } }; const rhs_key: c_int = blk: { switch (TriggerNode.get(r_trigger.?).data.key) { .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), + .catch_all => break :blk std.math.maxInt(c_int), } }; @@ -268,6 +271,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const key = switch (trigger.data.key) { .physical => |k| try std.fmt.allocPrint(alloc, "{t}", .{k}), .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}), + .catch_all => "catch_all", }; result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col }); @@ -314,6 +318,7 @@ fn iterateBindings( switch (t.key) { .physical => |k| try buf.writer.print("{t}", .{k}), .unicode => |c| try buf.writer.print("{u}", .{c}), + .catch_all => try buf.writer.print("catch_all", .{}), } break :blk win.gwidth(buf.written()); diff --git a/src/config/Config.zig b/src/config/Config.zig index 1aad62d7d..f2f6f2322 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1477,6 +1477,13 @@ class: ?[:0]const u8 = null, /// so if you specify both `a` and `KeyA`, the physical key will always be used /// regardless of what order they are configured. /// +/// The special key `catch_all` can be used to match any key that is not +/// otherwise bound. This can be combined with modifiers, for example +/// `ctrl+catch_all` will match any key pressed with `ctrl` that is not +/// otherwise bound. When looking up a binding, Ghostty first tries to match +/// `catch_all` with modifiers. If no match is found and the event has +/// modifiers, it falls back to `catch_all` without modifiers. +/// /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, /// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier /// or the alias. When debugging keybinds, the non-aliased modifier will always diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9f3ad8a2a..666852094 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1505,6 +1505,10 @@ pub const Trigger = struct { /// codepoint. This is useful for binding to keys that don't have a /// registered keycode with Ghostty. unicode: u21, + + /// A catch-all key that matches any key press that is otherwise + /// unbound. + catch_all, }; /// The extern struct used for triggers in the C API. @@ -1516,6 +1520,7 @@ pub const Trigger = struct { pub const Tag = enum(c_int) { physical, unicode, + catch_all, }; pub const Key = extern union { @@ -1611,6 +1616,13 @@ pub const Trigger = struct { continue :loop; } + // Check for catch_all. We do this near the end since its unlikely + // in most cases that we're setting a catch-all key. + if (std.mem.eql(u8, part, "catch_all")) { + result.key = .catch_all; + 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. @@ -1751,7 +1763,7 @@ pub const Trigger = struct { pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { .physical => |v| v == .unidentified, - else => false, + .unicode, .catch_all => false, }; } @@ -1771,6 +1783,7 @@ pub const Trigger = struct { hasher, foldedCodepoint(cp), ), + .catch_all => {}, } std.hash.autoHash(hasher, self.mods.binding()); } @@ -1801,6 +1814,9 @@ pub const Trigger = struct { .key = switch (self.key) { .physical => |v| .{ .physical = v }, .unicode => |v| .{ .unicode = @intCast(v) }, + // catch_all has no associated value so its an error + // for a C consumer to look at it. + .catch_all => undefined, }, .mods = self.mods, }; @@ -1821,6 +1837,7 @@ pub const Trigger = struct { switch (self.key) { .physical => |k| try writer.print("{t}", .{k}), .unicode => |c| try writer.print("{u}", .{c}), + .catch_all => try writer.writeAll("catch_all"), } } }; @@ -2213,6 +2230,14 @@ pub const Set = struct { if (self.get(trigger)) |v| return v; } + // Fallback to catch_all with modifiers first, then without modifiers. + trigger.key = .catch_all; + if (self.get(trigger)) |v| return v; + if (!trigger.mods.empty()) { + trigger.mods = .{}; + if (self.get(trigger)) |v| return v; + } + return null; } @@ -2433,6 +2458,31 @@ test "parse: w3c key names" { try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); } +test "parse: catch_all" { + const testing = std.testing; + + // Basic catch_all + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .catch_all }, + .action = .{ .ignore = {} }, + }, + try parseSingle("catch_all=ignore"), + ); + + // catch_all with modifiers + try testing.expectEqual( + Binding{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .catch_all, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl+catch_all=ignore"), + ); +} + test "parse: plus sign" { const testing = std.testing; @@ -3329,6 +3379,83 @@ test "set: getEvent codepoint case folding" { } } +test "set: getEvent catch_all fallback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Matches unbound key without modifiers + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Matches unbound key with modifiers (falls back to catch_all without mods) + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Specific binding takes precedence over catch_all + try s.parseAndPut(alloc, "ctrl+b=new_window"); + { + const action = s.getEvent(.{ + .key = .key_b, + .mods = .{ .ctrl = true }, + .unshifted_codepoint = 'b', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } +} + +test "set: getEvent catch_all with modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+catch_all=close_surface"); + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Key with ctrl matches catch_all with ctrl modifier + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .close_surface); + } + + // Key without mods matches catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Key with different mods falls back to catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .alt = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } +} + test "Action: clone" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); From c53b3fffd5af411be05467ebc88ee9222e67b031 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 13:26:20 -0800 Subject: [PATCH 202/605] config: keybind table parsing --- src/config/Config.zig | 313 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 309 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f2f6f2322..8d941c733 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5812,6 +5812,10 @@ pub const RepeatableFontVariation = struct { pub const Keybinds = struct { set: inputpkg.Binding.Set = .{}, + /// Defined key tables. The default key table is always the root "set", + /// which allows all table names to be available without reservation. + tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty, + pub fn init(self: *Keybinds, alloc: Allocator) !void { // We don't clear the memory because it's in the arena and unlikely // to be free-able anyways (since arenas can only clear the last @@ -6590,18 +6594,69 @@ pub const Keybinds = struct { return; } - // Let our much better tested binding package handle parsing and storage. + // Check for table syntax: "name/" or "name/binding" + // We look for '/' only before the first '=' to avoid matching + // action arguments like "foo=text:/hello". + const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len; + if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| { + const table_name = value[0..slash_idx]; + const binding = value[slash_idx + 1 ..]; + + // Table name cannot be empty + if (table_name.len == 0) return error.InvalidFormat; + + // Get or create the table + const gop = try self.tables.getOrPut(alloc, table_name); + if (!gop.found_existing) { + gop.value_ptr.* = .{}; + } + + // If there's no binding after the slash, this is a table + // definition/clear command + if (binding.len == 0) { + log.debug("config has 'keybind = {s}/', table cleared", .{table_name}); + gop.value_ptr.* = .{}; + return; + } + + // Parse and add the binding to the table + try gop.value_ptr.parseAndPut(alloc, binding); + return; + } + + // Parse into default set try self.set.parseAndPut(alloc, value); } /// Deep copy of the struct. Required by Config. pub fn clone(self: *const Keybinds, alloc: Allocator) Allocator.Error!Keybinds { - return .{ .set = try self.set.clone(alloc) }; + var tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty; + try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count())); + var it = self.tables.iterator(); + while (it.next()) |entry| { + tables.putAssumeCapacity(entry.key_ptr.*, try entry.value_ptr.clone(alloc)); + } + + return .{ + .set = try self.set.clone(alloc), + .tables = tables, + }; } /// Compare if two of our value are requal. Required by Config. pub fn equal(self: Keybinds, other: Keybinds) bool { - return equalSet(&self.set, &other.set); + if (!equalSet(&self.set, &other.set)) return false; + + // Compare tables + if (self.tables.count() != other.tables.count()) return false; + + var it = self.tables.iterator(); + while (it.next()) |entry| { + const other_set = other.tables.get(entry.key_ptr.*) orelse return false; + if (!equalSet(entry.value_ptr, &other_set)) return false; + } + + return true; } fn equalSet( @@ -6652,12 +6707,14 @@ pub const Keybinds = struct { /// Like formatEntry but has an option to include docs. pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void { - if (self.set.bindings.size == 0) { + if (self.set.bindings.size == 0 and self.tables.count() == 0) { try formatter.formatEntry(void, {}); return; } var buf: [1024]u8 = undefined; + + // Format root set bindings var iter = self.set.bindings.iterator(); while (iter.next()) |next| { const k = next.key_ptr.*; @@ -6684,6 +6741,23 @@ pub const Keybinds = struct { writer.print("{f}", .{k}) catch return error.OutOfMemory; try v.formatEntries(&writer, formatter); } + + // Format table bindings + var table_iter = self.tables.iterator(); + while (table_iter.next()) |table_entry| { + const table_name = table_entry.key_ptr.*; + const table_set = table_entry.value_ptr.*; + + var binding_iter = table_set.bindings.iterator(); + while (binding_iter.next()) |next| { + const k = next.key_ptr.*; + const v = next.value_ptr.*; + + var writer: std.Io.Writer = .fixed(&buf); + writer.print("{s}/{f}", .{ table_name, k }) catch return error.OutOfMemory; + try v.formatEntries(&writer, formatter); + } + } } /// Used by Formatter @@ -6768,6 +6842,237 @@ pub const Keybinds = struct { ; try std.testing.expectEqualStrings(want, buf.written()); } + + test "parseCLI table definition" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Define a table by adding a binding to it + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("foo")); + + const table = keybinds.tables.get("foo").?; + try testing.expectEqual(1, table.bindings.count()); + } + + test "parseCLI table clear" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add a binding to a table + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count()); + + // Clear the table with "foo/" + try keybinds.parseCLI(alloc, "foo/"); + try testing.expectEqual(0, keybinds.tables.get("foo").?.bindings.count()); + } + + test "parseCLI table multiple bindings" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + try testing.expectEqual(2, keybinds.tables.count()); + try testing.expectEqual(2, keybinds.tables.get("foo").?.bindings.count()); + try testing.expectEqual(1, keybinds.tables.get("bar").?.bindings.count()); + } + + test "parseCLI table does not affect root set" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + + // Root set should have the first binding + try testing.expectEqual(1, keybinds.set.bindings.count()); + // Table should have the second binding + try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count()); + } + + test "parseCLI table empty name is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try testing.expectError(error.InvalidFormat, keybinds.parseCLI(alloc, "/shift+a=copy_to_clipboard")); + } + + test "parseCLI table with key sequence" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Key sequences should work within tables + try keybinds.parseCLI(alloc, "foo/ctrl+a>ctrl+b=new_window"); + + const table = keybinds.tables.get("foo").?; + try testing.expectEqual(1, table.bindings.count()); + } + + test "parseCLI slash in action argument is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // A slash after the = should not be interpreted as a table delimiter + try keybinds.parseCLI(alloc, "ctrl+a=text:/hello"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "clone with tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + const cloned = try keybinds.clone(alloc); + + // Verify the clone has the same structure + try testing.expectEqual(keybinds.set.bindings.count(), cloned.set.bindings.count()); + try testing.expectEqual(keybinds.tables.count(), cloned.tables.count()); + try testing.expectEqual( + keybinds.tables.get("foo").?.bindings.count(), + cloned.tables.get("foo").?.bindings.count(), + ); + try testing.expectEqual( + keybinds.tables.get("bar").?.bindings.count(), + cloned.tables.get("bar").?.bindings.count(), + ); + } + + test "equal with tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + try testing.expect(keybinds1.equal(keybinds2)); + } + + test "equal with tables different table count" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds2.parseCLI(alloc, "bar/shift+b=paste_from_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "equal with tables different table names" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "bar/shift+a=copy_to_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "equal with tables different bindings" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "formatEntry with tables" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello"); + try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + try testing.expectEqualStrings("keybind = foo/shift+a=csi:hello\n", buf.written()); + } + + test "formatEntry with tables and root set" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "shift+b=csi:world"); + try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello"); + try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + const output = buf.written(); + try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null); + try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null); + } }; /// See "font-codepoint-map" for documentation. From 8c59143c1a9d5418ef9f97c7dfd57d8caa8d697f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 13:46:00 -0800 Subject: [PATCH 203/605] rename some key sequence state so it is clearer what it is --- src/Surface.zig | 56 ++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fc5a239ab..711c79833 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -253,18 +253,9 @@ const Mouse = struct { /// Keyboard state for the surface. pub const Keyboard = struct { - /// The currently active keybindings for the surface. This is used to - /// implement sequences: as leader keys are pressed, the active bindings - /// set is updated to reflect the current leader key sequence. If this is - /// null then the root bindings are used. - bindings: ?*const input.Binding.Set = null, - - /// The last handled binding. This is used to prevent encoding release - /// events for handled bindings. We only need to keep track of one because - /// at least at the time of writing this, its impossible for two keys of - /// a combination to be handled by different bindings before the release - /// of the prior (namely since you can't bind modifier-only). - last_trigger: ?u64 = null, + /// The currently active key sequence for the surface. If this is null + /// then we're not currently in a key sequence. + sequence_set: ?*const input.Binding.Set = null, /// The queued keys when we're in the middle of a sequenced binding. /// These are flushed when the sequence is completed and unconsumed or @@ -272,7 +263,14 @@ pub const Keyboard = struct { /// /// This is naturally bounded due to the configuration maximum /// length of a sequence. - queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .{}, + sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty, + + /// The last handled binding. This is used to prevent encoding release + /// events for handled bindings. We only need to keep track of one because + /// at least at the time of writing this, its impossible for two keys of + /// a combination to be handled by different bindings before the release + /// of the prior (namely since you can't bind modifier-only). + last_trigger: ?u64 = null, }; /// The configuration that a surface has, this is copied from the main @@ -793,8 +791,8 @@ pub fn deinit(self: *Surface) void { } // Clean up our keyboard state - for (self.keyboard.queued.items) |req| req.deinit(); - self.keyboard.queued.deinit(self.alloc); + for (self.keyboard.sequence_queued.items) |req| req.deinit(); + self.keyboard.sequence_queued.deinit(self.alloc); // Clean up our font grid self.app.font_grid_set.deref(self.font_grid_key); @@ -2565,7 +2563,7 @@ pub fn keyEventIsBinding( // Our keybinding set is either our current nested set (for // sequences) or the root set. - const set = self.keyboard.bindings orelse &self.config.keybind.set; + const set = self.keyboard.sequence_set orelse &self.config.keybind.set; // log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null }); @@ -2791,7 +2789,7 @@ fn maybeHandleBinding( // Find an entry in the keybind set that matches our event. const entry: input.Binding.Set.Entry = entry: { - const set = self.keyboard.bindings orelse &self.config.keybind.set; + const set = self.keyboard.sequence_set orelse &self.config.keybind.set; // Get our entry from the set for the given event. if (set.getEvent(event)) |v| break :entry v; @@ -2802,7 +2800,7 @@ fn maybeHandleBinding( // // We also ignore modifiers so that nested sequences such as // ctrl+a>ctrl+b>c work. - if (self.keyboard.bindings != null and + if (self.keyboard.sequence_set != null and !event.key.modifier()) { // Encode everything up to this point @@ -2816,13 +2814,13 @@ fn maybeHandleBinding( const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) { .leader => |set| { // Setup the next set we'll look at. - self.keyboard.bindings = set; + self.keyboard.sequence_set = set; // Store this event so that we can drain and encode on invalid. // We don't need to cap this because it is naturally capped by // the config validation. if (try self.encodeKey(event, insp_ev)) |req| { - try self.keyboard.queued.append(self.alloc, req); + try self.keyboard.sequence_queued.append(self.alloc, req); } // Start or continue our key sequence @@ -2861,8 +2859,8 @@ fn maybeHandleBinding( // perform an action (below) self.keyboard.last_trigger = null; - // An action also always resets the binding set. - self.keyboard.bindings = null; + // An action also always resets the sequence set. + self.keyboard.sequence_set = null; // Attempt to perform the action log.debug("key event binding flags={} action={f}", .{ @@ -2952,13 +2950,13 @@ fn endKeySequence( ); }; - // No matter what we clear our current binding set. This restores + // No matter what we clear our current sequence set. This restores // the set we look at to the root set. - self.keyboard.bindings = null; + self.keyboard.sequence_set = null; - if (self.keyboard.queued.items.len > 0) { + if (self.keyboard.sequence_queued.items.len > 0) { switch (action) { - .flush => for (self.keyboard.queued.items) |write_req| { + .flush => for (self.keyboard.sequence_queued.items) |write_req| { self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, @@ -2966,12 +2964,12 @@ fn endKeySequence( }, .unlocked); }, - .drop => for (self.keyboard.queued.items) |req| req.deinit(), + .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), } switch (mem) { - .free => self.keyboard.queued.clearAndFree(self.alloc), - .retain => self.keyboard.queued.clearRetainingCapacity(), + .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), + .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), } } } From 34ae3848b6ec356ef56299d26a74d2d4a566147b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 13:40:45 -0800 Subject: [PATCH 204/605] core: key tables --- src/Surface.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 711c79833..77daaf7e9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -265,6 +265,10 @@ pub const Keyboard = struct { /// length of a sequence. sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty, + /// The stack of tables that is currently active. The first value + /// in this is the first activated table (NOT the default keybinding set). + table_stack: std.ArrayListUnmanaged(*const input.Binding.Set) = .empty, + /// The last handled binding. This is used to prevent encoding release /// events for handled bindings. We only need to keep track of one because /// at least at the time of writing this, its impossible for two keys of @@ -793,6 +797,7 @@ pub fn deinit(self: *Surface) void { // Clean up our keyboard state for (self.keyboard.sequence_queued.items) |req| req.deinit(); self.keyboard.sequence_queued.deinit(self.alloc); + self.keyboard.table_stack.deinit(self.alloc); // Clean up our font grid self.app.font_grid_set.deref(self.font_grid_key); From 18ce219d78986d5dedadee88e47f6da7b7a59304 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:19:23 -0800 Subject: [PATCH 205/605] input: activate/deactivate key table binding actions --- src/Surface.zig | 43 +++++++++++++++++++++++++++++++++++++++++++ src/input/Binding.zig | 30 ++++++++++++++++++++++++++++++ src/input/command.zig | 4 ++++ 3 files changed, 77 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 77daaf7e9..b4a1048e5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5569,6 +5569,49 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + inline .activate_key_table, + .activate_key_table_once, + => |name, tag| { + // Look up the table in our config + const set = self.config.keybind.tables.getPtr(name) orelse + return false; + + // If this is the same table as is currently active, then + // do nothing. + if (self.keyboard.table_stack.items.len > 0) { + const items = self.keyboard.table_stack.items; + const active = items[items.len - 1]; + if (active == set) return false; + } + + // Add the table to the stack. + try self.keyboard.table_stack.append(self.alloc, set); + + // TODO: once + _ = tag; + }, + + .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Final key table active, clear our state. + 1 => self.keyboard.table_stack.clearAndFree(self.alloc), + + // Restore the prior key table. We don't free any memory in + // this case because we assume it will be freed later when + // we finish our key table. + else => _ = self.keyboard.table_stack.pop(), + }, + + .deactivate_all_key_tables => switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + }, + .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 666852094..983ed98b3 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -799,6 +799,32 @@ pub const Action = union(enum) { /// be undone or redone. redo, + /// Activate a named key table (see `keybind` configuration documentation). + /// The named key table will remain active until `deactivate_key_table` + /// is called. If you want a one-shot key table activation, use the + /// `activate_key_table_once` action instead. + /// + /// If the named key table does not exist, this action has no effect + /// and performable will report false. + /// + /// If the named key table is already the currently active key table, + /// this action has no effect and performable will report false. + activate_key_table: []const u8, + + /// Same as activate_key_table, but the key table will only be active + /// until the first valid keybinding from that table is used (including + /// any defined `catch_all` bindings). + activate_key_table_once: []const u8, + + /// Deactivate the currently active key table, if any. The next most + /// recently activated key table (if any) will become active again. + /// If no key table is active, this action has no effect. + deactivate_key_table, + + /// Deactivate all active key tables. If no active key table exists, + /// this will report performable as false. + deactivate_all_key_tables, + /// Quit Ghostty. quit, @@ -1253,6 +1279,10 @@ pub const Action = union(enum) { .toggle_background_opacity, .show_on_screen_keyboard, .reset_window_size, + .activate_key_table, + .activate_key_table_once, + .deactivate_key_table, + .deactivate_all_key_tables, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 6ac4312a9..67086f7ec 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -671,6 +671,10 @@ fn actionCommands(action: Action.Key) []const Command { .write_scrollback_file, .goto_tab, .resize_split, + .activate_key_table, + .activate_key_table_once, + .deactivate_key_table, + .deactivate_all_key_tables, .crash, => comptime &.{}, From 36f891afd8a63b64b6448277bf6cb59e44d005c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:30:33 -0800 Subject: [PATCH 206/605] implement key table lookup in maybeHandleBinding --- src/Surface.zig | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index b4a1048e5..47cafaf6f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2794,25 +2794,39 @@ fn maybeHandleBinding( // Find an entry in the keybind set that matches our event. const entry: input.Binding.Set.Entry = entry: { - const set = self.keyboard.sequence_set orelse &self.config.keybind.set; + // Handle key sequences first. + if (self.keyboard.sequence_set) |set| { + // Get our entry from the set for the given event. + if (set.getEvent(event)) |v| break :entry v; - // Get our entry from the set for the given event. - if (set.getEvent(event)) |v| break :entry v; + // No entry found. We need to encode everything up to this + // point and send to the pty since we're in a sequence. + // + // We also ignore modifiers so that nested sequences such as + // ctrl+a>ctrl+b>c work. + if (!event.key.modifier()) { + // Encode everything up to this point + self.endKeySequence(.flush, .retain); + } - // No entry found. If we're not looking at the root set of the - // bindings we need to encode everything up to this point and - // send to the pty. - // - // We also ignore modifiers so that nested sequences such as - // ctrl+a>ctrl+b>c work. - if (self.keyboard.sequence_set != null and - !event.key.modifier()) - { - // Encode everything up to this point - self.endKeySequence(.flush, .retain); + return null; } - return null; + // No currently active sequence, move on to tables. For tables, + // we search inner-most table to outer-most. The table stack does + // NOT include the root set. + const table_items = self.keyboard.table_stack.items; + if (table_items.len > 0) { + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + const set = table_items[rev_i]; + if (set.getEvent(event)) |v| break :entry v; + } + } + + // No table, use our default set + break :entry self.config.keybind.set.getEvent(event) orelse + return null; }; // Determine if this entry has an action or if its a leader key. From 14bbc4893f84bd02d0f2026d3cd091d1ba29dd07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:36:35 -0800 Subject: [PATCH 207/605] implement one-shot key tables --- src/Surface.zig | 30 ++++++++++++++++++++++-------- src/input/Binding.zig | 5 +++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 47cafaf6f..8c1c31a84 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -267,7 +267,10 @@ pub const Keyboard = struct { /// The stack of tables that is currently active. The first value /// in this is the first activated table (NOT the default keybinding set). - table_stack: std.ArrayListUnmanaged(*const input.Binding.Set) = .empty, + table_stack: std.ArrayListUnmanaged(struct { + set: *const input.Binding.Set, + once: bool, + }) = .empty, /// The last handled binding. This is used to prevent encoding release /// events for handled bindings. We only need to keep track of one because @@ -2819,8 +2822,19 @@ fn maybeHandleBinding( if (table_items.len > 0) { for (0..table_items.len) |i| { const rev_i: usize = table_items.len - 1 - i; - const set = table_items[rev_i]; - if (set.getEvent(event)) |v| break :entry v; + const table = table_items[rev_i]; + if (table.set.getEvent(event)) |v| { + // If this is a one-shot activation AND its the currently + // active table, then we deactivate it after this. + // Note: we may want to change the semantics here to + // remove this table no matter where it is in the stack, + // maybe. + if (table.once and i == 0) _ = try self.performBindingAction( + .deactivate_key_table, + ); + + break :entry v; + } } } @@ -5594,15 +5608,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // do nothing. if (self.keyboard.table_stack.items.len > 0) { const items = self.keyboard.table_stack.items; - const active = items[items.len - 1]; + const active = items[items.len - 1].set; if (active == set) return false; } // Add the table to the stack. - try self.keyboard.table_stack.append(self.alloc, set); - - // TODO: once - _ = tag; + try self.keyboard.table_stack.append(self.alloc, .{ + .set = set, + .once = tag == .activate_key_table_once, + }); }, .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 983ed98b3..22a5e8386 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -814,6 +814,11 @@ pub const Action = union(enum) { /// Same as activate_key_table, but the key table will only be active /// until the first valid keybinding from that table is used (including /// any defined `catch_all` bindings). + /// + /// The "once" check is only done if this is the currently active + /// key table. If another key table is activated later, then this + /// table will remain active until it pops back out to being the + /// active key table. activate_key_table_once: []const u8, /// Deactivate the currently active key table, if any. The next most From daa613482e6a7ceacb0e97ee24f96c0ecc3a871e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:57:28 -0800 Subject: [PATCH 208/605] keybind = clear and reset should reset tables, too --- src/Surface.zig | 20 ++++++++++++------ src/config/Config.zig | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8c1c31a84..2254b287c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2569,14 +2569,22 @@ pub fn keyEventIsBinding( .press, .repeat => {}, } - // Our keybinding set is either our current nested set (for - // sequences) or the root set. - const set = self.keyboard.sequence_set orelse &self.config.keybind.set; + // If we're in a sequence, check the sequence set + if (self.keyboard.sequence_set) |set| { + return set.getEvent(event) != null; + } - // log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null }); + // Check active key tables (inner-most to outer-most) + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + if (table_items[rev_i].set.getEvent(event) != null) { + return true; + } + } - // If we have a keybinding for this event then we return true. - return set.getEvent(event) != null; + // Check the root set + return self.config.keybind.set.getEvent(event) != null; } /// Called for any key events. This handles keybindings, encoding and diff --git a/src/config/Config.zig b/src/config/Config.zig index 8d941c733..17b4275b0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5822,6 +5822,7 @@ pub const Keybinds = struct { // allocated value). This isn't a memory leak because the arena // will be freed when the config is freed. self.set = .{}; + self.tables = .empty; // keybinds for opening and reloading config try self.set.put( @@ -6591,6 +6592,7 @@ pub const Keybinds = struct { // will be freed when the config is freed. log.info("config has 'keybind = clear', all keybinds cleared", .{}); self.set = .{}; + self.tables = .empty; return; } @@ -7073,6 +7075,52 @@ pub const Keybinds = struct { try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null); try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null); } + + test "parseCLI clear clears tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add bindings to root set and tables + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(2, keybinds.tables.count()); + + // Clear all keybinds + try keybinds.parseCLI(alloc, "clear"); + + // Both root set and tables should be cleared + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI reset clears tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add bindings to tables + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "bar/shift+b=paste_from_clipboard"); + + try testing.expectEqual(2, keybinds.tables.count()); + + // Reset to defaults (empty value) + try keybinds.parseCLI(alloc, ""); + + // Tables should be cleared, root set has defaults + try testing.expectEqual(0, keybinds.tables.count()); + try testing.expect(keybinds.set.bindings.count() > 0); + } }; /// See "font-codepoint-map" for documentation. From 845bcdb498da4d2e3552ffb9385ed8c9b7fc5218 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 15:11:08 -0800 Subject: [PATCH 209/605] config: copy key table name into arena --- src/Surface.zig | 11 +++++++++-- src/config/Config.zig | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 2254b287c..af7cdf136 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5609,15 +5609,20 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .activate_key_table_once, => |name, tag| { // Look up the table in our config - const set = self.config.keybind.tables.getPtr(name) orelse + const set = self.config.keybind.tables.getPtr(name) orelse { + log.debug("key table not found: {s}", .{name}); return false; + }; // If this is the same table as is currently active, then // do nothing. if (self.keyboard.table_stack.items.len > 0) { const items = self.keyboard.table_stack.items; const active = items[items.len - 1].set; - if (active == set) return false; + if (active == set) { + log.debug("ignoring duplicate activate table: {s}", .{name}); + return false; + } } // Add the table to the stack. @@ -5625,6 +5630,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .set = set, .once = tag == .activate_key_table_once, }); + + log.debug("key table activated: {s}", .{name}); }, .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { diff --git a/src/config/Config.zig b/src/config/Config.zig index 17b4275b0..c0d8e813e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6610,6 +6610,9 @@ pub const Keybinds = struct { // Get or create the table const gop = try self.tables.getOrPut(alloc, table_name); if (!gop.found_existing) { + // We need to copy our table name into the arena + // for valid lookups later. + gop.key_ptr.* = try alloc.dupe(u8, table_name); gop.value_ptr.* = .{}; } @@ -6636,7 +6639,8 @@ pub const Keybinds = struct { try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count())); var it = self.tables.iterator(); while (it.next()) |entry| { - tables.putAssumeCapacity(entry.key_ptr.*, try entry.value_ptr.clone(alloc)); + const key = try alloc.dupe(u8, entry.key_ptr.*); + tables.putAssumeCapacity(key, try entry.value_ptr.clone(alloc)); } return .{ From 1fbdcf1ee76406abec58b19ce8598a4000b06cb2 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 21 Dec 2025 00:15:47 +0000 Subject: [PATCH 210/605] deps: Update iTerm2 color schemes --- build.zig.zon | 2 +- build.zig.zon.json | 2 +- build.zig.zon.nix | 2 +- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 271428778..373c97aba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index c9a64ca5f..9ad3c39da 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 43a8efe46..9627286dd 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 24a2978d6..484e1949b 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -5,6 +5,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz +https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz @@ -32,4 +33,3 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 21f79ec04..3b17b64a6 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, From 44972198aeeaa21e7184de4e4425114c0fdbb870 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 19:47:51 -0800 Subject: [PATCH 211/605] apprt: add action for key table activation/deactivation --- include/ghostty.h | 23 ++++++++++ src/Surface.zig | 68 +++++++++++++++++++++++------ src/apprt/action.zig | 48 ++++++++++++++++++++ src/apprt/gtk/class/application.zig | 1 + 4 files changed, 126 insertions(+), 14 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 736c7546b..48915b179 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -691,6 +691,27 @@ typedef struct { ghostty_input_trigger_s trigger; } ghostty_action_key_sequence_s; +// apprt.action.KeyTable.Tag +typedef enum { + GHOSTTY_KEY_TABLE_ACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, +} ghostty_action_key_table_tag_e; + +// apprt.action.KeyTable.CValue +typedef union { + struct { + const char *name; + size_t len; + } activate; +} ghostty_action_key_table_u; + +// apprt.action.KeyTable.C +typedef struct { + ghostty_action_key_table_tag_e tag; + ghostty_action_key_table_u value; +} ghostty_action_key_table_s; + // apprt.action.ColorKind typedef enum { GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, @@ -836,6 +857,7 @@ typedef enum { GHOSTTY_ACTION_FLOAT_WINDOW, GHOSTTY_ACTION_SECURE_INPUT, GHOSTTY_ACTION_KEY_SEQUENCE, + GHOSTTY_ACTION_KEY_TABLE, GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, @@ -881,6 +903,7 @@ typedef union { ghostty_action_float_window_e float_window; ghostty_action_secure_input_e secure_input; ghostty_action_key_sequence_s key_sequence; + ghostty_action_key_table_s key_table; ghostty_action_color_change_s color_change; ghostty_action_reload_config_s reload_config; ghostty_action_config_change_s config_change; diff --git a/src/Surface.zig b/src/Surface.zig index af7cdf136..3a83c704a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5631,28 +5631,68 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .once = tag == .activate_key_table_once, }); + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .{ .activate = name }, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + log.debug("key table activated: {s}", .{name}); }, - .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { - // No key table active. This does nothing. - 0 => return false, + .deactivate_key_table => { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, - // Final key table active, clear our state. - 1 => self.keyboard.table_stack.clearAndFree(self.alloc), + // Final key table active, clear our state. + 1 => self.keyboard.table_stack.clearAndFree(self.alloc), - // Restore the prior key table. We don't free any memory in - // this case because we assume it will be freed later when - // we finish our key table. - else => _ = self.keyboard.table_stack.pop(), + // Restore the prior key table. We don't free any memory in + // this case because we assume it will be freed later when + // we finish our key table. + else => _ = self.keyboard.table_stack.pop(), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; }, - .deactivate_all_key_tables => switch (self.keyboard.table_stack.items.len) { - // No key table active. This does nothing. - 0 => return false, + .deactivate_all_key_tables => { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, - // Clear the entire table stack. - else => self.keyboard.table_stack.clearAndFree(self.alloc), + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate_all, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; }, .crash => |location| switch (location) { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 8e0a9d018..25fc6f08a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -250,6 +250,9 @@ pub const Action = union(Key) { /// key mode because other input may be ignored. key_sequence: KeySequence, + /// A key table has been activated or deactivated. + key_table: KeyTable, + /// A terminal color was changed programmatically through things /// such as OSC 10/11. color_change: ColorChange, @@ -371,6 +374,7 @@ pub const Action = union(Key) { float_window, secure_input, key_sequence, + key_table, color_change, reload_config, config_change, @@ -711,6 +715,50 @@ pub const KeySequence = union(enum) { } }; +pub const KeyTable = union(enum) { + activate: []const u8, + deactivate, + deactivate_all, + + // Sync with: ghostty_action_key_table_tag_e + pub const Tag = enum(c_int) { + activate, + deactivate, + deactivate_all, + }; + + // Sync with: ghostty_action_key_table_u + pub const CValue = extern union { + activate: extern struct { + name: [*]const u8, + len: usize, + }, + }; + + // Sync with: ghostty_action_key_table_s + pub const C = extern struct { + tag: Tag, + value: CValue, + }; + + pub fn cval(self: KeyTable) C { + return switch (self) { + .activate => |name| .{ + .tag = .activate, + .value = .{ .activate = .{ .name = name.ptr, .len = name.len } }, + }, + .deactivate => .{ + .tag = .deactivate, + .value = undefined, + }, + .deactivate_all => .{ + .tag = .deactivate_all, + .value = undefined, + }, + }; + } +}; + pub const ColorChange = extern struct { kind: ColorKind, r: u8, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index be0f3f2c8..1c0863f3c 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -744,6 +744,7 @@ pub const Application = extern struct { .toggle_background_opacity, .cell_size, .key_sequence, + .key_table, .render_inspector, .renderer_health, .color_change, From 901618cd8f944be82b47fad7efb577507d9802a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 19:58:24 -0800 Subject: [PATCH 212/605] macOS: hook up key table apprt action to state --- macos/Sources/Ghostty/Ghostty.Action.swift | 25 +++++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 32 +++++++++++++++++-- macos/Sources/Ghostty/Package.swift | 4 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 ++++++++++++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 5 ++- 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9eb7a8e46..bde3b3d69 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -141,6 +141,31 @@ extension Ghostty.Action { } } } + + enum KeyTable { + case activate(name: String) + case deactivate + case deactivateAll + + init?(c: ghostty_action_key_table_s) { + switch c.tag { + case GHOSTTY_KEY_TABLE_ACTIVATE: + let name = String( + bytesNoCopy: UnsafeMutableRawPointer(mutating: c.value.activate.name), + length: c.value.activate.len, + encoding: .utf8, + freeWhenDone: false + ) ?? "" + self = .activate(name: name) + case GHOSTTY_KEY_TABLE_DEACTIVATE: + self = .deactivate + case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL: + self = .deactivateAll + default: + return nil + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 3348ab714..4e9166168 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -578,7 +578,10 @@ extension Ghostty { case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) - + + case GHOSTTY_ACTION_KEY_TABLE: + keyTable(app, target: target, v: action.action.key_table) + case GHOSTTY_ACTION_PROGRESS_REPORT: progressReport(app, target: target, v: action.action.progress_report) @@ -1771,7 +1774,32 @@ extension Ghostty { assertionFailure() } } - + + private static func keyTable( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_key_table_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("key table does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let action = Ghostty.Action.KeyTable(c: v) else { return } + + NotificationCenter.default.post( + name: Notification.didChangeKeyTable, + object: surfaceView, + userInfo: [Notification.KeyTableKey: action] + ) + + default: + assertionFailure() + } + } + private static func progressReport( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 375e5c37b..aa62c16f7 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -475,6 +475,10 @@ extension Ghostty.Notification { static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence") static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence") static let KeySequenceKey = didContinueKeySequence.rawValue + ".key" + + /// Notifications related to key tables + static let didChangeKeyTable = Notification.Name("com.mitchellh.ghostty.didChangeKeyTable") + static let KeyTableKey = didChangeKeyTable.rawValue + ".action" } // Make the input enum hashable. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 88a0bb6e8..455249ff4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -65,6 +65,9 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The currently active key tables. Empty if no tables are active. + @Published var keyTables: [String] = [] + // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil { didSet { @@ -324,6 +327,11 @@ extension Ghostty { selector: #selector(ghosttyDidEndKeySequence), name: Ghostty.Notification.didEndKeySequence, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeKeyTable), + name: Ghostty.Notification.didChangeKeyTable, + object: self) center.addObserver( self, selector: #selector(ghosttyConfigDidChange(_:)), @@ -680,6 +688,22 @@ extension Ghostty { } } + @objc private func ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) { + guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + switch action { + case .activate(let name): + self.keyTables.append(name) + case .deactivate: + _ = self.keyTables.popLast() + case .deactivateAll: + self.keyTables.removeAll() + } + } + } + @objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) { // Get our managed configuration object out guard let config = notification.userInfo?[ diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index b2e429455..eb8a60fd9 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,7 +43,10 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil - + + // The currently active key tables. Empty if no tables are active. + @Published var keyTables: [String] = [] + /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false From eac0ec14fdc89e7bb63831a427670a5ade094380 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 20:11:39 -0800 Subject: [PATCH 213/605] macOS: revamped key table/sequence UI --- macos/Sources/Ghostty/SurfaceView.swift | 257 +++++++++++++++++++++--- 1 file changed, 233 insertions(+), 24 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 49c6a4982..fce50073c 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -123,30 +123,12 @@ extension Ghostty { } } - // If we are in the middle of a key sequence, then we show a visual element. We only - // support this on macOS currently although in theory we can support mobile with keyboards! - if !surfaceView.keySequence.isEmpty { - let padding: CGFloat = 5 - VStack { - Spacer() - - HStack { - Text(verbatim: "Pending Key Sequence:") - ForEach(0.. dragThreshold { + position = .bottom + } + dragOffset = .zero + } + } + ) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count) + } + + private struct CapsuleSizeKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } + } + + private var indicatorContent: some View { + HStack(alignment: .center, spacing: 8) { + // Key table indicator + if !keyTables.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Image(systemName: "keyboard.badge.ellipsis") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + // Show table stack with arrows between them + ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + Text(verbatim: table) + .font(.system(size: 13, weight: .medium, design: .rounded)) + } + } + } + + // Separator when both are active + if !keyTables.isEmpty && !keySequence.isEmpty { + Divider() + .frame(height: 14) + } + + // Key sequence indicator + if !keySequence.isEmpty { + HStack(alignment: .center, spacing: 4) { + ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in + KeyCap(key.description) + } + + // Animated ellipsis to indicate waiting for next key + PendingIndicator() + } + } + } + .frame(height: 18) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + Capsule() + .fill(.regularMaterial) + .overlay { + Capsule() + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) + } + .shadow(color: .black.opacity(0.2), radius: 8, y: 2) + } + .contentShape(Capsule()) + .backport.pointerStyle(.link) + .popover(isPresented: $isShowingPopover, arrowEdge: position.popoverEdge) { + VStack(alignment: .leading, spacing: 8) { + if !keyTables.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Label("Key Table", systemImage: "keyboard.badge.ellipsis") + .font(.headline) + Text("A key table is a named set of keybindings, activated by some other key. Keys are interpreted using this table until it is deactivated.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if !keyTables.isEmpty && !keySequence.isEmpty { + Divider() + } + + if !keySequence.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Label("Key Sequence", systemImage: "character.cursor.ibeam") + .font(.headline) + Text("A key sequence is a series of key presses that trigger an action. A pending key sequence is currently active.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .padding() + .frame(maxWidth: 400) + .fixedSize(horizontal: false, vertical: true) + } + .onTapGesture { + isShowingPopover.toggle() + } + } + + /// A small keycap-style view for displaying keyboard shortcuts + struct KeyCap: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(verbatim: text) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.12), radius: 0.5, y: 0.5) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5) + ) + } + } + + /// Animated dots to indicate waiting for the next key + struct PendingIndicator: View { + @State private var animationPhase: Int = 0 + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.secondary) + .frame(width: 4, height: 4) + .opacity(dotOpacity(for: index)) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: false)) { + animationPhase = 3 + } + } + } + + private func dotOpacity(for index: Int) -> Double { + let phase = Double(animationPhase) + let offset = Double(index) / 3.0 + let wave = sin((phase + offset) * .pi * 2) + return 0.3 + 0.7 * ((wave + 1) / 2) + } + } + } +#endif + /// Visual overlay that shows a border around the edges when the bell rings with border feature enabled. struct BellBorderOverlay: View { let bell: Bool From dc8f08239235d85be875e03fc67148e8eaab758e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 20:36:35 -0800 Subject: [PATCH 214/605] macos: copy the key table action bytes --- macos/Sources/Ghostty/Ghostty.Action.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index bde3b3d69..91f1491dd 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -150,12 +150,8 @@ extension Ghostty.Action { init?(c: ghostty_action_key_table_s) { switch c.tag { case GHOSTTY_KEY_TABLE_ACTIVATE: - let name = String( - bytesNoCopy: UnsafeMutableRawPointer(mutating: c.value.activate.name), - length: c.value.activate.len, - encoding: .utf8, - freeWhenDone: false - ) ?? "" + let data = Data(bytes: c.value.activate.name, count: c.value.activate.len) + let name = String(data: data, encoding: .utf8) ?? "" self = .activate(name: name) case GHOSTTY_KEY_TABLE_DEACTIVATE: self = .deactivate From 7d3db17396eeda31ec025cec314a42889fa115d4 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:17:01 +0100 Subject: [PATCH 215/605] macOS: key table animations and cleanup --- macos/Sources/Ghostty/SurfaceView.swift | 162 +++++++++++------------- 1 file changed, 76 insertions(+), 86 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index fce50073c..cf4bd37f6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -124,12 +124,10 @@ extension Ghostty { } // Show key state indicator for active key tables and/or pending key sequences - if !surfaceView.keyTables.isEmpty || !surfaceView.keySequence.isEmpty { - KeyStateIndicator( - keyTables: surfaceView.keyTables, - keySequence: surfaceView.keySequence - ) - } + KeyStateIndicator( + keyTables: surfaceView.keyTables, + keySequence: surfaceView.keySequence + ) #endif // If we have a URL from hovering a link, we show that. @@ -745,7 +743,6 @@ extension Ghostty { @State private var position: Position = .bottom @State private var dragOffset: CGSize = .zero @State private var isDragging = false - @State private var capsuleSize: CGSize = .zero private let padding: CGFloat = 8 @@ -765,82 +762,75 @@ extension Ghostty { case .bottom: return .bottom } } - } - - var body: some View { - GeometryReader { geo in - indicatorContent - .background( - GeometryReader { capsuleGeo in - Color.clear.preference( - key: CapsuleSizeKey.self, - value: capsuleGeo.size - ) - } - ) - .offset(dragOffset) - .padding(padding) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment) - .onPreferenceChange(CapsuleSizeKey.self) { size in - capsuleSize = size - } - .highPriorityGesture( - DragGesture(coordinateSpace: .local) - .onChanged { value in - isDragging = true - dragOffset = CGSize(width: 0, height: value.translation.height) - } - .onEnded { value in - isDragging = false - let dragThreshold: CGFloat = 50 - - withAnimation(.easeOut(duration: 0.2)) { - if position == .bottom && value.translation.height < -dragThreshold { - position = .top - } else if position == .top && value.translation.height > dragThreshold { - position = .bottom - } - dragOffset = .zero - } - } - ) + + var transitionEdge: Edge { + popoverEdge } - .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + var body: some View { + Group { + if !keyTables.isEmpty { + content + // Reset pointer style incase the mouse didn't move away + .backport.pointerStyle(keyTables.isEmpty ? nil : .link) + } + } + .transition(.move(edge: position.transitionEdge).combined(with: .opacity)) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count) } - - private struct CapsuleSizeKey: PreferenceKey { - static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() - } + + var content: some View { + indicatorContent + .offset(dragOffset) + .padding(padding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment) + .highPriorityGesture( + DragGesture(coordinateSpace: .local) + .onChanged { value in + isDragging = true + dragOffset = CGSize(width: 0, height: value.translation.height) + } + .onEnded { value in + isDragging = false + let dragThreshold: CGFloat = 50 + + withAnimation(.easeOut(duration: 0.2)) { + if position == .bottom && value.translation.height < -dragThreshold { + position = .top + } else if position == .top && value.translation.height > dragThreshold { + position = .bottom + } + dragOffset = .zero + } + } + ) } - + + @ViewBuilder private var indicatorContent: some View { HStack(alignment: .center, spacing: 8) { // Key table indicator - if !keyTables.isEmpty { - HStack(alignment: .firstTextBaseline, spacing: 5) { - Image(systemName: "keyboard.badge.ellipsis") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - - // Show table stack with arrows between them - ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in - if index > 0 { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) - } - Text(verbatim: table) - .font(.system(size: 13, weight: .medium, design: .rounded)) + HStack(spacing: 5) { + Image(systemName: "keyboard.badge.ellipsis") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + // Show table stack with arrows between them + ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) } + Text(verbatim: table) + .font(.system(size: 13, weight: .medium, design: .rounded)) } } - + // Separator when both are active - if !keyTables.isEmpty && !keySequence.isEmpty { + if !keySequence.isEmpty { Divider() .frame(height: 14) } @@ -853,11 +843,10 @@ extension Ghostty { } // Animated ellipsis to indicate waiting for next key - PendingIndicator() + PendingIndicator(paused: isDragging) } } } - .frame(height: 18) .padding(.horizontal, 12) .padding(.vertical, 6) .background { @@ -933,26 +922,27 @@ extension Ghostty { /// Animated dots to indicate waiting for the next key struct PendingIndicator: View { - @State private var animationPhase: Int = 0 - + @State private var animationPhase: Double = 0 + let paused: Bool + var body: some View { - HStack(spacing: 2) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(Color.secondary) - .frame(width: 4, height: 4) - .opacity(dotOpacity(for: index)) + TimelineView(.animation(paused: paused)) { context in + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.secondary) + .frame(width: 4, height: 4) + .opacity(dotOpacity(for: index)) + } } - } - .onAppear { - withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: false)) { - animationPhase = 3 + .onChange(of: context.date.timeIntervalSinceReferenceDate) { newValue in + animationPhase = newValue } } } private func dotOpacity(for index: Int) -> Double { - let phase = Double(animationPhase) + let phase = animationPhase let offset = Double(index) / 3.0 let wave = sin((phase + offset) * .pi * 2) return 0.3 + 0.7 * ((wave + 1) / 2) From 18c8c338e0a19b17f505ae37736eac481bb1922a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 21 Dec 2025 07:30:58 -0800 Subject: [PATCH 216/605] Reset key tables on config reload, bound max active key tables Two unrelated changes to polish key tables: 1. Key tables should be reset (deactivated) when teh config is reloaded. This matches the behavior of key sequences as well, which are reset on config reload. 2. A maximum number of active key tables is now enforced (8). This prevents a misbehaving config from consuming too much memory by activating too many key tables. This is an arbitrary limit we can adjust later if needed. --- src/Surface.zig | 67 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3a83c704a..2784f93db 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -49,6 +49,10 @@ const Renderer = rendererpkg.Renderer; const min_window_width_cells: u32 = 10; const min_window_height_cells: u32 = 4; +/// The maximum number of key tables that can be active at any +/// given time. `activate_key_table` calls after this are ignored. +const max_active_key_tables = 8; + /// Allocator alloc: Allocator, @@ -267,6 +271,8 @@ pub const Keyboard = struct { /// The stack of tables that is currently active. The first value /// in this is the first activated table (NOT the default keybinding set). + /// + /// This is bounded by `max_active_key_tables`. table_stack: std.ArrayListUnmanaged(struct { set: *const input.Binding.Set, once: bool, @@ -1737,6 +1743,14 @@ pub fn updateConfig( // If we are in the middle of a key sequence, clear it. self.endKeySequence(.drop, .free); + // Deactivate all key tables since they may have changed. Importantly, + // we store pointers into the config as part of our table stack so + // we can't keep them active across config changes. But this behavior + // also matches key sequences. + _ = self.deactivateAllKeyTables() catch |err| { + log.warn("failed to deactivate key tables err={}", .{err}); + }; + // Before sending any other config changes, we give the renderer a new font // grid. We could check to see if there was an actual change to the font, // but this is easier and pretty rare so it's not a performance concern. @@ -2967,6 +2981,30 @@ fn maybeHandleBinding( return null; } +fn deactivateAllKeyTables(self: *Surface) !bool { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate_all, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + + return true; +} + const KeySequenceQueued = enum { flush, drop }; const KeySequenceMemory = enum { retain, free }; @@ -5625,6 +5663,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } } + // If we're already at the max, ignore it. + if (self.keyboard.table_stack.items.len >= max_active_key_tables) { + log.info( + "ignoring activate table, max depth reached: {s}", + .{name}, + ); + return false; + } + // Add the table to the stack. try self.keyboard.table_stack.append(self.alloc, .{ .set = set, @@ -5674,25 +5721,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .deactivate_all_key_tables => { - switch (self.keyboard.table_stack.items.len) { - // No key table active. This does nothing. - 0 => return false, - - // Clear the entire table stack. - else => self.keyboard.table_stack.clearAndFree(self.alloc), - } - - // Notify the UI. - _ = self.rt_app.performAction( - .{ .surface = self }, - .key_table, - .deactivate_all, - ) catch |err| { - log.warn( - "failed to notify app of key table err={}", - .{err}, - ); - }; + return try self.deactivateAllKeyTables(); }, .crash => |location| switch (location) { From 9ce04b81b74d069e719a1d982277e3b6ed78a929 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 21 Dec 2025 12:17:20 -0500 Subject: [PATCH 217/605] shell-integration: ensure shell resources exist Our automatic shell integrations require certain resource paths to exist. If they're missing, the launched shell could end up in an inconsistent and unexpected state. For example, we temporarily set ZDOTDIR to our zsh shell integration directory and then restore it from our .zshenv file, but if that script isn't available, the user's shell environment will be broken. The actual runtime logic change was simple: each shell integration routine attempts to open its expected resource path and skips automatic shell integration upon failure. The more complex change was reworking our unit tests to run in a temporary resources directory structure. --- src/termio/shell_integration.zig | 205 ++++++++++++++++++++++++------- 1 file changed, 164 insertions(+), 41 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 71492230e..fc2d4827a 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -114,7 +114,7 @@ fn setupShell( } if (std.mem.eql(u8, "elvish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); + if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; return .{ .shell = .elvish, .command = try command.clone(alloc_arena), @@ -122,7 +122,7 @@ fn setupShell( } if (std.mem.eql(u8, "fish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); + if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; return .{ .shell = .fish, .command = try command.clone(alloc_arena), @@ -130,7 +130,7 @@ fn setupShell( } if (std.mem.eql(u8, "zsh", exe)) { - try setupZsh(resource_dir, env); + if (!try setupZsh(resource_dir, env)) return null; return .{ .shell = .zsh, .command = try command.clone(alloc_arena), @@ -152,9 +152,13 @@ test "force shell" { inline for (@typeInfo(Shell).@"enum".fields) |field| { const shell = @field(Shell, field.name); + + var res: TmpResourcesDir = try .init(alloc, shell); + defer res.deinit(); + const result = try setup( alloc, - ".", + res.path, .{ .shell = "sh" }, &env, shell, @@ -345,13 +349,18 @@ fn setupBash( } // Set our new ENV to point to our integration script. - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const integ_dir = try std.fmt.bufPrint( - &path_buf, + var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const script_path = try std.fmt.bufPrint( + &script_path_buf, "{s}/shell-integration/bash/ghostty.bash", .{resource_dir}, ); - try env.put("ENV", integ_dir); + if (std.fs.openFileAbsolute(script_path, .{})) |file| { + file.close(); + try env.put("ENV", script_path); + } else |_| { + return null; + } // Get the command string from the builder, then copy it to the arena // allocator. The stackFallback allocator's memory becomes invalid after @@ -366,14 +375,21 @@ test "bash" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - + const command = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}), + env.get("ENV").?, + ); } test "bash: unsupported options" { @@ -382,6 +398,9 @@ test "bash: unsupported options" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + const cmdlines = [_][:0]const u8{ "bash --posix", "bash --rcfile script.sh --posix", @@ -394,7 +413,7 @@ test "bash: unsupported options" { var env = EnvMap.init(alloc); defer env.deinit(); - try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, ".", &env) == null); + try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null); try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null); try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null); try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); @@ -407,13 +426,15 @@ test "bash: inject flags" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + // bash --norc { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env); - + const command = try setupBash(alloc, .{ .shell = "bash --norc" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?); } @@ -423,8 +444,7 @@ test "bash: inject flags" { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env); - + const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); } @@ -436,19 +456,22 @@ test "bash: rcfile" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); // bash --rcfile { - const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } // bash --init-file { - const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } @@ -460,12 +483,15 @@ test "bash: HISTFILE" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + // HISTFILE unset { var env = EnvMap.init(alloc); defer env.deinit(); - _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expect(std.mem.endsWith(u8, env.get("HISTFILE").?, ".bash_history")); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE").?); } @@ -477,7 +503,7 @@ test "bash: HISTFILE" { try env.put("HISTFILE", "my_history"); - _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); } @@ -489,14 +515,22 @@ test "bash: ENV" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); try env.put("ENV", "env.sh"); - _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}), + env.get("ENV").?, + ); } test "bash: additional arguments" { @@ -505,18 +539,21 @@ test "bash: additional arguments" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); // "-" argument separator { - const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, res.path, &env); try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); } // "--" argument separator { - const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, res.path, &env); try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); } } @@ -532,20 +569,22 @@ fn setupXdgDataDirs( alloc_arena: Allocator, resource_dir: []const u8, env: *EnvMap, -) !void { +) !bool { var path_buf: [std.fs.max_path_bytes]u8 = undefined; // Get our path to the shell integration directory. - const integ_dir = try std.fmt.bufPrint( + const integ_path = try std.fmt.bufPrint( &path_buf, "{s}/shell-integration", .{resource_dir}, ); + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + integ_dir.close(); // Set an env var so we can remove this from XDG_DATA_DIRS later. // This happens in the shell integration config itself. We do this // so that our modifications don't interfere with other commands. - try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_dir); + try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_path); // We attempt to avoid allocating by using the stack up to 4K. // Max stack size is considerably larger on mac @@ -565,9 +604,11 @@ fn setupXdgDataDirs( try internal_os.prependEnv( stack_alloc, env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share", - integ_dir, + integ_path, ), ); + + return true; } test "xdg: empty XDG_DATA_DIRS" { @@ -577,13 +618,23 @@ test "xdg: empty XDG_DATA_DIRS" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .fish); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); - try setupXdgDataDirs(alloc, ".", &env); + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); - try testing.expectEqualStrings("./shell-integration", env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?); - try testing.expectEqualStrings("./shell-integration:/usr/local/share:/usr/share", env.get("XDG_DATA_DIRS").?); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/usr/local/share:/usr/share", .{res.path}), + env.get("XDG_DATA_DIRS").?, + ); } test "xdg: existing XDG_DATA_DIRS" { @@ -593,14 +644,24 @@ test "xdg: existing XDG_DATA_DIRS" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .fish); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); try env.put("XDG_DATA_DIRS", "/opt/share"); - try setupXdgDataDirs(alloc, ".", &env); + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); - try testing.expectEqualStrings("./shell-integration", env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?); - try testing.expectEqualStrings("./shell-integration:/opt/share", env.get("XDG_DATA_DIRS").?); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/opt/share", .{res.path}), + env.get("XDG_DATA_DIRS").?, + ); } /// Setup the zsh automatic shell integration. This works by setting @@ -609,7 +670,7 @@ test "xdg: existing XDG_DATA_DIRS" { fn setupZsh( resource_dir: []const u8, env: *EnvMap, -) !void { +) !bool { // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); @@ -617,34 +678,96 @@ fn setupZsh( // Set our new ZDOTDIR to point to our shell resource directory. var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const integ_dir = try std.fmt.bufPrint( + const integ_path = try std.fmt.bufPrint( &path_buf, "{s}/shell-integration/zsh", .{resource_dir}, ); - try env.put("ZDOTDIR", integ_dir); + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + integ_dir.close(); + try env.put("ZDOTDIR", integ_path); + + return true; } test "zsh" { const testing = std.testing; + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + defer res.deinit(); + var env = EnvMap.init(testing.allocator); defer env.deinit(); - try setupZsh(".", &env); - try testing.expectEqualStrings("./shell-integration/zsh", env.get("ZDOTDIR").?); + try testing.expect(try setupZsh(res.path, &env)); + try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null); } test "zsh: ZDOTDIR" { const testing = std.testing; + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + defer res.deinit(); + var env = EnvMap.init(testing.allocator); defer env.deinit(); try env.put("ZDOTDIR", "$HOME/.config/zsh"); - try setupZsh(".", &env); - try testing.expectEqualStrings("./shell-integration/zsh", env.get("ZDOTDIR").?); + try testing.expect(try setupZsh(res.path, &env)); + try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?); } + +/// Test helper that creates a temporary resources directory with shell integration paths. +const TmpResourcesDir = struct { + allocator: Allocator, + tmp_dir: std.testing.TmpDir, + path: []const u8, + shell_path: []const u8, + + fn init(allocator: std.mem.Allocator, shell: Shell) !TmpResourcesDir { + var tmp_dir = std.testing.tmpDir(.{}); + errdefer tmp_dir.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const relative_shell_path = try std.fmt.bufPrint( + &path_buf, + "shell-integration/{s}", + .{@tagName(shell)}, + ); + try tmp_dir.dir.makePath(relative_shell_path); + + const path = try tmp_dir.dir.realpathAlloc(allocator, "."); + errdefer allocator.free(path); + + const shell_path = try std.fmt.allocPrint( + allocator, + "{s}/{s}", + .{ path, relative_shell_path }, + ); + errdefer allocator.free(shell_path); + + switch (shell) { + .bash => try tmp_dir.dir.writeFile(.{ + .sub_path = "shell-integration/bash/ghostty.bash", + .data = "", + }), + else => {}, + } + + return .{ + .allocator = allocator, + .tmp_dir = tmp_dir, + .path = path, + .shell_path = shell_path, + }; + } + + fn deinit(self: *TmpResourcesDir) void { + self.allocator.free(self.shell_path); + self.allocator.free(self.path); + self.tmp_dir.cleanup(); + } +}; From 97cd4c71d50d3adc5690ee2645af0e08aa5f4b3c Mon Sep 17 00:00:00 2001 From: Henrique Albuquerque <18596542+henrialb@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:57:23 +0000 Subject: [PATCH 218/605] Fix typo --- 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 c0d8e813e..15a1877ff 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3271,7 +3271,7 @@ else /// more subtle border. @"gtk-toolbar-style": GtkToolbarStyle = .raised, -/// The style of the GTK titlbar. Available values are `native` and `tabs`. +/// The style of the GTK titlebar. Available values are `native` and `tabs`. /// /// The `native` titlebar style is a traditional titlebar with a title, a few /// buttons and window controls. A separate tab bar will show up below the From 8a8b06e74dc52cb440df83931004b03b250e1e62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 21 Dec 2025 13:28:12 -0800 Subject: [PATCH 219/605] config: document key tables for `keybind` --- src/config/Config.zig | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 15a1877ff..8f1cece45 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1666,6 +1666,50 @@ class: ?[:0]const u8 = null, /// /// - 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)). +/// +/// You may also create a named set of keybindings known as a "key table." +/// A key table must be explicitly activated for the bindings to become +/// available. This can be used to implement features such as a +/// "copy mode", "vim mode", etc. Generically, this can implement modal +/// keyboard input. +/// +/// Key tables are defined using the syntax `/`. The +/// `` value is everything documented above for keybinds. The +/// `
` value is the name of the key table. Table names can contain +/// anything except `/` and `=`. For example `foo/ctrl+a=new_window` +/// defines a binding within a table named `foo`. +/// +/// Tables are activated and deactivated using the binding actions +/// `activate_key_table:` and `deactivate_key_table`. Other table +/// related binding actions also exist; see the documentation for a full list. +/// These are the primary way to interact with key tables. +/// +/// Binding lookup proceeds from the innermost table outward, so keybinds in +/// the default table remain available unless explicitly unbound in an inner +/// table. +/// +/// A key table has some special syntax and handling: +/// +/// * `/` (with no binding) defines and clears a table, resetting all +/// of its keybinds and settings. +/// +/// * You cannot activate a table that is already the innermost table; such +/// attempts are ignored. However, the same table can appear multiple times +/// in the stack as long as it is not innermost (e.g., `A -> B -> A -> B` +/// is valid, but `A -> B -> B` is not). +/// +/// * A table can be activated in one-shot mode using +/// `activate_key_table_once:`. A one-shot table is automatically +/// deactivated when any non-catch-all binding is invoked. +/// +/// * Key sequences work within tables: `foo/ctrl+a>ctrl+b=new_window`. +/// If an invalid key is pressed, the sequence ends but the table remains +/// active. +/// +/// * Prefixes like `global:` work within tables: +/// `foo/global:ctrl+a=new_window`. +/// +/// Key tables are available since Ghostty 1.3.0. keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells From 39481453fe8b958a94ec4aeae1a4f885954c6386 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 21 Dec 2025 13:32:23 -0800 Subject: [PATCH 220/605] macos: show the key sequence overlay if no tables are active --- macos/Sources/Ghostty/SurfaceView.swift | 33 +++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index cf4bd37f6..2d5039d29 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -770,10 +770,9 @@ extension Ghostty { var body: some View { Group { - if !keyTables.isEmpty { + if !keyTables.isEmpty || !keySequence.isEmpty { content - // Reset pointer style incase the mouse didn't move away - .backport.pointerStyle(keyTables.isEmpty ? nil : .link) + .backport.pointerStyle(!keyTables.isEmpty ? .link : nil) } } .transition(.move(edge: position.transitionEdge).combined(with: .opacity)) @@ -812,25 +811,27 @@ extension Ghostty { private var indicatorContent: some View { HStack(alignment: .center, spacing: 8) { // Key table indicator - HStack(spacing: 5) { - Image(systemName: "keyboard.badge.ellipsis") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + if !keyTables.isEmpty { + HStack(spacing: 5) { + Image(systemName: "keyboard.badge.ellipsis") + .font(.system(size: 13)) + .foregroundStyle(.secondary) - // Show table stack with arrows between them - ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in - if index > 0 { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) + // Show table stack with arrows between them + ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + Text(verbatim: table) + .font(.system(size: 13, weight: .medium, design: .rounded)) } - Text(verbatim: table) - .font(.system(size: 13, weight: .medium, design: .rounded)) } } // Separator when both are active - if !keySequence.isEmpty { + if !keyTables.isEmpty && !keySequence.isEmpty { Divider() .frame(height: 14) } From 73fd007a836f1f7423606c94d2c9c935aa3c81f5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 21 Dec 2025 16:44:43 -0500 Subject: [PATCH 221/605] shell-integration: log warnings for missing paths --- src/termio/shell_integration.zig | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index fc2d4827a..3a541dcae 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -358,7 +358,8 @@ fn setupBash( if (std.fs.openFileAbsolute(script_path, .{})) |file| { file.close(); try env.put("ENV", script_path); - } else |_| { + } else |err| { + log.warn("unable to open {s}: {}", .{ script_path, err }); return null; } @@ -578,7 +579,10 @@ fn setupXdgDataDirs( "{s}/shell-integration", .{resource_dir}, ); - var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + log.warn("unable to open {s}: {}", .{ integ_path, err }); + return false; + }; integ_dir.close(); // Set an env var so we can remove this from XDG_DATA_DIRS later. @@ -683,7 +687,10 @@ fn setupZsh( "{s}/shell-integration/zsh", .{resource_dir}, ); - var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + log.warn("unable to open {s}: {}", .{ integ_path, err }); + return false; + }; integ_dir.close(); try env.put("ZDOTDIR", integ_path); From d0767a089aa7d81eec523fa3ab1233f401e9d0e4 Mon Sep 17 00:00:00 2001 From: -k Date: Sun, 21 Dec 2025 17:11:34 -0500 Subject: [PATCH 222/605] build: fix `simdutf`/`highway` flags --- pkg/highway/build.zig | 5 +++++ pkg/simdutf/build.zig | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index fd93675e6..04fe70853 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -72,6 +72,11 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined", }); + + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (target.result.os.tag != .windows) { try flags.appendSlice(b.allocator, &.{ "-fmath-errno", diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 0d827c1cc..2b157d1a9 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -32,6 +32,10 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize-trap=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + lib.addCSourceFiles(.{ .flags = flags.items, .files = &.{ From ab352b5af9694a7cba8e237d0b1b5a507a6e4226 Mon Sep 17 00:00:00 2001 From: Yasu Flores Date: Sun, 21 Dec 2025 20:26:57 -0600 Subject: [PATCH 223/605] macos: Support native actions to move to beginning of document and move to end of document --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 455249ff4..817fca191 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -9,7 +9,7 @@ extension Ghostty { /// The NSView implementation for a terminal surface. class SurfaceView: OSView, ObservableObject, Codable, Identifiable { typealias ID = UUID - + /// Unique ID per surface let id: UUID @@ -44,14 +44,14 @@ extension Ghostty { // The hovered URL string @Published var hoverUrl: String? = nil - + // The progress report (if any) @Published var progressReport: Action.ProgressReport? = nil { didSet { // Cancel any existing timer progressReportTimer?.invalidate() progressReportTimer = nil - + // If we have a new progress report, start a timer to remove it after 15 seconds if progressReport != nil { progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in @@ -101,7 +101,7 @@ extension Ghostty { } } } - + // Cancellable for search state needle changes private var searchNeedleCancellable: AnyCancellable? @@ -219,7 +219,7 @@ extension Ghostty { // A timer to fallback to ghost emoji if no title is set within the grace period private var titleFallbackTimer: Timer? - + // Timer to remove progress report after 15 seconds private var progressReportTimer: Timer? @@ -418,7 +418,7 @@ extension Ghostty { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - + // Cancel progress report timer progressReportTimer?.invalidate() } @@ -555,16 +555,16 @@ extension Ghostty { // Add buttons alert.addButton(withTitle: "OK") alert.addButton(withTitle: "Cancel") - + // Make the text field the first responder so it gets focus alert.window.initialFirstResponder = textField - + let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in guard let self else { return } - + // Check if the user clicked "OK" guard response == .alertFirstButtonReturn else { return } - + // Get the input text let newTitle = textField.stringValue if newTitle.isEmpty { @@ -988,7 +988,7 @@ extension Ghostty { var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas - + if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; @@ -1350,7 +1350,7 @@ extension Ghostty { var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) key_ev.composing = composing - + // For text, we only encode UTF8 if we don't have a single control // character. Control characters are encoded by Ghostty itself. // Without this, `ctrl+enter` does the wrong thing. @@ -1509,7 +1509,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func find(_ sender: Any?) { guard let surface = self.surface else { return } let action = "start_search" @@ -1517,7 +1517,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" @@ -1533,7 +1533,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } let action = "end_search" @@ -1593,7 +1593,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func changeTitle(_ sender: Any) { promptTitle() } @@ -1703,7 +1703,7 @@ extension Ghostty { let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false self.init(app, baseConfig: config, uuid: uuid) - + // Restore the saved title after initialization if let title = savedTitle { self.title = title @@ -1920,6 +1920,16 @@ extension Ghostty.SurfaceView: NSTextInputClient { return } + // Process MacOS native scroll events + switch selector { + case #selector(moveToBeginningOfDocument(_:)): + surfaceModel!.perform(action: "scroll_to_top") + case #selector(moveToEndOfDocument(_:)): + surfaceModel!.perform(action: "scroll_to_bottom") + default: + break + } + print("SEL: \(selector)") } @@ -1960,14 +1970,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { // The "COMBINATION" bit is key: we might get sent a string (we can handle that) // but get requested an image (we can't handle that at the time of writing this), // so we must bubble up. - + // Types we can receive let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")] - + // Types that we can send. Currently the same as receivable but I'm separating // this out so we can modify this in the future. let sendable: [NSPasteboard.PasteboardType] = receivable - + // The sendable types that require a selection (currently all) let sendableRequiresSelection = sendable @@ -1984,7 +1994,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { return super.validRequestor(forSendType: sendType, returnType: returnType) } } - + return self } @@ -2030,7 +2040,7 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty - + case #selector(findHide): return searchState != nil @@ -2135,7 +2145,7 @@ extension Ghostty.SurfaceView { override func accessibilitySelectedTextRange() -> NSRange { return selectedRange() } - + /// Returns the currently selected text as a string. /// This allows assistive technologies to read the selected content. override func accessibilitySelectedText() -> String? { @@ -2149,21 +2159,21 @@ extension Ghostty.SurfaceView { let str = String(cString: text.text) return str.isEmpty ? nil : str } - + /// Returns the number of characters in the terminal content. /// This helps assistive technologies understand the size of the content. override func accessibilityNumberOfCharacters() -> Int { let content = cachedScreenContents.get() return content.count } - + /// Returns the visible character range for the terminal. /// For terminals, we typically show all content as visible. override func accessibilityVisibleCharacterRange() -> NSRange { let content = cachedScreenContents.get() return NSRange(location: 0, length: content.count) } - + /// Returns the line number for a given character index. /// This helps assistive technologies navigate by line. override func accessibilityLine(for index: Int) -> Int { @@ -2171,7 +2181,7 @@ extension Ghostty.SurfaceView { let substring = String(content.prefix(index)) return substring.components(separatedBy: .newlines).count - 1 } - + /// Returns a substring for the given range. /// This allows assistive technologies to read specific portions of the content. override func accessibilityString(for range: NSRange) -> String? { @@ -2179,7 +2189,7 @@ extension Ghostty.SurfaceView { guard let swiftRange = Range(range, in: content) else { return nil } return String(content[swiftRange]) } - + /// Returns an attributed string for the given range. /// /// Note: right now this only applies font information. One day it'd be nice to extend @@ -2190,9 +2200,9 @@ extension Ghostty.SurfaceView { override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { guard let surface = self.surface else { return nil } guard let plainString = accessibilityString(for: range) else { return nil } - + var attributes: [NSAttributedString.Key: Any] = [:] - + // Try to get the font from the surface if let fontRaw = ghostty_surface_quicklook_font(surface) { let font = Unmanaged.fromOpaque(fontRaw) From 2215b731da23013324b242fbb28f00b230441770 Mon Sep 17 00:00:00 2001 From: Yasu Flores Date: Sun, 21 Dec 2025 20:47:56 -0600 Subject: [PATCH 224/605] Address warning and add guard clause --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 817fca191..c54f674a5 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1910,6 +1910,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { /// 1. Prevents an audible NSBeep for unimplemented actions. /// 2. Allows us to properly encode super+key input events that we don't handle override func doCommand(by selector: Selector) { + guard let surfaceModel else { return } // If we are being processed by performKeyEquivalent with a command binding, // we send it back through the event system so it can be encoded. if let lastPerformKeyEvent, @@ -1923,9 +1924,9 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Process MacOS native scroll events switch selector { case #selector(moveToBeginningOfDocument(_:)): - surfaceModel!.perform(action: "scroll_to_top") + _ = surfaceModel.perform(action: "scroll_to_top") case #selector(moveToEndOfDocument(_:)): - surfaceModel!.perform(action: "scroll_to_bottom") + _ = surfaceModel.perform(action: "scroll_to_bottom") default: break } From b4a5ddfef966f8def62864f901b0d9cec608fd82 Mon Sep 17 00:00:00 2001 From: Suyeol Jeon Date: Thu, 18 Dec 2025 17:13:02 +0900 Subject: [PATCH 225/605] macos: apply window position after setting content size When window-width/height is configured, the window size is set via setContentSize in windowDidLoad. However, window-position-x/y was not being applied after this resize, causing the window to appear at an incorrect position. This was a regression introduced in c75bade89 which refactored the default size logic from a computed NSRect property to a DefaultSize enum. The original code called adjustForWindowPosition after calculating the frame, but this was lost during the refactoring. Fixes the issue by calling adjustForWindowPosition after applying contentIntrinsicSize to ensure window position is correctly set. --- macos/Sources/Features/Terminal/TerminalController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 8a0c5f46d..bccdd9c69 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -952,9 +952,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case .contentIntrinsicSize: // Content intrinsic size requires a short delay so that AppKit // can layout our SwiftUI views. - DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak window] in - guard let window else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in + guard let self, let window else { return } defaultSize.apply(to: window) + if let screen = window.screen ?? NSScreen.main { + let frame = self.adjustForWindowPosition(frame: window.frame, on: screen) + window.setFrameOrigin(frame.origin) + } } } } From 5bd814adf8b2fad4f7d8ca7c05776c8dcb6cd35a Mon Sep 17 00:00:00 2001 From: Yasu Flores Date: Mon, 22 Dec 2025 08:53:43 -0600 Subject: [PATCH 226/605] move guard down to keep surfaceModel logic together --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c54f674a5..37cc9282e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1910,7 +1910,6 @@ extension Ghostty.SurfaceView: NSTextInputClient { /// 1. Prevents an audible NSBeep for unimplemented actions. /// 2. Allows us to properly encode super+key input events that we don't handle override func doCommand(by selector: Selector) { - guard let surfaceModel else { return } // If we are being processed by performKeyEquivalent with a command binding, // we send it back through the event system so it can be encoded. if let lastPerformKeyEvent, @@ -1921,6 +1920,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { return } + guard let surfaceModel else { return } // Process MacOS native scroll events switch selector { case #selector(moveToBeginningOfDocument(_:)): From 3877ead07133070792b02b79a10aa9e51d560879 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 08:46:57 -0800 Subject: [PATCH 227/605] input: parse chains (don't do anything with them yet) --- src/input/Binding.zig | 59 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 22a5e8386..0bacea87d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -52,6 +52,7 @@ pub const Parser = struct { trigger_it: SequenceIterator, action: Action, flags: Flags = .{}, + chain: bool, pub const Elem = union(enum) { /// A leader trigger in a sequence. @@ -59,6 +60,12 @@ pub const Parser = struct { /// The final trigger and action in a sequence. binding: Binding, + + /// A chained action `chain=` that should be appended + /// to the previous binding. Note that any action is parsed, including + /// invalid actions for chains such as `unbind`. We expect downstream + /// consumers to validate that the action is valid for chaining. + chain: Action, }; pub fn init(raw_input: []const u8) Error!Parser { @@ -95,12 +102,23 @@ pub const Parser = struct { return Error.InvalidFormat; }; + // Detect chains. Chains must not have flags. + const chain = std.mem.eql(u8, input[0..eql_idx], "chain"); + if (chain and start_idx > 0) return Error.InvalidFormat; + // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ - .trigger_it = .{ .input = input[0..eql_idx] }, + .trigger_it = .{ + // This is kind of hacky but we put a dummy trigger + // for chained inputs. The `next` will never yield this + // because we have chain set. When we find a nicer way to + // do this we can remove it, the e2e is tested. + .input = if (chain) "a" else input[0..eql_idx], + }, .action = try .parse(input[eql_idx + 1 ..]), .flags = flags, + .chain = chain, }; } @@ -156,6 +174,9 @@ pub const Parser = struct { return .{ .leader = trigger }; } + // If we're a chain then return it as-is. + if (self.chain) return .{ .chain = self.action }; + // Out of triggers, yield the final action. return .{ .binding = .{ .trigger = trigger, @@ -191,19 +212,26 @@ const SequenceIterator = struct { /// Returns true if there are no more triggers to parse. pub fn done(self: *const SequenceIterator) bool { - return self.i > self.input.len; + return self.i >= self.input.len; } }; /// Parse a single, non-sequenced binding. To support sequences you must /// use parse. This is a convenience function for single bindings aimed /// primarily at tests. -fn parseSingle(raw_input: []const u8) (Error || error{UnexpectedSequence})!Binding { +/// +/// This doesn't support `chain` either, since chaining requires some +/// stateful concept of a prior binding. +fn parseSingle(raw_input: []const u8) (Error || error{ + UnexpectedChain, + UnexpectedSequence, +})!Binding { var p = try Parser.init(raw_input); const elem = (try p.next()) orelse return Error.InvalidFormat; return switch (elem) { .leader => error.UnexpectedSequence, .binding => elem.binding, + .chain => error.UnexpectedChain, }; } @@ -2155,6 +2183,8 @@ pub const Set = struct { b.flags, ), }, + + .chain => @panic("TODO"), } } @@ -2887,6 +2917,29 @@ test "parse: action with a tuple" { try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,four")); } +test "parse: chain" { + const testing = std.testing; + + // Valid + { + var p = try Parser.init("chain=new_tab"); + try testing.expectEqual(Parser.Elem{ + .chain = .new_tab, + }, try p.next()); + try testing.expect(try p.next() == null); + } + + // Chain can't have flags + try testing.expectError(error.InvalidFormat, Parser.init("global:chain=ignore")); + + // Chain can't be part of a sequence + { + var p = try Parser.init("a>chain=ignore"); + _ = try p.next(); + try testing.expectError(error.InvalidFormat, p.next()); + } +} + test "sequence iterator" { const testing = std.testing; From 42c21eb16b0afc6d44820968bf4d585bbf9ccb21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 09:13:05 -0800 Subject: [PATCH 228/605] input: leaf_chained tagged union value --- src/App.zig | 53 +++++++++++++------- src/Surface.zig | 44 ++++++++++++----- src/apprt/embedded.zig | 2 +- src/cli/list_keybinds.zig | 4 +- src/config/Config.zig | 15 ++++++ src/input/Binding.zig | 101 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 184 insertions(+), 35 deletions(-) diff --git a/src/App.zig b/src/App.zig index 99d03399c..00be56f49 100644 --- a/src/App.zig +++ b/src/App.zig @@ -357,15 +357,17 @@ pub fn keyEvent( // Get the keybind entry for this event. We don't support key sequences // so we can look directly in the top-level set. const entry = rt_app.config.keybind.set.getEvent(event) orelse return false; - const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) { + const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { // Sequences aren't supported. Our configuration parser verifies // this for global keybinds but we may still get an entry for // a non-global keybind. .leader => return false, // Leaf entries are good - .leaf => |leaf| leaf, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), }; + const actions: []const input.Binding.Action = leaf.actionsSlice(); + assert(actions.len > 0); // If we aren't focused, then we only process global keybinds. if (!self.focused and !leaf.flags.global) return false; @@ -373,13 +375,7 @@ pub fn keyEvent( // Global keybinds are done using performAll so that they // can target all surfaces too. if (leaf.flags.global) { - self.performAllAction(rt_app, leaf.action) catch |err| { - log.warn("error performing global keybind action action={s} err={}", .{ - @tagName(leaf.action), - err, - }); - }; - + self.performAllChainedAction(rt_app, actions); return true; } @@ -389,14 +385,20 @@ pub fn keyEvent( // If we are focused, then we process keybinds only if they are // app-scoped. Otherwise, we do nothing. Surface-scoped should - // be processed by Surface.keyEvent. - const app_action = leaf.action.scoped(.app) orelse return false; - self.performAction(rt_app, app_action) catch |err| { - log.warn("error performing app keybind action action={s} err={}", .{ - @tagName(app_action), - err, - }); - }; + // be processed by Surface.keyEvent. For chained actions, all + // actions must be app-scoped. + for (actions) |action| if (action.scoped(.app) == null) return false; + for (actions) |action| { + self.performAction( + rt_app, + action.scoped(.app).?, + ) catch |err| { + log.warn("error performing app keybind action action={s} err={}", .{ + @tagName(action), + err, + }); + }; + } return true; } @@ -454,6 +456,23 @@ pub fn performAction( } } +/// Performs a chained action. We will continue executing each action +/// even if there is a failure in a prior action. +pub fn performAllChainedAction( + self: *App, + rt_app: *apprt.App, + actions: []const input.Binding.Action, +) void { + for (actions) |action| { + self.performAllAction(rt_app, action) catch |err| { + log.warn("error performing chained action action={s} err={}", .{ + @tagName(action), + err, + }); + }; + } +} + /// Perform an app-wide binding action. If the action is surface-specific /// then it will be performed on all surfaces. To perform only app-scoped /// actions, use performAction. diff --git a/src/Surface.zig b/src/Surface.zig index 2784f93db..0ce758636 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2866,7 +2866,7 @@ fn maybeHandleBinding( }; // Determine if this entry has an action or if its a leader key. - const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) { + const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => |set| { // Setup the next set we'll look at. self.keyboard.sequence_set = set; @@ -2893,9 +2893,8 @@ fn maybeHandleBinding( return .consumed; }, - .leaf => |leaf| leaf, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), }; - const action = leaf.action; // consumed determines if the input is consumed or if we continue // encoding the key (if we have a key to encode). @@ -2917,36 +2916,58 @@ fn maybeHandleBinding( // An action also always resets the sequence set. self.keyboard.sequence_set = null; + // Setup our actions + const actions = leaf.actionsSlice(); + // Attempt to perform the action - log.debug("key event binding flags={} action={f}", .{ + log.debug("key event binding flags={} action={any}", .{ leaf.flags, - action, + actions, }); const performed = performed: { // If this is a global or all action, then we perform it on // the app and it applies to every surface. if (leaf.flags.global or leaf.flags.all) { - try self.app.performAllAction(self.rt_app, action); + self.app.performAllChainedAction( + self.rt_app, + actions, + ); // "All" actions are always performed since they are global. break :performed true; } - break :performed try self.performBindingAction(action); + // Perform each action. We are performed if ANY of the chained + // actions perform. + var performed: bool = false; + for (actions) |action| { + if (self.performBindingAction(action)) |_| { + performed = true; + } else |err| { + log.info( + "key binding action failed action={t} err={}", + .{ action, err }, + ); + } + } + + break :performed performed; }; 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)) { + for (actions) |action| 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; + for (actions) |action| if (action == .ignore) { + return .ignored; + }; } // If we have the performable flag and the action was not performed, @@ -2970,7 +2991,8 @@ fn maybeHandleBinding( // Store our last trigger so we don't encode the release event self.keyboard.last_trigger = event.bindingHash(); - if (insp_ev) |ev| ev.binding = action; + // TODO: Inspector must support chained events + if (insp_ev) |ev| ev.binding = actions[0]; return .consumed; } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index da7a585a5..1cb9231bc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -155,7 +155,7 @@ pub const App = struct { while (it.next()) |entry| { switch (entry.value_ptr.*) { .leader => {}, - .leaf => |leaf| if (leaf.flags.global) return true, + inline .leaf, .leaf_chained => |leaf| if (leaf.flags.global) return true, } } diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index e463f55b9..fb7ad19ec 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -326,7 +326,6 @@ fn iterateBindings( switch (bind.value_ptr.*) { .leader => |leader| { - // Recursively iterate on the set of bindings for this leader key var n_iter = leader.bindings.iterator(); const sub_bindings, const max_width = try iterateBindings(alloc, &n_iter, win); @@ -353,6 +352,9 @@ fn iterateBindings( .action = leaf.action, }); }, + .leaf_chained => { + // TODO: Show these. + }, } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 8f1cece45..f75944aeb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6749,6 +6749,21 @@ pub const Keybinds = struct { other_leaf, )) return false; }, + + .leaf_chained => { + const self_chain = self_entry.value_ptr.*.leaf_chained; + const other_chain = other_entry.value_ptr.*.leaf_chained; + + if (self_chain.flags != other_chain.flags) return false; + if (self_chain.actions.items.len != other_chain.actions.items.len) return false; + for (self_chain.actions.items, other_chain.actions.items) |a1, a2| { + if (!equalField( + inputpkg.Binding.Action, + a1, + a2, + )) return false; + } + }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0bacea87d..39b6ffcba 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -212,7 +212,7 @@ const SequenceIterator = struct { /// Returns true if there are no more triggers to parse. pub fn done(self: *const SequenceIterator) bool { - return self.i >= self.input.len; + return self.i > self.input.len; } }; @@ -1953,6 +1953,9 @@ pub const Set = struct { /// to take along with the flags that may define binding behavior. leaf: Leaf, + /// A set of actions to take in response to a trigger. + leaf_chained: LeafChained, + /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. pub fn format( @@ -2018,6 +2021,8 @@ pub const Set = struct { buffer.print("={f}", .{leaf.action}) catch return error.OutOfMemory; try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); }, + + .leaf_chained => @panic("TODO"), } } }; @@ -2044,6 +2049,47 @@ pub const Set = struct { std.hash.autoHash(&hasher, self.flags); return hasher.final(); } + + pub fn generic(self: *const Leaf) GenericLeaf { + return .{ + .flags = self.flags, + .actions = .{ .single = .{self.action} }, + }; + } + }; + + /// Leaf node of a set that triggers multiple actions in sequence. + pub const LeafChained = struct { + actions: std.ArrayList(Action), + flags: Flags, + + pub fn deinit(self: *LeafChained, alloc: Allocator) void { + self.actions.deinit(alloc); + } + + pub fn generic(self: *const LeafChained) GenericLeaf { + return .{ + .flags = self.flags, + .actions = .{ .many = self.actions.items }, + }; + } + }; + + /// A generic leaf node that can be used to unify the handling of + /// leaf and leaf_chained. + pub const GenericLeaf = struct { + flags: Flags, + actions: union(enum) { + single: [1]Action, + many: []const Action, + }, + + pub fn actionsSlice(self: *const GenericLeaf) []const Action { + return switch (self.actions) { + .single => |*arr| arr, + .many => |slice| slice, + }; + } }; /// A full key-value entry for the set. @@ -2057,6 +2103,9 @@ pub const Set = struct { s.deinit(alloc); alloc.destroy(s); }, + + .leaf_chained => |*l| l.deinit(alloc), + .leaf => {}, }; @@ -2133,7 +2182,7 @@ pub const Set = struct { error.OutOfMemory => return error.OutOfMemory, }, - .leaf => { + .leaf, .leaf_chained => { // Remove the existing action. Fallthrough as if // we don't have a leader. set.remove(alloc, t); @@ -2163,6 +2212,7 @@ pub const Set = struct { leaf.action, leaf.flags, ) catch {}, + .leaf_chained => @panic("TODO"), }; }, @@ -2184,7 +2234,9 @@ pub const Set = struct { ), }, - .chain => @panic("TODO"), + .chain => { + // TODO: Do this, ignore for now. + }, } } @@ -2236,6 +2288,12 @@ pub const Set = struct { } } }, + + // Chained leaves aren't in the reverse mapping so we just + // clear it out. + .leaf_chained => |*l| { + l.deinit(alloc); + }, }; gop.value_ptr.* = .{ .leaf = .{ @@ -2312,7 +2370,7 @@ pub const Set = struct { } fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { - const entry = self.bindings.get(t) orelse return; + var entry = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); switch (entry) { @@ -2334,7 +2392,7 @@ pub const Set = struct { var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { - .leader => {}, + .leader, .leaf_chained => {}, .leaf => |leaf_search| { if (leaf_search.action.hash() == action_hash) { self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); @@ -2348,6 +2406,12 @@ pub const Set = struct { _ = self.reverse.remove(leaf.action); } }, + + // Chained leaves are never in our reverse mapping so no + // cleanup is required. + .leaf_chained => |*l| { + l.deinit(alloc); + }, } } @@ -2366,6 +2430,8 @@ pub const Set = struct { // contain allocated strings). .leaf => |*s| s.* = try s.clone(alloc), + .leaf_chained => @panic("TODO"), + // Must be deep cloned. .leader => |*s| { const ptr = try alloc.create(Set); @@ -3356,6 +3422,31 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } +// test "set: parseAndPut chain" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var s: Set = .{}; +// defer s.deinit(alloc); +// +// try s.parseAndPut(alloc, "a=new_window"); +// try s.parseAndPut(alloc, "chain=new_tab"); +// +// // Creates forward mapping +// { +// const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; +// try testing.expect(action.action == .new_window); +// try testing.expectEqual(Flags{}, action.flags); +// } +// +// // Does not create reverse mapping, because reverse mappings are only for +// // non-chain actions. +// { +// const trigger = s.getTrigger(.new_window); +// try testing.expect(trigger == null); +// } +// } + test "set: getEvent physical" { const testing = std.testing; const alloc = testing.allocator; From 457fededeb6f30b272b81bfe4583987091a0a846 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 10:51:43 -0800 Subject: [PATCH 229/605] input: keep track of chain parent --- src/input/Binding.zig | 323 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 305 insertions(+), 18 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 39b6ffcba..6c91415e4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1943,6 +1943,10 @@ pub const Set = struct { /// integration with GUI toolkits. reverse: ReverseMap = .{}, + /// The chain parent is the information necessary to attach a chained + /// action to the proper location in our mapping. + chain_parent: ?HashMap.Entry = null, + /// The entry type for the forward mapping of trigger to action. pub const Value = union(enum) { /// This key is a leader key in a sequence. You must follow the given @@ -2135,26 +2139,55 @@ pub const Set = struct { // We use recursion so that we can utilize the stack as our state // for cleanup. - self.parseAndPutRecurse(alloc, &it) catch |err| switch (err) { - // If this gets sent up to the root then we've unbound - // all the way up and this put was a success. - error.SequenceUnbind => {}, + const updated_set_ = self.parseAndPutRecurse( + alloc, + &it, + ) catch |err| err: { + switch (err) { + // If this gets sent up to the root then we've unbound + // all the way up and this put was a success. + error.SequenceUnbind => break :err null, - // Unrecoverable - error.OutOfMemory => return error.OutOfMemory, + // If our parser input was too short then the format + // is invalid because we handle all valid cases. + error.UnexpectedEndOfInput => return error.InvalidFormat, + + // Unrecoverable + error.OutOfMemory => return error.OutOfMemory, + } + + // Errors must never fall through. + unreachable; }; + + // If we have an updated set (a binding was added) then we store + // it for our chain parent. If we didn't update a set then we clear + // our chain parent since chaining is no longer valid until a + // valid binding is saved. + if (updated_set_) |updated_set| { + // A successful addition must have recorded a chain parent. + assert(updated_set.chain_parent != null); + if (updated_set != self) self.chain_parent = updated_set.chain_parent; + assert(self.chain_parent != null); + } else { + self.chain_parent = null; + } } const ParseAndPutRecurseError = Allocator.Error || error{ SequenceUnbind, + UnexpectedEndOfInput, }; + /// Returns the set that was ultimately updated if a binding was + /// added. Unbind does not return a set since nothing was added. fn parseAndPutRecurse( set: *Set, alloc: Allocator, it: *Parser, - ) ParseAndPutRecurseError!void { - const elem = (it.next() catch unreachable) orelse return; + ) ParseAndPutRecurseError!?*Set { + const elem = (it.next() catch unreachable) orelse + return error.UnexpectedEndOfInput; switch (elem) { .leader => |t| { // If we have a leader, we need to upsert a set for it. @@ -2177,9 +2210,11 @@ pub const Set = struct { error.SequenceUnbind => if (s.bindings.count() == 0) { set.remove(alloc, t); return error.SequenceUnbind; - }, + } else null, - error.OutOfMemory => return error.OutOfMemory, + error.UnexpectedEndOfInput, + error.OutOfMemory, + => err, }, .leaf, .leaf_chained => { @@ -2199,7 +2234,7 @@ pub const Set = struct { try set.bindings.put(alloc, t, .{ .leader = next }); // Recurse - parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { + return parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { // If our action was to unbind, we restore the old // action if we have it. error.SequenceUnbind => { @@ -2214,9 +2249,13 @@ pub const Set = struct { ) catch {}, .leaf_chained => @panic("TODO"), }; + + return null; }, - error.OutOfMemory => return error.OutOfMemory, + error.UnexpectedEndOfInput, + error.OutOfMemory, + => return err, }; }, @@ -2226,16 +2265,20 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => try set.putFlags( - alloc, - b.trigger, - b.action, - b.flags, - ), + else => { + try set.putFlags( + alloc, + b.trigger, + b.action, + b.flags, + ); + return set; + }, }, .chain => { // TODO: Do this, ignore for now. + return set; }, } } @@ -2267,7 +2310,21 @@ pub const Set = struct { // See the reverse map docs for more information. const track_reverse: bool = !flags.performable; + // No matter what our chained parent becomes invalid because + // getOrPut invalidates pointers. + self.chain_parent = null; + const gop = try self.bindings.getOrPut(alloc, t); + self.chain_parent = .{ + .key_ptr = gop.key_ptr, + .value_ptr = gop.value_ptr, + }; + errdefer { + // If we have any errors we can't trust our values here. And + // we can't restore the old values because they're also invalidated + // by getOrPut so we just disable chaining. + self.chain_parent = null; + } if (gop.found_existing) switch (gop.value_ptr.*) { // If we have a leader we need to clean up the memory @@ -2304,6 +2361,13 @@ pub const Set = struct { if (track_reverse) try self.reverse.put(alloc, action, t); errdefer if (track_reverse) self.reverse.remove(action); + + // Invariant: after successful put, chain_parent must be valid and point + // to the entry we just added/updated. + assert(self.chain_parent != null); + assert(self.chain_parent.?.key_ptr == gop.key_ptr); + assert(self.chain_parent.?.value_ptr == gop.value_ptr); + assert(self.chain_parent.?.value_ptr.* == .leaf); } /// Get a binding for a given trigger. @@ -2370,6 +2434,11 @@ pub const Set = struct { } fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { + // Removal always resets our chain parent. We could make this + // finer grained but the way it is documented is that chaining + // must happen directly after sets so this works. + self.chain_parent = null; + var entry = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); @@ -3097,6 +3166,15 @@ test "set: parseAndPut typical binding" { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key.unicode == 'a'); } + + // Sets up the chain parent properly + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } } test "set: parseAndPut unconsumed binding" { @@ -3121,6 +3199,15 @@ test "set: parseAndPut unconsumed binding" { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key.unicode == 'a'); } + + // Sets up the chain parent properly + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } } test "set: parseAndPut removed binding" { @@ -3139,6 +3226,206 @@ test "set: parseAndPut removed binding" { try testing.expect(s.get(trigger) == null); } try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); + + // Sets up the chain parent properly + try testing.expect(s.chain_parent == null); +} + +test "set: put sets chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // chain_parent should be set + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } + + // chain_parent value should be a leaf + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); +} + +test "set: putFlags sets chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + + // chain_parent should be set + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } + + // chain_parent value should be a leaf with correct flags + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); + try testing.expect(!s.chain_parent.?.value_ptr.*.leaf.flags.consumed); +} + +test "set: sequence sets chain_parent to final leaf" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + + // chain_parent should be set and point to 'b' (the final leaf) + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("b", buf.written()); + } + + // chain_parent value should be a leaf + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); + try testing.expect(s.chain_parent.?.value_ptr.*.leaf.action == .new_window); +} + +test "set: multiple leaves under leader updates chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + + // After first binding, chain_parent should be 'b' + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("b", buf.written()); + } + + try s.parseAndPut(alloc, "a>c=new_tab"); + + // After second binding, chain_parent should be updated to 'c' + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("c", buf.written()); + } + try testing.expect(s.chain_parent.?.value_ptr.*.leaf.action == .new_tab); +} + +test "set: sequence unbind clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try testing.expect(s.chain_parent != null); + + try s.parseAndPut(alloc, "a>b=unbind"); + + // After unbind, chain_parent should be cleared + try testing.expect(s.chain_parent == null); +} + +test "set: sequence unbind with remaining leaves clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>c=new_tab"); + try s.parseAndPut(alloc, "a>b=unbind"); + + // After unbind, chain_parent should be cleared even though 'c' remains + try testing.expect(s.chain_parent == null); + + // But 'c' should still exist + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(a_entry.value_ptr.* == .leader); + const inner_set = a_entry.value_ptr.*.leader; + try testing.expect(inner_set.get(.{ .key = .{ .unicode = 'c' } }) != null); +} + +test "set: direct remove clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.chain_parent != null); + + s.remove(alloc, .{ .key = .{ .unicode = 'a' } }); + + // After removal, chain_parent should be cleared + try testing.expect(s.chain_parent == null); +} + +test "set: invalid format preserves chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + const before_key = s.chain_parent.?.key_ptr; + const before_value = s.chain_parent.?.value_ptr; + + // Try an invalid parse - should fail + try testing.expectError(error.InvalidAction, s.parseAndPut(alloc, "a=invalid_action_xyz")); + + // chain_parent should be unchanged + try testing.expect(s.chain_parent != null); + try testing.expect(s.chain_parent.?.key_ptr == before_key); + try testing.expect(s.chain_parent.?.value_ptr == before_value); +} + +test "set: clone produces null chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try testing.expect(s.chain_parent != null); + + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + // Clone should have null chain_parent + try testing.expect(cloned.chain_parent == null); + + // But should have the binding + try testing.expect(cloned.get(.{ .key = .{ .unicode = 'a' } }) != null); } test "set: parseAndPut sequence" { From 4fdc52b920c03035ad3ce9aef042b602d2bd0a0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:32:52 -0800 Subject: [PATCH 230/605] input: appendChain --- src/input/Binding.zig | 123 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6c91415e4..a5bb44b4d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2370,6 +2370,52 @@ pub const Set = struct { assert(self.chain_parent.?.value_ptr.* == .leaf); } + /// Append a chained action to the prior set action. + /// + /// It is an error if there is no valid prior chain parent. + pub fn appendChain( + self: *Set, + alloc: Allocator, + action: Action, + ) (Allocator.Error || error{NoChainParent})!void { + const parent = self.chain_parent orelse return error.NoChainParent; + switch (parent.value_ptr.*) { + // Leader can never be a chain parent. Verified through various + // assertions and unit tests. + .leader => unreachable, + + // If it is already a chained action, we just append the + // action. Easy! + .leaf_chained => |*leaf| try leaf.actions.append( + alloc, + action, + ), + + // If it is a leaf, we need to convert it to a leaf_chained. + // We also need to be careful to remove any prior reverse + // mappings for this action since chained actions are not + // part of the reverse mapping. + .leaf => |leaf| { + // Setup our failable actions list first. + var actions: std.ArrayList(Action) = .empty; + try actions.ensureTotalCapacity(alloc, 2); + errdefer actions.deinit(alloc); + actions.appendAssumeCapacity(leaf.action); + actions.appendAssumeCapacity(action); + + // Clean up our reverse mapping. We only do this if + // we're the chain parent because only the root set + // maintains reverse mappings. + // TODO + + parent.value_ptr.* = .{ .leaf_chained = .{ + .actions = actions, + .flags = leaf.flags, + } }; + }, + } + } + /// Get a binding for a given trigger. pub fn get(self: Set, t: Trigger) ?Entry { return self.bindings.getEntry(t); @@ -4069,3 +4115,80 @@ test "action: format" { try a.format(&buf.writer); try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); } + +test "set: appendChain with no parent returns error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try testing.expectError(error.NoChainParent, s.appendChain(alloc, .{ .new_tab = {} })); +} + +test "set: appendChain after put converts to leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // First appendChain converts leaf to leaf_chained and appends the new action + try s.appendChain(alloc, .{ .new_tab = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: appendChain after putFlags preserves flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + try s.appendChain(alloc, .{ .new_tab = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expect(!chained.flags.consumed); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: appendChain multiple times" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try s.appendChain(alloc, .{ .new_tab = {} }); + try s.appendChain(alloc, .{ .close_surface = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + try testing.expect(chained.actions.items[2] == .close_surface); +} From a3373f3c6a3653df70c0bb447b14cf72a181e906 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:43:16 -0800 Subject: [PATCH 231/605] input: appendChain reverse mapping --- src/input/Binding.zig | 166 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 136 insertions(+), 30 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a5bb44b4d..b4f519379 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1944,8 +1944,21 @@ pub const Set = struct { reverse: ReverseMap = .{}, /// The chain parent is the information necessary to attach a chained - /// action to the proper location in our mapping. - chain_parent: ?HashMap.Entry = null, + /// action to the proper location in our mapping. It tracks both the + /// entry in the hashmap and the set it belongs to, which is needed + /// to properly update reverse mappings when converting a leaf to + /// a chained action. + chain_parent: ?ChainParent = null, + + /// Information about a chain parent entry, including which set it + /// belongs to. This is needed because reverse mappings are only + /// maintained in the root set, but the chain parent entry may be + /// in a nested set (for leader key sequences). + const ChainParent = struct { + key_ptr: *Trigger, + value_ptr: *Value, + set: *Set, + }; /// The entry type for the forward mapping of trigger to action. pub const Value = union(enum) { @@ -2318,6 +2331,7 @@ pub const Set = struct { self.chain_parent = .{ .key_ptr = gop.key_ptr, .value_ptr = gop.value_ptr, + .set = self, }; errdefer { // If we have any errors we can't trust our values here. And @@ -2403,15 +2417,22 @@ pub const Set = struct { actions.appendAssumeCapacity(leaf.action); actions.appendAssumeCapacity(action); - // Clean up our reverse mapping. We only do this if - // we're the chain parent because only the root set - // maintains reverse mappings. - // TODO - + // Convert to leaf_chained first, before fixing up reverse + // mapping. This is important because fixupReverseForAction + // searches for other bindings with the same action, and we + // don't want to find this entry (which is now chained). parent.value_ptr.* = .{ .leaf_chained = .{ .actions = actions, .flags = leaf.flags, } }; + + // Clean up our reverse mapping. Chained actions are not + // part of the reverse mapping, so we need to fix up the + // reverse map (possibly restoring another trigger for the + // same action). + if (!leaf.flags.performable) { + parent.set.fixupReverseForAction(leaf.action); + } }, } } @@ -2498,29 +2519,7 @@ pub const Set = struct { }, // For an action we need to fix up the reverse mapping. - // Note: we'd LIKE to replace this with the most recent binding but - // our hash map obviously has no concept of ordering so we have to - // choose whatever. Maybe a switch to an array hash map here. - .leaf => |leaf| { - const action_hash = leaf.action.hash(); - - var it = self.bindings.iterator(); - while (it.next()) |it_entry| { - switch (it_entry.value_ptr.*) { - .leader, .leaf_chained => {}, - .leaf => |leaf_search| { - if (leaf_search.action.hash() == action_hash) { - self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); - break; - } - }, - } - } else { - // No other trigger points to this action so we remove - // the reverse mapping completely. - _ = self.reverse.remove(leaf.action); - } - }, + .leaf => |leaf| self.fixupReverseForAction(leaf.action), // Chained leaves are never in our reverse mapping so no // cleanup is required. @@ -2530,6 +2529,38 @@ pub const Set = struct { } } + /// Fix up the reverse mapping after removing an action. + /// + /// When an action is removed from a binding (either by removal or by + /// converting to a chained action), we need to update the reverse mapping. + /// If another binding has the same action, we update the reverse mapping + /// to point to that binding. Otherwise, we remove the action from the + /// reverse mapping entirely. + /// + /// Note: we'd LIKE to replace this with the most recent binding but + /// our hash map obviously has no concept of ordering so we have to + /// choose whatever. Maybe a switch to an array hash map here. + fn fixupReverseForAction(self: *Set, action: Action) void { + const action_hash = action.hash(); + + var it = self.bindings.iterator(); + while (it.next()) |it_entry| { + switch (it_entry.value_ptr.*) { + .leader, .leaf_chained => {}, + .leaf => |leaf_search| { + if (leaf_search.action.hash() == action_hash) { + self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + return; + } + }, + } + } + + // No other trigger points to this action so we remove + // the reverse mapping completely. + _ = self.reverse.remove(action); + } + /// Deep clone the set. pub fn clone(self: *const Set, alloc: Allocator) !Set { var result: Set = .{ @@ -4192,3 +4223,78 @@ test "set: appendChain multiple times" { try testing.expect(chained.actions.items[1] == .new_tab); try testing.expect(chained.actions.items[2] == .close_surface); } + +test "set: appendChain removes reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // Verify reverse mapping exists before chaining + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); + + // Chaining should remove the reverse mapping + try s.appendChain(alloc, .{ .new_tab = {} }); + + // Reverse mapping should be gone since chained actions are not in reverse map + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); +} + +test "set: appendChain with performable does not affect reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Add a non-performable binding first + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); + + // Add a performable binding (not in reverse map) and chain it + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .close_surface = {} }, + .{ .performable = true }, + ); + + // close_surface was performable, so not in reverse map + try testing.expect(s.getTrigger(.{ .close_surface = {} }) == null); + + // Chaining the performable binding should not crash or affect anything + try s.appendChain(alloc, .{ .new_tab = {} }); + + // The non-performable new_window binding should still be in reverse map + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); +} + +test "set: appendChain restores next valid reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Add two bindings for the same action + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + + // Reverse mapping should point to 'b' (most recent) + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'b'); + } + + // Chain an action to 'b', which should restore 'a' in the reverse map + try s.appendChain(alloc, .{ .new_tab = {} }); + + // Now reverse mapping should point to 'a' + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} From 67be309e3f43ab91c759368d3d7d4fd396182b94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:50:39 -0800 Subject: [PATCH 232/605] input: Trigger.eql --- src/input/Binding.zig | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b4f519379..0d52f23cd 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1870,6 +1870,19 @@ pub const Trigger = struct { return array; } + /// Returns true if two triggers are equal. + pub fn eql(self: Trigger, other: Trigger) bool { + if (self.mods != other.mods) return false; + const self_tag = std.meta.activeTag(self.key); + const other_tag = std.meta.activeTag(other.key); + if (self_tag != other_tag) return false; + return switch (self.key) { + .physical => |v| v == other.key.physical, + .unicode => |v| v == other.key.unicode, + .catch_all => true, + }; + } + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ @@ -2974,6 +2987,66 @@ test "parse: all triggers" { } } +test "Trigger: eql" { + const testing = std.testing; + + // Equal physical keys + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + try testing.expect(t1.eql(t2)); + } + + // Different physical keys + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true } }; + try testing.expect(!t1.eql(t2)); + } + + // Different mods + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }; + try testing.expect(!t1.eql(t2)); + } + + // Equal unicode keys + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + try testing.expect(t1.eql(t2)); + } + + // Different unicode keys + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .unicode = 'b' }, .mods = .{} }; + try testing.expect(!t1.eql(t2)); + } + + // Different key types + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .physical = .key_a }, .mods = .{} }; + try testing.expect(!t1.eql(t2)); + } + + // catch_all + { + const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; + const t2: Trigger = .{ .key = .catch_all, .mods = .{} }; + try testing.expect(t1.eql(t2)); + } + + // catch_all with different mods + { + const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; + const t2: Trigger = .{ .key = .catch_all, .mods = .{ .alt = true } }; + try testing.expect(!t1.eql(t2)); + } +} + test "parse: modifier aliases" { const testing = std.testing; From 9bf1b9ac711252782aa73d166173570c8fedd44d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:54:05 -0800 Subject: [PATCH 233/605] input: cleaner reverse mapping cleanup --- src/input/Binding.zig | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0d52f23cd..2594cdbc5 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2443,9 +2443,10 @@ pub const Set = struct { // part of the reverse mapping, so we need to fix up the // reverse map (possibly restoring another trigger for the // same action). - if (!leaf.flags.performable) { - parent.set.fixupReverseForAction(leaf.action); - } + parent.set.fixupReverseForAction( + leaf.action, + parent.key_ptr.*, + ); }, } } @@ -2532,7 +2533,10 @@ pub const Set = struct { }, // For an action we need to fix up the reverse mapping. - .leaf => |leaf| self.fixupReverseForAction(leaf.action), + .leaf => |leaf| self.fixupReverseForAction( + leaf.action, + t, + ), // Chained leaves are never in our reverse mapping so no // cleanup is required. @@ -2550,19 +2554,35 @@ pub const Set = struct { /// to point to that binding. Otherwise, we remove the action from the /// reverse mapping entirely. /// + /// The `old` parameter is the trigger that was previously bound to this + /// action. It is used to check if the reverse mapping still points to + /// this trigger; if not, no fixup is needed since the reverse map already + /// points to a different trigger for this action. + /// /// Note: we'd LIKE to replace this with the most recent binding but /// our hash map obviously has no concept of ordering so we have to /// choose whatever. Maybe a switch to an array hash map here. - fn fixupReverseForAction(self: *Set, action: Action) void { - const action_hash = action.hash(); + fn fixupReverseForAction( + self: *Set, + action: Action, + old: Trigger, + ) void { + const entry = self.reverse.getEntry(action) orelse return; + // If our value is not the same as the old trigger, we can + // ignore it because our reverse mapping points somewhere else. + if (!entry.value_ptr.eql(old)) return; + + // It is the same trigger, so let's now go through our bindings + // and try to find another trigger that maps to the same action. + const action_hash = action.hash(); var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { .leader, .leaf_chained => {}, .leaf => |leaf_search| { if (leaf_search.action.hash() == action_hash) { - self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + entry.value_ptr.* = it_entry.key_ptr.*; return; } }, From b8fe66a70162712d845b7ff9bd5c989d629fde2c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:58:04 -0800 Subject: [PATCH 234/605] input: parseAndPut handles chains --- src/input/Binding.zig | 189 +++++++++++++++++++++++++++++++++++------- 1 file changed, 161 insertions(+), 28 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2594cdbc5..66ebb49a2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2166,6 +2166,7 @@ pub const Set = struct { // We use recursion so that we can utilize the stack as our state // for cleanup. const updated_set_ = self.parseAndPutRecurse( + self, alloc, &it, ) catch |err| err: { @@ -2178,6 +2179,12 @@ pub const Set = struct { // is invalid because we handle all valid cases. error.UnexpectedEndOfInput => return error.InvalidFormat, + // If we had a chain without a parent then the format is wrong. + error.NoChainParent => return error.InvalidFormat, + + // If we had an invalid action for a chain (e.g. unbind). + error.InvalidChainAction => return error.InvalidFormat, + // Unrecoverable error.OutOfMemory => return error.OutOfMemory, } @@ -2202,12 +2209,15 @@ pub const Set = struct { const ParseAndPutRecurseError = Allocator.Error || error{ SequenceUnbind, + NoChainParent, UnexpectedEndOfInput, + InvalidChainAction, }; /// Returns the set that was ultimately updated if a binding was /// added. Unbind does not return a set since nothing was added. fn parseAndPutRecurse( + root: *Set, set: *Set, alloc: Allocator, it: *Parser, @@ -2225,7 +2235,7 @@ pub const Set = struct { if (old) |entry| switch (entry) { // We have an existing leader for this key already // so recurse into this set. - .leader => |s| return parseAndPutRecurse( + .leader => |s| return root.parseAndPutRecurse( s, alloc, it, @@ -2238,7 +2248,9 @@ pub const Set = struct { return error.SequenceUnbind; } else null, + error.NoChainParent, error.UnexpectedEndOfInput, + error.InvalidChainAction, error.OutOfMemory, => err, }, @@ -2260,7 +2272,7 @@ pub const Set = struct { try set.bindings.put(alloc, t, .{ .leader = next }); // Recurse - return parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { + return root.parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { // If our action was to unbind, we restore the old // action if we have it. error.SequenceUnbind => { @@ -2279,7 +2291,9 @@ pub const Set = struct { return null; }, + error.NoChainParent, error.UnexpectedEndOfInput, + error.InvalidChainAction, error.OutOfMemory, => return err, }; @@ -2302,8 +2316,12 @@ pub const Set = struct { }, }, - .chain => { - // TODO: Do this, ignore for now. + .chain => |action| { + // Chains can only happen on the root. + assert(set == root); + // Unbind is not valid for chains. + if (action == .unbind) return error.InvalidChainAction; + try set.appendChain(alloc, action); return set; }, } @@ -2405,6 +2423,9 @@ pub const Set = struct { alloc: Allocator, action: Action, ) (Allocator.Error || error{NoChainParent})!void { + // Unbind is not a valid chain action; callers must check this. + assert(action != .unbind); + const parent = self.chain_parent orelse return error.NoChainParent; switch (parent.value_ptr.*) { // Leader can never be a chain parent. Verified through various @@ -3879,30 +3900,142 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } -// test "set: parseAndPut chain" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var s: Set = .{}; -// defer s.deinit(alloc); -// -// try s.parseAndPut(alloc, "a=new_window"); -// try s.parseAndPut(alloc, "chain=new_tab"); -// -// // Creates forward mapping -// { -// const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; -// try testing.expect(action.action == .new_window); -// try testing.expectEqual(Flags{}, action.flags); -// } -// -// // Does not create reverse mapping, because reverse mappings are only for -// // non-chain actions. -// { -// const trigger = s.getTrigger(.new_window); -// try testing.expect(trigger == null); -// } -// } +test "set: parseAndPut chain" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Creates forward mapping as leaf_chained + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + } + + // Does not create reverse mapping, because reverse mappings are only for + // non-chained actions. + { + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); + } +} + +test "set: parseAndPut chain without parent is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Chain without a prior binding should fail + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=new_tab")); +} + +test "set: parseAndPut chain multiple times" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + // Should have 3 actions chained + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + try testing.expect(chained.actions.items[2] == .close_surface); + } +} + +test "set: parseAndPut chain preserves flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "unconsumed:a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Should preserve unconsumed flag + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expect(!chained.flags.consumed); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + } +} + +test "set: parseAndPut chain after unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Chain after unbind should fail because chain_parent is cleared + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=new_tab")); +} + +test "set: parseAndPut chain on sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Navigate to the inner set + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(a_entry == .leader); + const inner_set = a_entry.leader; + + // Check the chained binding + const b_entry = inner_set.get(.{ .key = .{ .unicode = 'b' } }).?.value_ptr.*; + try testing.expect(b_entry == .leaf_chained); + const chained = b_entry.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: parseAndPut chain with unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + + // chain=unbind is not valid + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=unbind")); + + // Original binding should still exist + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expect(entry.leaf.action == .new_window); +} test "set: getEvent physical" { const testing = std.testing; From 442146cf9f8a60f4b4d127199b162df662588971 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:13:34 -0800 Subject: [PATCH 235/605] input: implement leaf_chained clone --- src/input/Binding.zig | 75 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 66ebb49a2..85c3c2942 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2093,6 +2093,21 @@ pub const Set = struct { actions: std.ArrayList(Action), flags: Flags, + pub fn clone( + self: LeafChained, + alloc: Allocator, + ) Allocator.Error!LeafChained { + var cloned_actions = try self.actions.clone(alloc); + errdefer cloned_actions.deinit(alloc); + for (cloned_actions.items) |*action| { + action.* = try action.clone(alloc); + } + return .{ + .actions = cloned_actions, + .flags = self.flags, + }; + } + pub fn deinit(self: *LeafChained, alloc: Allocator) void { self.actions.deinit(alloc); } @@ -2630,7 +2645,7 @@ pub const Set = struct { // contain allocated strings). .leaf => |*s| s.* = try s.clone(alloc), - .leaf_chained => @panic("TODO"), + .leaf_chained => |*s| s.* = try s.clone(alloc), // Must be deep cloned. .leader => |*s| { @@ -3619,6 +3634,64 @@ test "set: clone produces null chain_parent" { try testing.expect(cloned.get(.{ .key = .{ .unicode = 'a' } }) != null); } +test "set: clone with leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding using parseAndPut with chain= + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Verify we have a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 2), entry.value_ptr.leaf_chained.actions.items.len); + + // Clone the set + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + // Verify the cloned set has the leaf_chained with same actions + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(cloned_entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 2), cloned_entry.value_ptr.leaf_chained.actions.items.len); + try testing.expect(cloned_entry.value_ptr.leaf_chained.actions.items[0] == .new_window); + try testing.expect(cloned_entry.value_ptr.leaf_chained.actions.items[1] == .new_tab); +} + +test "set: clone with leaf_chained containing allocated data" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var s: Set = .{}; + + // Create a chained binding with text actions (which have allocated strings) + try s.parseAndPut(alloc, "a=text:hello"); + try s.parseAndPut(alloc, "chain=text:world"); + + // Verify we have a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + // Clone the set + const cloned = try s.clone(alloc); + + // Verify the cloned set has independent copies of the text + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(cloned_entry.value_ptr.* == .leaf_chained); + try testing.expectEqualStrings("hello", cloned_entry.value_ptr.leaf_chained.actions.items[0].text); + try testing.expectEqualStrings("world", cloned_entry.value_ptr.leaf_chained.actions.items[1].text); + + // Verify the pointers are different (truly cloned, not shared) + try testing.expect(entry.value_ptr.leaf_chained.actions.items[0].text.ptr != + cloned_entry.value_ptr.leaf_chained.actions.items[0].text.ptr); +} + test "set: parseAndPut sequence" { const testing = std.testing; const alloc = testing.allocator; From 578b4c284b80fed9e07e121ada4e2515d17c8f58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:19:09 -0800 Subject: [PATCH 236/605] apprt/gtk: handle global actions with chains --- src/apprt/gtk/class/global_shortcuts.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index 57652916a..718b371fd 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -169,13 +169,16 @@ pub const GlobalShortcuts = extern struct { var trigger_buf: [1024]u8 = undefined; var it = config.keybind.set.bindings.iterator(); while (it.next()) |entry| { - const leaf = switch (entry.value_ptr.*) { - // Global shortcuts can't have leaders + const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => continue, - .leaf => |leaf| leaf, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), }; if (!leaf.flags.global) continue; + // We only allow global keybinds that map to exactly a single + // action for now. TODO: remove this restriction + if (leaf.actions.len != 1) continue; + const trigger = if (key.xdgShortcutFromTrigger( &trigger_buf, entry.key_ptr.*, @@ -197,7 +200,7 @@ pub const GlobalShortcuts = extern struct { try priv.map.put( alloc, try alloc.dupeZ(u8, trigger), - leaf.action, + leaf.actions[0], ); } From e4c7d4e059eb46fc98e63f2c7c9291e6354a6463 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:22:38 -0800 Subject: [PATCH 237/605] input: handle unbind cleanup for leaf chains --- src/input/Binding.zig | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 85c3c2942..b02d67019 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2300,7 +2300,25 @@ pub const Set = struct { leaf.action, leaf.flags, ) catch {}, - .leaf_chained => @panic("TODO"), + + .leaf_chained => |leaf| chain: { + // Rebuild our chain + set.putFlags( + alloc, + t, + leaf.actions.items[0], + leaf.flags, + ) catch break :chain; + for (leaf.actions.items[1..]) |action| { + set.appendChain( + alloc, + action, + ) catch { + set.remove(alloc, t); + break :chain; + }; + } + }, }; return null; From 7dd903588b5ddcab6c67256b6590b869b71af9a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:27:40 -0800 Subject: [PATCH 238/605] input: formatter for chained entries --- src/input/Binding.zig | 117 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b02d67019..83c6ef38f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2052,7 +2052,19 @@ pub const Set = struct { try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); }, - .leaf_chained => @panic("TODO"), + .leaf_chained => |leaf| { + const pos = buffer.end; + for (leaf.actions.items, 0..) |action, i| { + if (i == 0) { + buffer.print("={f}", .{action}) catch return error.OutOfMemory; + } else { + buffer.end = 0; + buffer.print("chain={f}", .{action}) catch return error.OutOfMemory; + } + try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + buffer.end = pos; + } + }, } } }; @@ -4615,3 +4627,106 @@ test "set: appendChain restores next valid reverse mapping" { try testing.expect(trigger.key.unicode == 'a'); } } + +test "set: formatEntries leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Verify it's a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + // Write the trigger first (as formatEntry in Config.zig does) + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=new_window + \\keybind = chain=new_tab + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries leaf_chained multiple chains" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding with 3 actions + try s.parseAndPut(alloc, "ctrl+a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + // Verify it's a leaf_chained with 3 actions + const entry = s.get(.{ .key = .{ .unicode = 'a' }, .mods = .{ .ctrl = true } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 3), entry.value_ptr.leaf_chained.actions.items.len); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = ctrl+a=new_window + \\keybind = chain=new_tab + \\keybind = chain=close_surface + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries leaf_chained with text action" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding with text actions + try s.parseAndPut(alloc, "a=text:hello"); + try s.parseAndPut(alloc, "chain=text:world"); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=text:hello + \\keybind = chain=text:world + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} From 99325a3d451bdf5b3bb64b2569633f6f16f2e3c8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:32:12 -0800 Subject: [PATCH 239/605] config: docs for chains --- src/config/Config.zig | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index f75944aeb..a2ce88320 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1667,6 +1667,44 @@ class: ?[:0]const u8 = null, /// - 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)). /// +/// ## Chained Actions +/// +/// A keybind can have multiple actions by using the `chain` keyword for +/// subsequent actions. When a keybind is activated, all chained actions are +/// executed in order. The syntax is: +/// +/// ```ini +/// keybind = ctrl+a=new_window +/// keybind = chain=goto_split:left +/// ``` +/// +/// This binds `ctrl+a` to first open a new window, then move focus to the +/// left split. Each `chain` entry appends an action to the most recently +/// defined keybind. You can chain as many actions as you want: +/// +/// ```ini +/// keybind = ctrl+a=new_window +/// keybind = chain=goto_split:left +/// keybind = chain=toggle_fullscreen +/// ``` +/// +/// Chained actions cannot have prefixes like `global:` or `unconsumed:`. +/// The flags from the original keybind apply to the entire chain. +/// +/// Chained actions work with key sequences as well. For example: +/// +/// ```ini +/// keybind = ctrl+a>n=new_window +/// keybind = chain=goto_split:left +/// ```` +/// +/// Chains with key sequences apply to the most recent binding in the +/// sequence. +/// +/// Chained keybinds are available since Ghostty 1.3.0. +/// +/// ## Key Tables +/// /// You may also create a named set of keybindings known as a "key table." /// A key table must be explicitly activated for the bindings to become /// available. This can be used to implement features such as a From 931c6c71f2f522f1ac19563035879a75cdbc12cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:38:46 -0800 Subject: [PATCH 240/605] fix up gtk --- src/apprt/gtk/class/global_shortcuts.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index 718b371fd..cf0f31a6e 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -177,7 +177,8 @@ pub const GlobalShortcuts = extern struct { // We only allow global keybinds that map to exactly a single // action for now. TODO: remove this restriction - if (leaf.actions.len != 1) continue; + const actions = leaf.actionsSlice(); + if (actions.len != 1) continue; const trigger = if (key.xdgShortcutFromTrigger( &trigger_buf, @@ -200,7 +201,7 @@ pub const GlobalShortcuts = extern struct { try priv.map.put( alloc, try alloc.dupeZ(u8, trigger), - leaf.actions[0], + actions[0], ); } From 76c0bdf559f466c56803e9b2f72a0790cbad07ad Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 22 Dec 2025 17:48:04 -0600 Subject: [PATCH 241/605] input: fix performable bindings --- src/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0ce758636..0fb0c034b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2941,8 +2941,8 @@ fn maybeHandleBinding( // actions perform. var performed: bool = false; for (actions) |action| { - if (self.performBindingAction(action)) |_| { - performed = true; + if (self.performBindingAction(action)) |performed_| { + performed = performed or performed_; } else |err| { log.info( "key binding action failed action={t} err={}", From dcbb3fe56fbc67ed5257fd4233bedfd69e5e1a3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 20:34:23 -0800 Subject: [PATCH 242/605] inspector: show chained bindings --- src/Surface.zig | 18 ++++++++++++++---- src/inspector/key.zig | 24 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0fb0c034b..ea17c6104 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2941,8 +2941,8 @@ fn maybeHandleBinding( // actions perform. var performed: bool = false; for (actions) |action| { - if (self.performBindingAction(action)) |performed_| { - performed = performed or performed_; + if (self.performBindingAction(action)) |v| { + performed = performed or v; } else |err| { log.info( "key binding action failed action={t} err={}", @@ -2991,8 +2991,18 @@ fn maybeHandleBinding( // Store our last trigger so we don't encode the release event self.keyboard.last_trigger = event.bindingHash(); - // TODO: Inspector must support chained events - if (insp_ev) |ev| ev.binding = actions[0]; + if (insp_ev) |ev| { + ev.binding = self.alloc.dupe( + input.Binding.Action, + actions, + ) catch |err| binding: { + log.warn( + "error allocating binding action for inspector err={}", + .{err}, + ); + break :binding &.{}; + }; + } return .consumed; } diff --git a/src/inspector/key.zig b/src/inspector/key.zig index dbccb47a8..e42e4f23c 100644 --- a/src/inspector/key.zig +++ b/src/inspector/key.zig @@ -13,7 +13,8 @@ pub const Event = struct { event: input.KeyEvent, /// The binding that was triggered as a result of this event. - binding: ?input.Binding.Action = null, + /// Multiple bindings are possible if they are chained. + binding: []const input.Binding.Action = &.{}, /// The data sent to the pty as a result of this keyboard event. /// This is allocated using the inspector allocator. @@ -32,6 +33,7 @@ pub const Event = struct { } pub fn deinit(self: *const Event, alloc: Allocator) void { + alloc.free(self.binding); if (self.event.utf8.len > 0) alloc.free(self.event.utf8); if (self.pty.len > 0) alloc.free(self.pty); } @@ -79,12 +81,28 @@ pub const Event = struct { ); defer cimgui.c.igEndTable(); - if (self.binding) |binding| { + if (self.binding.len > 0) { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); _ = cimgui.c.igTableSetColumnIndex(0); cimgui.c.igText("Triggered Binding"); _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(binding).ptr); + + const height: f32 = height: { + const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); + const padding = cimgui.c.igGetStyle().*.FramePadding.y * 2; + break :height cimgui.c.igGetTextLineHeightWithSpacing() * item_count + padding; + }; + if (cimgui.c.igBeginListBox("##bindings", .{ .x = 0, .y = height })) { + defer cimgui.c.igEndListBox(); + for (self.binding) |action| { + _ = cimgui.c.igSelectable_Bool( + @tagName(action).ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } } pty: { From c11febd0dd8d31e20c70227e0e1af6e212181b1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 20:42:50 -0800 Subject: [PATCH 243/605] cli/list-keybinds: support chained keybindings --- src/cli/list_keybinds.zig | 54 +++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index fb7ad19ec..2fb900e48 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -96,7 +96,7 @@ const TriggerNode = struct { const ChordBinding = struct { triggers: std.SinglyLinkedList, - action: Binding.Action, + actions: []const Binding.Action, // Order keybinds based on various properties // 1. Longest chord sequence @@ -281,16 +281,32 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { } } - const action = try std.fmt.allocPrint(alloc, "{f}", .{bind.action}); - // If our action has an argument, we print the argument in a different color - if (std.mem.indexOfScalar(u8, action, ':')) |idx| { - _ = win.print(&.{ - .{ .text = action[0..idx] }, - .{ .text = action[idx .. idx + 1], .style = .{ .dim = true } }, - .{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } }, - }, .{ .col_offset = widest_chord + 3 }); - } else { - _ = win.printSegment(.{ .text = action }, .{ .col_offset = widest_chord + 3 }); + var action_col: u16 = widest_chord + 3; + for (bind.actions, 0..) |act, i| { + if (i > 0) { + const chain_result = win.printSegment( + .{ .text = ", ", .style = .{ .dim = true } }, + .{ .col_offset = action_col }, + ); + action_col = chain_result.col; + } + + const action = try std.fmt.allocPrint(alloc, "{f}", .{act}); + // If our action has an argument, we print the argument in a different color + if (std.mem.indexOfScalar(u8, action, ':')) |idx| { + const print_result = win.print(&.{ + .{ .text = action[0..idx] }, + .{ .text = action[idx .. idx + 1], .style = .{ .dim = true } }, + .{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } }, + }, .{ .col_offset = action_col }); + action_col = print_result.col; + } else { + const print_result = win.printSegment( + .{ .text = action }, + .{ .col_offset = action_col }, + ); + action_col = print_result.col; + } } try vx.prettyPrint(writer); } @@ -346,14 +362,24 @@ fn iterateBindings( const node = try alloc.create(TriggerNode); node.* = .{ .data = bind.key_ptr.* }; + const actions = try alloc.alloc(Binding.Action, 1); + actions[0] = leaf.action; + widest_chord = @max(widest_chord, width); try bindings.append(alloc, .{ .triggers = .{ .first = &node.node }, - .action = leaf.action, + .actions = actions, }); }, - .leaf_chained => { - // TODO: Show these. + .leaf_chained => |leaf| { + const node = try alloc.create(TriggerNode); + node.* = .{ .data = bind.key_ptr.* }; + + widest_chord = @max(widest_chord, width); + try bindings.append(alloc, .{ + .triggers = .{ .first = &node.node }, + .actions = leaf.actions.items, + }); }, } } From 56f5a14dde230907e069b15955aacefd5a85a354 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 20:57:06 -0800 Subject: [PATCH 244/605] config: improve key table parsing robustness Fixes #10020 This improves parsing key tables so that the following edge cases are now handled correctly, which were regressions from prior tip behavior: - `/=action` - `ctrl+/=action` - `table//=action` (valid to bind `/` in a table) - `table/a>//=action` (valid to bind a table with a sequence) --- src/config/Config.zig | 122 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 6 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 8f1cece45..52141293a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1676,8 +1676,10 @@ class: ?[:0]const u8 = null, /// Key tables are defined using the syntax `
/`. The /// `` value is everything documented above for keybinds. The /// `
` value is the name of the key table. Table names can contain -/// anything except `/` and `=`. For example `foo/ctrl+a=new_window` -/// defines a binding within a table named `foo`. +/// anything except `/`, `=`, `+`, and `>`. The characters `+` and `>` are +/// reserved for keybind syntax (modifier combinations and key sequences). +/// For example `foo/ctrl+a=new_window` defines a binding within a table +/// named `foo`. /// /// Tables are activated and deactivated using the binding actions /// `activate_key_table:` and `deactivate_key_table`. Other table @@ -6644,12 +6646,21 @@ pub const Keybinds = struct { // We look for '/' only before the first '=' to avoid matching // action arguments like "foo=text:/hello". const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len; - if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| { + if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| table: { const table_name = value[0..slash_idx]; - const binding = value[slash_idx + 1 ..]; - // Table name cannot be empty - if (table_name.len == 0) return error.InvalidFormat; + // Length zero is valid, so you can set `/=action` for the slash key + if (table_name.len == 0) break :table; + + // Ignore '+', '>' because they can be part of sequences and + // triggers. This lets things like `ctrl+/=action` work. + if (std.mem.indexOfAny( + u8, + table_name, + "+>", + ) != null) break :table; + + const binding = value[slash_idx + 1 ..]; // Get or create the table const gop = try self.tables.getOrPut(alloc, table_name); @@ -7002,6 +7013,105 @@ pub const Keybinds = struct { try testing.expectEqual(0, keybinds.tables.count()); } + test "parseCLI slash as key with modifier is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // ctrl+/ should be parsed as a keybind with '/' as the key, not a table + try keybinds.parseCLI(alloc, "ctrl+/=text:foo"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI shift+slash as key is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // shift+/ should be parsed as a keybind, not a table + try keybinds.parseCLI(alloc, "shift+/=ignore"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI bare slash as key is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Bare / as a key should work (empty table name is rejected) + try keybinds.parseCLI(alloc, "/=text:foo"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI slash in key sequence is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Key sequence ending with / should work + try keybinds.parseCLI(alloc, "ctrl+a>ctrl+/=new_window"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI table with slash in binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Table with a binding that uses / as the key + try keybinds.parseCLI(alloc, "mytable//=text:foo"); + + // Should be in the table + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("mytable")); + try testing.expectEqual(1, keybinds.tables.get("mytable").?.bindings.count()); + } + + test "parseCLI table with sequence containing slash" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Table with a key sequence that ends with / + try keybinds.parseCLI(alloc, "mytable/a>/=new_window"); + + // Should be in the table + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("mytable")); + } + test "clone with tables" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); From 6720076c952123e96578f1eec93111badd2a1b8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 21:18:59 -0800 Subject: [PATCH 245/605] ci: update macOS builds to use Xcode 26.2 We were fixed on 26.0 previously. --- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 6 +++--- .github/workflows/test.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 748965513..960ff4efe 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -143,7 +143,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index fb6aef87d..3af59e7a5 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -232,7 +232,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -466,7 +466,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -650,7 +650,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30f34120a..b91555f2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -456,7 +456,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -499,7 +499,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -764,7 +764,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version From 12815f7fa32460c6168d65209f8da7a252a855b2 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Tue, 23 Dec 2025 08:19:45 -0600 Subject: [PATCH 246/605] feat(cli): list keybindings from key tables - Display keybindings grouped by their source table, with table name as prefix - Sort default bindings before table bindings, maintaining visual hierarchy - Support keybindings defined in key tables alongside default bindings - Enable users to discover all available keybindings across the entire config --- src/cli/list_keybinds.zig | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 2fb900e48..61050d0cb 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -95,18 +95,35 @@ const TriggerNode = struct { }; const ChordBinding = struct { + table_name: ?[]const u8 = null, triggers: std.SinglyLinkedList, actions: []const Binding.Action, // Order keybinds based on various properties - // 1. Longest chord sequence - // 2. Most active modifiers - // 3. Alphabetically by active modifiers - // 4. Trigger key order + // 1. Default bindings before table bindings (tables grouped at end) + // 2. Longest chord sequence + // 3. Most active modifiers + // 4. Alphabetically by active modifiers + // 5. Trigger key order + // 6. Within tables, sort by table name // These properties propagate through chorded keypresses // // Adapted from Binding.lessThan pub fn lessThan(_: void, lhs: ChordBinding, rhs: ChordBinding) bool { + const lhs_has_table = lhs.table_name != null; + const rhs_has_table = rhs.table_name != null; + + if (lhs_has_table != rhs_has_table) { + return !lhs_has_table; + } + + if (lhs_has_table) { + const table_cmp = std.mem.order(u8, lhs.table_name.?, rhs.table_name.?); + if (table_cmp != .eq) { + return table_cmp == .lt; + } + } + const lhs_len = lhs.triggers.len(); const rhs_len = rhs.triggers.len(); @@ -231,10 +248,30 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const win = vx.window(); - // Generate a list of bindings, recursively traversing chorded keybindings + // Collect default bindings, recursively flattening chords var iter = keybinds.set.bindings.iterator(); - const bindings, const widest_chord = try iterateBindings(alloc, &iter, &win); + const default_bindings, var widest_chord = try iterateBindings(alloc, &iter, &win); + var bindings_list: std.ArrayList(ChordBinding) = .empty; + try bindings_list.appendSlice(alloc, default_bindings); + + // Collect key table bindings + var widest_table_prefix: u16 = 0; + var table_iter = keybinds.tables.iterator(); + while (table_iter.next()) |table_entry| { + const table_name = table_entry.key_ptr.*; + var binding_iter = table_entry.value_ptr.bindings.iterator(); + const table_bindings, const table_width = try iterateBindings(alloc, &binding_iter, &win); + for (table_bindings) |*b| { + b.table_name = table_name; + } + + try bindings_list.appendSlice(alloc, table_bindings); + widest_chord = @max(widest_chord, table_width); + widest_table_prefix = @max(widest_table_prefix, @as(u16, @intCast(win.gwidth(table_name) + win.gwidth("/")))); + } + + const bindings = bindings_list.items; std.mem.sort(ChordBinding, bindings, {}, ChordBinding.lessThan); // Set up styles for each modifier @@ -242,12 +279,22 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const ctrl_style: vaxis.Style = .{ .fg = .{ .index = 2 } }; const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } }; const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } }; + const table_style: vaxis.Style = .{ .fg = .{ .index = 8 } }; // Print the list for (bindings) |bind| { win.clear(); var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false }; + + if (bind.table_name) |name| { + result = win.printSegment( + .{ .text = name, .style = table_style }, + .{ .col_offset = result.col }, + ); + result = win.printSegment(.{ .text = "/", .style = table_style }, .{ .col_offset = result.col }); + } + var maybe_trigger = bind.triggers.first; while (maybe_trigger) |node| : (maybe_trigger = node.next) { const trigger: *TriggerNode = .get(node); @@ -281,7 +328,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { } } - var action_col: u16 = widest_chord + 3; + var action_col: u16 = widest_table_prefix + widest_chord + 3; for (bind.actions, 0..) |act, i| { if (i > 0) { const chain_result = win.printSegment( From a1ee2f07648aea90a9df3d50c0a5341f1624d741 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 09:06:46 -0800 Subject: [PATCH 247/605] apprt/gtk: store key sequences/tables in surface state --- src/apprt/gtk.zig | 1 + src/apprt/gtk/class/application.zig | 35 +++++++- src/apprt/gtk/class/surface.zig | 72 ++++++++++++++++ src/apprt/gtk/key.zig | 122 ++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 415d3773d..07b4eb0e7 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -10,4 +10,5 @@ pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef; test { @import("std").testing.refAllDecls(@This()); _ = @import("gtk/ext.zig"); + _ = @import("gtk/key.zig"); } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 1c0863f3c..b16bce049 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -669,6 +669,9 @@ pub const Application = extern struct { .inspector => return Action.controlInspector(target, value), + .key_sequence => return Action.keySequence(target, value), + .key_table => return Action.keyTable(target, value), + .mouse_over_link => Action.mouseOverLink(target, value), .mouse_shape => Action.mouseShape(target, value), .mouse_visibility => Action.mouseVisibility(target, value), @@ -743,8 +746,6 @@ pub const Application = extern struct { .toggle_visibility, .toggle_background_opacity, .cell_size, - .key_sequence, - .key_table, .render_inspector, .renderer_health, .color_change, @@ -2660,6 +2661,36 @@ const Action = struct { }, } } + + pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool { + switch (target) { + .app => { + log.warn("key_sequence action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keySequenceAction(value) catch |err| { + log.warn("error handling key_sequence action: {}", .{err}); + }; + return true; + }, + } + } + + pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool { + switch (target) { + .app => { + log.warn("key_table action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keyTableAction(value) catch |err| { + log.warn("error handling key_table action: {}", .{err}); + }; + return true; + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 93d1beeb2..c35c78302 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -617,6 +617,10 @@ pub const Surface = extern struct { vscroll_policy: gtk.ScrollablePolicy = .natural, vadj_signal_group: ?*gobject.SignalGroup = null, + // Key state tracking for key sequences and tables + key_sequence: std.ArrayListUnmanaged([:0]const u8) = .empty, + key_tables: std.ArrayListUnmanaged([:0]const u8) = .empty, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -778,6 +782,66 @@ pub const Surface = extern struct { if (priv.inspector) |v| v.queueRender(); } + /// Handle a key sequence action from the apprt. + pub fn keySequenceAction( + self: *Self, + value: apprt.action.KeySequence, + ) Allocator.Error!void { + const priv = self.private(); + const alloc = Application.default().allocator(); + + switch (value) { + .trigger => |trigger| { + // Convert the trigger to a human-readable label + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + if (gtk_key.labelFromTrigger(&buf.writer, trigger)) |success| { + if (!success) return; + } else |_| return error.OutOfMemory; + + // Make space + try priv.key_sequence.ensureUnusedCapacity(alloc, 1); + + // Copy and append + const duped = try buf.toOwnedSliceSentinel(0); + errdefer alloc.free(duped); + priv.key_sequence.appendAssumeCapacity(duped); + }, + .end => { + // Free all the stored strings and clear + for (priv.key_sequence.items) |s| alloc.free(s); + priv.key_sequence.clearAndFree(alloc); + }, + } + } + + /// Handle a key table action from the apprt. + pub fn keyTableAction( + self: *Self, + value: apprt.action.KeyTable, + ) Allocator.Error!void { + const priv = self.private(); + const alloc = Application.default().allocator(); + + switch (value) { + .activate => |name| { + // Duplicate the name string and push onto stack + const duped = try alloc.dupeZ(u8, name); + errdefer alloc.free(duped); + try priv.key_tables.append(alloc, duped); + }, + .deactivate => { + // Pop and free the top table + if (priv.key_tables.pop()) |s| alloc.free(s); + }, + .deactivate_all => { + // Free all tables and clear + for (priv.key_tables.items) |s| alloc.free(s); + priv.key_tables.clearAndFree(alloc); + }, + } + } + pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { const priv = self.private(); return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; @@ -1787,6 +1851,14 @@ pub const Surface = extern struct { glib.free(@ptrCast(@constCast(v))); priv.title_override = null; } + + // Clean up key sequence and key table state + const alloc = Application.default().allocator(); + for (priv.key_sequence.items) |s| alloc.free(s); + priv.key_sequence.deinit(alloc); + for (priv.key_tables.items) |s| alloc.free(s); + priv.key_tables.deinit(alloc); + self.clearCgroup(); gobject.Object.virtual_methods.finalize.call( diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 35c9390b2..5f717e14a 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -233,6 +233,70 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint { } } +/// Converts a trigger to a human-readable label for display in UI. +/// +/// Uses GTK accelerator-style formatting (e.g., "Ctrl+Shift+A"). +/// Returns false if the trigger cannot be formatted (e.g., catch_all). +pub fn labelFromTrigger( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) std.Io.Writer.Error!bool { + // Modifiers first, using human-readable format + if (trigger.mods.super) try writer.writeAll("Super+"); + if (trigger.mods.ctrl) try writer.writeAll("Ctrl+"); + if (trigger.mods.alt) try writer.writeAll("Alt+"); + if (trigger.mods.shift) try writer.writeAll("Shift+"); + + // Write the key + return writeTriggerKeyLabel(writer, trigger); +} + +/// Writes the key portion of a trigger in human-readable format. +fn writeTriggerKeyLabel( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) error{WriteFailed}!bool { + switch (trigger.key) { + .physical => |k| { + const keyval = keyvalFromKey(k) orelse return false; + const name = gdk.keyvalName(keyval) orelse return false; + // Capitalize the first letter for nicer display + const span = std.mem.span(name); + if (span.len > 0) { + if (span[0] >= 'a' and span[0] <= 'z') { + try writer.writeByte(span[0] - 'a' + 'A'); + if (span.len > 1) try writer.writeAll(span[1..]); + } else { + try writer.writeAll(span); + } + } + }, + + .unicode => |cp| { + // Try to get a nice name from GDK first + if (gdk.keyvalName(cp)) |name| { + const span = std.mem.span(name); + if (span.len > 0) { + // Capitalize the first letter for nicer display + if (span[0] >= 'a' and span[0] <= 'z') { + try writer.writeByte(span[0] - 'a' + 'A'); + if (span.len > 1) try writer.writeAll(span[1..]); + } else { + try writer.writeAll(span); + } + } + } else { + // Fall back to printing the character + try writer.print("{u}", .{cp}); + } + }, + + .catch_all => return false, + } + + return true; +} + test "accelFromTrigger" { const testing = std.testing; var buf: [256]u8 = undefined; @@ -263,6 +327,64 @@ test "xdgShortcutFromTrigger" { })).?); } +test "labelFromTrigger" { + const testing = std.testing; + + // Simple unicode key with modifier + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })); + try testing.expectEqualStrings("Super+Q", buf.written()); + } + + // Multiple modifiers + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })); + try testing.expectEqualStrings("Super+Ctrl+Alt+Shift+Backslash", buf.written()); + } + + // Physical key + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .ctrl = true }, + .key = .{ .physical = .key_a }, + })); + try testing.expectEqualStrings("Ctrl+A", buf.written()); + } + + // No modifiers + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{}, + .key = .{ .physical = .escape }, + })); + try testing.expectEqualStrings("Escape", buf.written()); + } + + // catch_all returns false + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(!try labelFromTrigger(&buf.writer, .{ + .mods = .{}, + .key = .catch_all, + })); + } +} + /// 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 8f44b74b331d4977c89cc928791905840f29d7f6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 23 Dec 2025 12:22:29 -0500 Subject: [PATCH 248/605] shell-integration: add failure regression test Add a unit test to prevent regressions in our failure state. For example, we always want to set GHOSTTY_SHELL_FEATURES, even if automatic shell integration fails, because it's also used for manual shell integration (e.g. #5048). --- src/termio/shell_integration.zig | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 3a541dcae..dba4a8f32 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -70,7 +70,6 @@ pub fn setup( exe, ); - // Setup our feature env vars try setupFeatures(env, features); return result; @@ -168,6 +167,29 @@ test "force shell" { } } +test "shell integration failure" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + const result = try setup( + alloc, + "/nonexistent", + .{ .shell = "sh" }, + &env, + null, + .{ .cursor = true, .title = false, .path = false }, + ); + + try testing.expect(result == null); + try testing.expectEqualStrings("cursor", env.get("GHOSTTY_SHELL_FEATURES").?); +} + /// Set up the shell integration features environment variable. pub fn setupFeatures( env: *EnvMap, @@ -234,7 +256,7 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }); + try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures)); try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null); } From 1562967d5103355916b4065123caf89608b70ae4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 09:35:51 -0800 Subject: [PATCH 249/605] apprt/gtk: key state overlay --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/key_state_overlay.zig | 290 +++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 6 + src/apprt/gtk/css/style.css | 12 + src/apprt/gtk/ui/1.2/key-state-overlay.blp | 58 +++++ src/apprt/gtk/ui/1.2/surface.blp | 3 + 6 files changed, 370 insertions(+) create mode 100644 src/apprt/gtk/class/key_state_overlay.zig create mode 100644 src/apprt/gtk/ui/1.2/key-state-overlay.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index c77579aab..d3684c171 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -44,6 +44,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 2, .name = "search-overlay" }, + .{ .major = 1, .minor = 2, .name = "key-state-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, diff --git a/src/apprt/gtk/class/key_state_overlay.zig b/src/apprt/gtk/class/key_state_overlay.zig new file mode 100644 index 000000000..15dc0d502 --- /dev/null +++ b/src/apprt/gtk/class/key_state_overlay.zig @@ -0,0 +1,290 @@ +const std = @import("std"); +const adw = @import("adw"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gdk = @import("gdk"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_key_state_overlay); + +/// An overlay that displays the current key table stack and pending key sequence. +/// This helps users understand what key bindings are active and what keys they've +/// pressed in a multi-key sequence. +pub const KeyStateOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyKeyStateOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const active = struct { + pub const name = "active"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("active"), + }, + ); + }; + + pub const @"tables-text" = struct { + pub const name = "tables-text"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("tables_text"), + }, + ); + }; + + pub const @"has-tables" = struct { + pub const name = "has-tables"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasTables }, + ), + }, + ); + }; + + pub const @"sequence-text" = struct { + pub const name = "sequence-text"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("sequence_text"), + }, + ); + }; + + pub const @"has-sequence" = struct { + pub const name = "has-sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSequence }, + ), + }, + ); + }; + + pub const pending = struct { + pub const name = "pending"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("pending"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; + }; + + const Private = struct { + /// Whether the overlay is active/visible. + active: bool = false, + + /// The formatted key table stack text (e.g., "default › vim"). + tables_text: ?[:0]const u8 = null, + + /// The formatted key sequence text (e.g., "Ctrl+A B"). + sequence_text: ?[:0]const u8 = null, + + /// Whether we're waiting for more keys in a sequence. + pending: bool = false, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .end, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Set dummy data for UI iteration + const priv = self.private(); + priv.active = true; + priv.tables_text = glib.ext.dupeZ(u8, "default › vim"); + priv.sequence_text = glib.ext.dupeZ(u8, "Ctrl+A"); + priv.pending = true; + + // Notify property changes so bindings update + const obj = self.as(gobject.Object); + obj.notifyByPspec(properties.active.impl.param_spec); + obj.notifyByPspec(properties.@"tables-text".impl.param_spec); + obj.notifyByPspec(properties.@"has-tables".impl.param_spec); + obj.notifyByPspec(properties.@"sequence-text".impl.param_spec); + obj.notifyByPspec(properties.@"has-sequence".impl.param_spec); + obj.notifyByPspec(properties.pending.impl.param_spec); + } + + fn getHasTables(self: *Self) bool { + return self.private().tables_text != null; + } + + fn getHasSequence(self: *Self) bool { + return self.private().sequence_text != null; + } + + fn closureShowChevron( + _: *Self, + has_tables: bool, + has_sequence: bool, + ) callconv(.c) c_int { + return if (has_tables and has_sequence) 1 else 0; + } + + //--------------------------------------------------------------- + // Template callbacks + + fn onDragEnd( + _: *gtk.GestureDrag, + _: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // Key state overlay only moves between top-center and bottom-center. + // Horizontal alignment is always center. + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + const new_y = self_y + offset_y + (self_height / 2); + + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + self.as(gtk.Widget).queueResize(); + } + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.tables_text) |v| { + glib.free(@ptrCast(@constCast(v))); + } + if (priv.sequence_text) |v| { + glib.free(@ptrCast(@constCast(v))); + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 2, + .name = "key-state-overlay", + }), + ); + + // Template Callbacks + class.bindTemplateCallback("on_drag_end", &onDragEnd); + class.bindTemplateCallback("show_chevron", &closureShowChevron); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + properties.@"tables-text".impl, + properties.@"has-tables".impl, + properties.@"sequence-text".impl, + properties.@"has-sequence".impl, + properties.pending.impl, + properties.@"valign-target".impl, + }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index c35c78302..50d7f3dc2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -26,6 +26,7 @@ const Application = @import("application.zig").Application; const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const SearchOverlay = @import("search_overlay.zig").SearchOverlay; +const KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; @@ -553,6 +554,9 @@ pub const Surface = extern struct { /// The search overlay search_overlay: *SearchOverlay, + /// The key state overlay + key_state_overlay: *KeyStateOverlay, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -3308,6 +3312,7 @@ pub const Surface = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); gobject.ext.ensureType(SearchOverlay); + gobject.ext.ensureType(KeyStateOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -3328,6 +3333,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); class.bindTemplateChildPrivate("search_overlay", .{}); + class.bindTemplateChildPrivate("key_state_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 938d23ad8..f5491b7de 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -46,6 +46,18 @@ label.url-overlay.right { outline-width: 1px; } +/* + * GhosttySurface key state overlay + */ +.key-state-overlay { + padding: 6px 10px; + margin: 8px; + border-radius: 8px; + outline-style: solid; + outline-color: #555555; + outline-width: 1px; +} + /* * GhosttySurface resize overlay */ diff --git a/src/apprt/gtk/ui/1.2/key-state-overlay.blp b/src/apprt/gtk/ui/1.2/key-state-overlay.blp new file mode 100644 index 000000000..504d2e26e --- /dev/null +++ b/src/apprt/gtk/ui/1.2/key-state-overlay.blp @@ -0,0 +1,58 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyKeyStateOverlay: Adw.Bin { + visible: bind template.active; + valign-target: end; + halign: center; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "key-state-overlay", + ] + + orientation: horizontal; + spacing: 6; + + Image { + icon-name: "input-keyboard-symbolic"; + pixel-size: 16; + } + + Label tables_label { + visible: bind template.has-tables; + label: bind template.tables-text; + xalign: 0.0; + } + + Label chevron_label { + visible: bind $show_chevron(template.has-tables, template.has-sequence) as ; + label: "›"; + + styles [ + "dim-label", + ] + } + + Label sequence_label { + visible: bind template.has-sequence; + label: bind template.sequence-text; + xalign: 0.0; + } + + Spinner pending_spinner { + visible: bind template.pending; + spinning: bind template.pending; + } + } + } +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 4ebfeabfb..e9db4208e 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -155,6 +155,9 @@ Overlay terminal_page { previous-match => $search_previous_match(); } + [overlay] + $GhosttyKeyStateOverlay key_state_overlay {} + [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface From 3d2aa9bd829665fcb96500317b4bb67767cfc5cc Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 23 Dec 2025 12:59:58 -0500 Subject: [PATCH 250/605] shell-integration: always call setupFeatures Our existing logic already ensured that setupFeatures() was always called, but that was happening from two code paths: explicitly when shell integration is .none and implicitly via setup(). We can simplify this by always calling setupFeatures() once, outside of the (automatic) shell integration path. There's one small behavioral change: we previously didn't set up shell features in the automatic shell integration path if we didn't have a resources directory (as a side effect). Resources are required for shell integrations, but we don't need them to export GHOSTTY_SHELL_FEATURES, which could potentially still be useful on its on. --- src/termio/Exec.zig | 15 +++++++-------- src/termio/shell_integration.zig | 9 ++------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 7c7b711fd..93ad835c5 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -750,15 +750,15 @@ const Subprocess = struct { else => "sh", } }; + // Always set up shell features (GHOSTTY_SHELL_FEATURES). These are + // used by both automatic and manual shell integrations. + try shell_integration.setupFeatures( + &env, + cfg.shell_integration_features, + ); + const force: ?shell_integration.Shell = switch (cfg.shell_integration) { .none => { - // Even if shell integration is none, we still want to - // set up the feature env vars - try shell_integration.setupFeatures( - &env, - cfg.shell_integration_features, - ); - // This is a source of confusion for users despite being // opt-in since it results in some Ghostty features not // working. We always want to log it. @@ -784,7 +784,6 @@ const Subprocess = struct { default_shell_command, &env, force, - cfg.shell_integration_features, ) orelse { log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); break :shell default_shell_command; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index dba4a8f32..e9f85e44c 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -44,7 +44,6 @@ pub fn setup( command: config.Command, env: *EnvMap, force_shell: ?Shell, - features: config.ShellIntegrationFeatures, ) !?ShellIntegration { const exe = if (force_shell) |shell| switch (shell) { .bash => "bash", @@ -70,8 +69,6 @@ pub fn setup( exe, ); - try setupFeatures(env, features); - return result; } @@ -161,7 +158,6 @@ test "force shell" { .{ .shell = "sh" }, &env, shell, - .{}, ); try testing.expectEqual(shell, result.?.shell); } @@ -183,11 +179,10 @@ test "shell integration failure" { .{ .shell = "sh" }, &env, null, - .{ .cursor = true, .title = false, .path = false }, ); try testing.expect(result == null); - try testing.expectEqualStrings("cursor", env.get("GHOSTTY_SHELL_FEATURES").?); + try testing.expectEqual(0, env.count()); } /// Set up the shell integration features environment variable. @@ -756,7 +751,7 @@ const TmpResourcesDir = struct { path: []const u8, shell_path: []const u8, - fn init(allocator: std.mem.Allocator, shell: Shell) !TmpResourcesDir { + fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir { var tmp_dir = std.testing.tmpDir(.{}); errdefer tmp_dir.cleanup(); From 85ce7d0b04f258cb53068aa753e80d26dad15865 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:10:44 -0800 Subject: [PATCH 251/605] apprt/gtk: write StringList for boxed type of strings --- src/apprt/gtk/ext.zig | 3 + src/apprt/gtk/ext/slice.zig | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/apprt/gtk/ext/slice.zig diff --git a/src/apprt/gtk/ext.zig b/src/apprt/gtk/ext.zig index 9b1eeecc6..df9ab4ea2 100644 --- a/src/apprt/gtk/ext.zig +++ b/src/apprt/gtk/ext.zig @@ -12,6 +12,8 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); pub const actions = @import("ext/actions.zig"); +const slice = @import("ext/slice.zig"); +pub const StringList = slice.StringList; /// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`. pub fn boxedCopy(comptime T: type, ptr: *const T) *T { @@ -64,4 +66,5 @@ pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool { test { _ = actions; + _ = slice; } diff --git a/src/apprt/gtk/ext/slice.zig b/src/apprt/gtk/ext/slice.zig new file mode 100644 index 000000000..a746d8045 --- /dev/null +++ b/src/apprt/gtk/ext/slice.zig @@ -0,0 +1,106 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const glib = @import("glib"); +const gobject = @import("gobject"); + +/// A boxed type that holds a list of string slices. +pub const StringList = struct { + arena: ArenaAllocator, + strings: []const [:0]const u8, + + pub fn create( + alloc: Allocator, + strings: []const [:0]const u8, + ) Allocator.Error!*StringList { + var arena: ArenaAllocator = .init(alloc); + errdefer arena.deinit(); + const arena_alloc = arena.allocator(); + var stored = try arena_alloc.alloc([:0]const u8, strings.len); + for (strings, 0..) |s, i| stored[i] = try arena_alloc.dupeZ(u8, s); + + const ptr = try alloc.create(StringList); + errdefer alloc.destroy(ptr); + ptr.* = .{ .arena = arena, .strings = stored }; + + return ptr; + } + + pub fn deinit(self: *StringList) void { + self.arena.deinit(); + } + + pub fn destroy(self: *StringList) void { + const alloc = self.arena.child_allocator; + self.deinit(); + alloc.destroy(self); + } + + pub const getGObjectType = gobject.ext.defineBoxed( + StringList, + .{ + .name = "GhosttyStringList", + .funcs = .{ + .copy = &struct { + fn copy(self: *StringList) callconv(.c) *StringList { + return StringList.create( + self.arena.child_allocator, + self.strings, + ) catch @panic("OOM"); + } + }.copy, + .free = &struct { + fn free(self: *StringList) callconv(.c) void { + self.destroy(); + } + }.free, + }, + }, + ); +}; + +test "StringList create and destroy" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{ "hello", "world" }; + const list = try StringList.create(alloc, input); + defer list.destroy(); + + try testing.expectEqual(@as(usize, 2), list.strings.len); + try testing.expectEqualStrings("hello", list.strings[0]); + try testing.expectEqualStrings("world", list.strings[1]); +} + +test "StringList create empty list" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{}; + const list = try StringList.create(alloc, input); + defer list.destroy(); + + try testing.expectEqual(@as(usize, 0), list.strings.len); +} + +test "StringList boxedCopy and boxedFree" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{ "foo", "bar", "baz" }; + const original = try StringList.create(alloc, input); + defer original.destroy(); + + const copied: *StringList = @ptrCast(@alignCast(gobject.boxedCopy( + StringList.getGObjectType(), + original, + ))); + defer gobject.boxedFree(StringList.getGObjectType(), copied); + + try testing.expectEqual(@as(usize, 3), copied.strings.len); + try testing.expectEqualStrings("foo", copied.strings[0]); + try testing.expectEqualStrings("bar", copied.strings[1]); + try testing.expectEqualStrings("baz", copied.strings[2]); + + try testing.expect(original.strings.ptr != copied.strings.ptr); +} From 481490bd1176760d154b4fa6584604d5de231757 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:25:04 -0800 Subject: [PATCH 252/605] apprt/gtk: add getters for key-sequence and key-table --- src/apprt/gtk/class/surface.zig | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 50d7f3dc2..556e5e2ec 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -361,6 +361,44 @@ pub const Surface = extern struct { }, ); }; + + pub const @"key-sequence" = struct { + pub const name = "key-sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getKeySequence, + .getter_transfer = .full, + }, + ), + }, + ); + }; + + pub const @"key-table" = struct { + pub const name = "key-table"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getKeyTable, + .getter_transfer = .full, + }, + ), + }, + ); + }; }; pub const signals = struct { @@ -1949,6 +1987,20 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"default-size".impl.param_spec); } + /// Get the key sequence list. Full transfer. + fn getKeySequence(self: *Self) ?*ext.StringList { + const priv = self.private(); + const alloc = Application.default().allocator(); + return ext.StringList.create(alloc, priv.key_sequence.items) catch null; + } + + /// Get the key table list. Full transfer. + fn getKeyTable(self: *Self) ?*ext.StringList { + const priv = self.private(); + const alloc = Application.default().allocator(); + return ext.StringList.create(alloc, priv.key_tables.items) catch null; + } + /// Return the min size, if set. pub fn getMinSize(self: *Self) ?*Size { const priv = self.private(); @@ -3385,6 +3437,8 @@ pub const Surface = extern struct { properties.@"error".impl, properties.@"font-size-request".impl, properties.focused.impl, + properties.@"key-sequence".impl, + properties.@"key-table".impl, properties.@"min-size".impl, properties.@"mouse-shape".impl, properties.@"mouse-hidden".impl, From 7ca3f41f6f25e13e5da065700eaad0c5798a9ddb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:31:53 -0800 Subject: [PATCH 253/605] apprt/gtk: key state overlay take bindings from surface --- src/apprt/gtk/class/key_state_overlay.zig | 111 ++++++++++++++-------- src/apprt/gtk/ext/slice.zig | 5 + src/apprt/gtk/ui/1.2/surface.blp | 5 +- 3 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/apprt/gtk/class/key_state_overlay.zig b/src/apprt/gtk/class/key_state_overlay.zig index 15dc0d502..20c0a8ab8 100644 --- a/src/apprt/gtk/class/key_state_overlay.zig +++ b/src/apprt/gtk/class/key_state_overlay.zig @@ -1,10 +1,9 @@ const std = @import("std"); const adw = @import("adw"); -const glib = @import("glib"); const gobject = @import("gobject"); -const gdk = @import("gdk"); const gtk = @import("gtk"); +const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; @@ -39,15 +38,23 @@ pub const KeyStateOverlay = extern struct { ); }; - pub const @"tables-text" = struct { - pub const name = "tables-text"; + pub const tables = struct { + pub const name = "tables"; const impl = gobject.ext.defineProperty( name, Self, - ?[:0]const u8, + ?*ext.StringList, .{ - .default = null, - .accessor = C.privateStringFieldAccessor("tables_text"), + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getTables, + .getter_transfer = .full, + .setter = setTables, + .setter_transfer = .full, + }, + ), }, ); }; @@ -69,15 +76,23 @@ pub const KeyStateOverlay = extern struct { ); }; - pub const @"sequence-text" = struct { - pub const name = "sequence-text"; + pub const sequence = struct { + pub const name = "sequence"; const impl = gobject.ext.defineProperty( name, Self, - ?[:0]const u8, + ?*ext.StringList, .{ - .default = null, - .accessor = C.privateStringFieldAccessor("sequence_text"), + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getSequence, + .getter_transfer = .full, + .setter = setSequence, + .setter_transfer = .full, + }, + ), }, ); }; @@ -130,11 +145,11 @@ pub const KeyStateOverlay = extern struct { /// Whether the overlay is active/visible. active: bool = false, - /// The formatted key table stack text (e.g., "default › vim"). - tables_text: ?[:0]const u8 = null, + /// The key table stack. + tables: ?*ext.StringList = null, - /// The formatted key sequence text (e.g., "Ctrl+A B"). - sequence_text: ?[:0]const u8 = null, + /// The key sequence. + sequence: ?*ext.StringList = null, /// Whether we're waiting for more keys in a sequence. pending: bool = false, @@ -147,30 +162,52 @@ pub const KeyStateOverlay = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + } - // Set dummy data for UI iteration + fn getTables(self: *Self) ?*ext.StringList { const priv = self.private(); - priv.active = true; - priv.tables_text = glib.ext.dupeZ(u8, "default › vim"); - priv.sequence_text = glib.ext.dupeZ(u8, "Ctrl+A"); - priv.pending = true; + if (priv.tables) |tables| { + return ext.StringList.create(tables.allocator(), tables.strings) catch null; + } + return null; + } - // Notify property changes so bindings update - const obj = self.as(gobject.Object); - obj.notifyByPspec(properties.active.impl.param_spec); - obj.notifyByPspec(properties.@"tables-text".impl.param_spec); - obj.notifyByPspec(properties.@"has-tables".impl.param_spec); - obj.notifyByPspec(properties.@"sequence-text".impl.param_spec); - obj.notifyByPspec(properties.@"has-sequence".impl.param_spec); - obj.notifyByPspec(properties.pending.impl.param_spec); + fn getSequence(self: *Self) ?*ext.StringList { + const priv = self.private(); + if (priv.sequence) |sequence| { + return ext.StringList.create(sequence.allocator(), sequence.strings) catch null; + } + return null; + } + + fn setTables(self: *Self, value: ?*ext.StringList) void { + const priv = self.private(); + if (priv.tables) |old| { + old.destroy(); + priv.tables = null; + } + + priv.tables = value; + self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec); + } + + fn setSequence(self: *Self, value: ?*ext.StringList) void { + const priv = self.private(); + if (priv.sequence) |old| { + old.destroy(); + priv.sequence = null; + } + + priv.sequence = value; + self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec); } fn getHasTables(self: *Self) bool { - return self.private().tables_text != null; + return self.private().tables != null; } fn getHasSequence(self: *Self) bool { - return self.private().sequence_text != null; + return self.private().sequence != null; } fn closureShowChevron( @@ -229,11 +266,11 @@ pub const KeyStateOverlay = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.tables_text) |v| { - glib.free(@ptrCast(@constCast(v))); + if (priv.tables) |v| { + v.destroy(); } - if (priv.sequence_text) |v| { - glib.free(@ptrCast(@constCast(v))); + if (priv.sequence) |v| { + v.destroy(); } gobject.Object.virtual_methods.finalize.call( @@ -270,9 +307,9 @@ pub const KeyStateOverlay = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, - properties.@"tables-text".impl, + properties.tables.impl, properties.@"has-tables".impl, - properties.@"sequence-text".impl, + properties.sequence.impl, properties.@"has-sequence".impl, properties.pending.impl, properties.@"valign-target".impl, diff --git a/src/apprt/gtk/ext/slice.zig b/src/apprt/gtk/ext/slice.zig index a746d8045..49ad63d85 100644 --- a/src/apprt/gtk/ext/slice.zig +++ b/src/apprt/gtk/ext/slice.zig @@ -36,6 +36,11 @@ pub const StringList = struct { alloc.destroy(self); } + /// Returns the general-purpose allocator used by this StringList. + pub fn allocator(self: *const StringList) Allocator { + return self.arena.child_allocator; + } + pub const getGObjectType = gobject.ext.defineBoxed( StringList, .{ diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index e9db4208e..a594ba98f 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -156,7 +156,10 @@ Overlay terminal_page { } [overlay] - $GhosttyKeyStateOverlay key_state_overlay {} + $GhosttyKeyStateOverlay key_state_overlay { + tables: bind template.key-table; + sequence: bind template.key-sequence; + } [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface From 71d5ae5a51b96b4fc841e6cc07b2ccfc79489987 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:43:40 -0800 Subject: [PATCH 254/605] apprt/gtk: key state overlay text is dynamic --- src/apprt/gtk/class/key_state_overlay.zig | 115 ++++++++++++--------- src/apprt/gtk/class/surface.zig | 8 ++ src/apprt/gtk/ui/1.2/key-state-overlay.blp | 10 +- 3 files changed, 78 insertions(+), 55 deletions(-) diff --git a/src/apprt/gtk/class/key_state_overlay.zig b/src/apprt/gtk/class/key_state_overlay.zig index 20c0a8ab8..7aca8f01d 100644 --- a/src/apprt/gtk/class/key_state_overlay.zig +++ b/src/apprt/gtk/class/key_state_overlay.zig @@ -1,10 +1,12 @@ const std = @import("std"); const adw = @import("adw"); +const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); +const Application = @import("application.zig").Application; const Common = @import("../class.zig").Common; const log = std.log.scoped(.gtk_ghostty_key_state_overlay); @@ -25,19 +27,6 @@ pub const KeyStateOverlay = extern struct { }); pub const properties = struct { - pub const active = struct { - pub const name = "active"; - const impl = gobject.ext.defineProperty( - name, - Self, - bool, - .{ - .default = false, - .accessor = C.privateShallowFieldAccessor("active"), - }, - ); - }; - pub const tables = struct { pub const name = "tables"; const impl = gobject.ext.defineProperty( @@ -50,7 +39,7 @@ pub const KeyStateOverlay = extern struct { ?*ext.StringList, .{ .getter = getTables, - .getter_transfer = .full, + .getter_transfer = .none, .setter = setTables, .setter_transfer = .full, }, @@ -88,7 +77,7 @@ pub const KeyStateOverlay = extern struct { ?*ext.StringList, .{ .getter = getSequence, - .getter_transfer = .full, + .getter_transfer = .none, .setter = setSequence, .setter_transfer = .full, }, @@ -114,19 +103,6 @@ pub const KeyStateOverlay = extern struct { ); }; - pub const pending = struct { - pub const name = "pending"; - const impl = gobject.ext.defineProperty( - name, - Self, - bool, - .{ - .default = false, - .accessor = C.privateShallowFieldAccessor("pending"), - }, - ); - }; - pub const @"valign-target" = struct { pub const name = "valign-target"; const impl = gobject.ext.defineProperty( @@ -142,18 +118,12 @@ pub const KeyStateOverlay = extern struct { }; const Private = struct { - /// Whether the overlay is active/visible. - active: bool = false, - /// The key table stack. tables: ?*ext.StringList = null, /// The key sequence. sequence: ?*ext.StringList = null, - /// Whether we're waiting for more keys in a sequence. - pending: bool = false, - /// Target vertical alignment for the overlay. valign_target: gtk.Align = .end, @@ -165,19 +135,11 @@ pub const KeyStateOverlay = extern struct { } fn getTables(self: *Self) ?*ext.StringList { - const priv = self.private(); - if (priv.tables) |tables| { - return ext.StringList.create(tables.allocator(), tables.strings) catch null; - } - return null; + return self.private().tables; } fn getSequence(self: *Self) ?*ext.StringList { - const priv = self.private(); - if (priv.sequence) |sequence| { - return ext.StringList.create(sequence.allocator(), sequence.strings) catch null; - } - return null; + return self.private().sequence; } fn setTables(self: *Self, value: ?*ext.StringList) void { @@ -186,8 +148,11 @@ pub const KeyStateOverlay = extern struct { old.destroy(); priv.tables = null; } + if (value) |v| { + priv.tables = v; + } - priv.tables = value; + self.as(gobject.Object).notifyByPspec(properties.tables.impl.param_spec); self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec); } @@ -197,17 +162,22 @@ pub const KeyStateOverlay = extern struct { old.destroy(); priv.sequence = null; } + if (value) |v| { + priv.sequence = v; + } - priv.sequence = value; + self.as(gobject.Object).notifyByPspec(properties.sequence.impl.param_spec); self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec); } fn getHasTables(self: *Self) bool { - return self.private().tables != null; + const v = self.private().tables orelse return false; + return v.strings.len > 0; } fn getHasSequence(self: *Self) bool { - return self.private().sequence != null; + const v = self.private().sequence orelse return false; + return v.strings.len > 0; } fn closureShowChevron( @@ -218,6 +188,50 @@ pub const KeyStateOverlay = extern struct { return if (has_tables and has_sequence) 1 else 0; } + fn closureHasState( + _: *Self, + has_tables: bool, + has_sequence: bool, + ) callconv(.c) c_int { + return if (has_tables or has_sequence) 1 else 0; + } + + fn closureTablesText( + _: *Self, + tables: ?*ext.StringList, + ) callconv(.c) ?[*:0]const u8 { + const list = tables orelse return null; + if (list.strings.len == 0) return null; + + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + for (list.strings, 0..) |s, i| { + if (i > 0) buf.writer.writeAll(" > ") catch return null; + buf.writer.writeAll(s) catch return null; + } + + return glib.ext.dupeZ(u8, buf.written()); + } + + fn closureSequenceText( + _: *Self, + sequence: ?*ext.StringList, + ) callconv(.c) ?[*:0]const u8 { + const list = sequence orelse return null; + if (list.strings.len == 0) return null; + + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + for (list.strings, 0..) |s, i| { + if (i > 0) buf.writer.writeAll(" ") catch return null; + buf.writer.writeAll(s) catch return null; + } + + return glib.ext.dupeZ(u8, buf.written()); + } + //--------------------------------------------------------------- // Template callbacks @@ -303,15 +317,16 @@ pub const KeyStateOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("on_drag_end", &onDragEnd); class.bindTemplateCallback("show_chevron", &closureShowChevron); + class.bindTemplateCallback("has_state", &closureHasState); + class.bindTemplateCallback("tables_text", &closureTablesText); + class.bindTemplateCallback("sequence_text", &closureSequenceText); // Properties gobject.ext.registerProperties(class, &.{ - properties.active.impl, properties.tables.impl, properties.@"has-tables".impl, properties.sequence.impl, properties.@"has-sequence".impl, - properties.pending.impl, properties.@"valign-target".impl, }); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 556e5e2ec..a14d53c32 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -832,6 +832,10 @@ pub const Surface = extern struct { const priv = self.private(); const alloc = Application.default().allocator(); + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"key-sequence".impl.param_spec); + switch (value) { .trigger => |trigger| { // Convert the trigger to a human-readable label @@ -865,6 +869,10 @@ pub const Surface = extern struct { const priv = self.private(); const alloc = Application.default().allocator(); + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"key-table".impl.param_spec); + switch (value) { .activate => |name| { // Duplicate the name string and push onto stack diff --git a/src/apprt/gtk/ui/1.2/key-state-overlay.blp b/src/apprt/gtk/ui/1.2/key-state-overlay.blp index 504d2e26e..c8654bfbb 100644 --- a/src/apprt/gtk/ui/1.2/key-state-overlay.blp +++ b/src/apprt/gtk/ui/1.2/key-state-overlay.blp @@ -2,7 +2,7 @@ using Gtk 4.0; using Adw 1; template $GhosttyKeyStateOverlay: Adw.Bin { - visible: bind template.active; + visible: bind $has_state(template.has-tables, template.has-sequence) as ; valign-target: end; halign: center; valign: bind template.valign-target; @@ -30,7 +30,7 @@ template $GhosttyKeyStateOverlay: Adw.Bin { Label tables_label { visible: bind template.has-tables; - label: bind template.tables-text; + label: bind $tables_text(template.tables) as ; xalign: 0.0; } @@ -45,13 +45,13 @@ template $GhosttyKeyStateOverlay: Adw.Bin { Label sequence_label { visible: bind template.has-sequence; - label: bind template.sequence-text; + label: bind $sequence_text(template.sequence) as ; xalign: 0.0; } Spinner pending_spinner { - visible: bind template.pending; - spinning: bind template.pending; + visible: bind template.has-sequence; + spinning: bind template.has-sequence; } } } From f2fe979bab56f434d9b3711b373a9b172ec1fc91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 11:23:02 -0800 Subject: [PATCH 255/605] update valgrind suppressions --- valgrind.supp | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/valgrind.supp b/valgrind.supp index eeb395d03..27479fd5c 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -72,7 +72,16 @@ fun:gdk_surface_handle_event ... } - +{ + GTK CSS Node Validation + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + ... + fun:gtk_css_node_validate_internal + fun:gtk_css_node_validate + ... +} { GTK CSS Provider Leak Memcheck:Leak @@ -196,8 +205,44 @@ fun:svga_context_flush ... } - { + SVGA Stuff + Memcheck:Leak + match-leak-kinds: definite + fun:calloc + fun:svga_create_surface_view + fun:svga_set_framebuffer_state + fun:st_update_framebuffer_state + fun:st_Clear + fun:gsk_gpu_render_pass_op_gl_command + ... +} +{ + GTK Icon + Memcheck:Leak + match-leak-kinds: possible + fun:*alloc + ... + fun:gtk_icon_theme_set_display + fun:gtk_icon_theme_get_for_display + ... +} +{ + GDK Wayland Connection + Memcheck:Leak + match-leak-kinds: possible + fun:calloc + fun:wl_closure_init + fun:wl_connection_demarshal + fun:wl_display_read_events + fun:gdk_wayland_poll_source_check + fun:g_main_context_check_unlocked + fun:g_main_context_iterate_unlocked.isra.0 + fun:g_main_context_iteration + ... +} +{ + GSK Renderer GPU Stuff Memcheck:Leak match-leak-kinds: possible @@ -297,6 +342,21 @@ fun:g_main_context_iteration ... } +{ + GSK More Forms + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gsk_gl_device_use_program + fun:gsk_gl_frame_use_program + fun:gsk_gpu_shader_op_gl_command_n + fun:gsk_gpu_render_pass_op_gl_command + fun:gsk_gl_frame_submit + fun:gsk_gpu_renderer_render_texture + fun:gsk_renderer_render_texture + fun:render_contents + ... +} { GTK Shader Selector Memcheck:Leak From 256c3b9ffba19d6d18963dd34f719f8878d388f6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 23 Dec 2025 14:51:09 -0500 Subject: [PATCH 256/605] shell-integration: ensure clean env on failure Our shell integration routines can now fail when resources are missing. This change introduces tests to ensure that they leave behind a clean environment upon failure. The bash integration needed a little reordering to support this. --- src/termio/shell_integration.zig | 103 ++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index dba4a8f32..519c226c7 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -344,6 +344,28 @@ fn setupBash( try cmd.appendArg(arg); } } + + // Preserve an existing ENV value. We're about to overwrite it. + if (env.get("ENV")) |v| { + try env.put("GHOSTTY_BASH_ENV", v); + } + + // Set our new ENV to point to our integration script. + var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const script_path = try std.fmt.bufPrint( + &script_path_buf, + "{s}/shell-integration/bash/ghostty.bash", + .{resource_dir}, + ); + if (std.fs.openFileAbsolute(script_path, .{})) |file| { + file.close(); + try env.put("ENV", script_path); + } else |err| { + log.warn("unable to open {s}: {}", .{ script_path, err }); + env.remove("GHOSTTY_BASH_ENV"); + return null; + } + try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); if (rcfile) |v| { try env.put("GHOSTTY_BASH_RCFILE", v); @@ -365,26 +387,6 @@ fn setupBash( } } - // Preserve an existing ENV value. We're about to overwrite it. - if (env.get("ENV")) |v| { - try env.put("GHOSTTY_BASH_ENV", v); - } - - // Set our new ENV to point to our integration script. - var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const script_path = try std.fmt.bufPrint( - &script_path_buf, - "{s}/shell-integration/bash/ghostty.bash", - .{resource_dir}, - ); - if (std.fs.openFileAbsolute(script_path, .{})) |file| { - file.close(); - try env.put("ENV", script_path); - } else |err| { - log.warn("unable to open {s}: {}", .{ script_path, err }); - return null; - } - // Get the command string from the builder, then copy it to the arena // allocator. The stackFallback allocator's memory becomes invalid after // this function returns, so we must copy to the arena. @@ -437,9 +439,7 @@ test "bash: unsupported options" { defer env.deinit(); try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null); - try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null); - try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null); - try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); + try testing.expectEqual(0, env.count()); } } @@ -581,6 +581,25 @@ test "bash: additional arguments" { } } +test "bash: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupBash(alloc, .{ .shell = "bash" }, resources_dir, &env) == null); + try testing.expectEqual(0, env.count()); +} + /// Setup automatic shell integration for shells that include /// their modules from paths in `XDG_DATA_DIRS` env variable. /// @@ -690,6 +709,25 @@ test "xdg: existing XDG_DATA_DIRS" { ); } +test "xdg: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env)); + try testing.expectEqual(0, env.count()); +} + /// Setup the zsh automatic shell integration. This works by setting /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config. @@ -749,6 +787,25 @@ test "zsh: ZDOTDIR" { try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?); } +test "zsh: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(!try setupZsh(resources_dir, &env)); + try testing.expectEqual(0, env.count()); +} + /// Test helper that creates a temporary resources directory with shell integration paths. const TmpResourcesDir = struct { allocator: Allocator, From 0db0655ea56ee919043cf0ff841ce81dc00a41e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 20:28:49 -0800 Subject: [PATCH 257/605] Invalid key sequence does not encode if a `catch_all` has `ignore` This adds some new special case handling for key sequences when an unbound keyboard input is received. If the current keybinding set scope (i.e. active tables) has a `catch_all` binding that would `ignore` input, then the entire key sequence is dropped. Normally, when an unbound key sequence is received, Ghostty encodes it and sends it to the running program. This special behavior is useful for things like Vim mode which have `g>g` to scroll to top, and a `catch_all=ignore` to drop all other input. If the user presses `g>h` (unbound), you don't want `gh` to show up in your terminal input, because the `catch_all=ignore` indicates that the user wants that mode to drop all unbound input. --- src/Surface.zig | 78 ++++++++++++++++++++++++++++++++----------- src/config/Config.zig | 5 +++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ea17c6104..fd658a43b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2826,14 +2826,21 @@ fn maybeHandleBinding( // No entry found. We need to encode everything up to this // point and send to the pty since we're in a sequence. - // - // We also ignore modifiers so that nested sequences such as + + // We ignore modifiers so that nested sequences such as // ctrl+a>ctrl+b>c work. - if (!event.key.modifier()) { - // Encode everything up to this point - self.endKeySequence(.flush, .retain); + if (event.key.modifier()) return null; + + // If we have a catch-all of ignore, then we special case our + // invalid sequence handling to ignore it. + if (self.catchAllIsIgnore()) { + self.endKeySequence(.drop, .retain); + return .ignored; } + // Encode everything up to this point + self.endKeySequence(.flush, .retain); + return null; } @@ -3037,6 +3044,34 @@ fn deactivateAllKeyTables(self: *Surface) !bool { return true; } +/// This checks if the current keybinding sets have a catch_all binding +/// with `ignore`. This is used to determine some special input cases. +fn catchAllIsIgnore(self: *Surface) bool { + // Get our catch all + const entry: input.Binding.Set.Entry = entry: { + const trigger: input.Binding.Trigger = .{ .key = .catch_all }; + + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + const entry = table_items[rev_i].set.get(trigger) orelse continue; + break :entry entry; + } + + break :entry self.config.keybind.set.get(trigger) orelse + return false; + }; + + // We have a catch-all entry, see if its an ignore + return switch (entry.value_ptr.*) { + .leader => false, + .leaf => |leaf| leaf.action == .ignore, + .leaf_chained => |leaf| chained: for (leaf.actions.items) |action| { + if (action == .ignore) break :chained true; + } else false, + }; +} + const KeySequenceQueued = enum { flush, drop }; const KeySequenceMemory = enum { retain, free }; @@ -3065,23 +3100,26 @@ fn endKeySequence( // the set we look at to the root set. self.keyboard.sequence_set = null; - if (self.keyboard.sequence_queued.items.len > 0) { - switch (action) { - .flush => for (self.keyboard.sequence_queued.items) |write_req| { - self.queueIo(switch (write_req) { - .small => |v| .{ .write_small = v }, - .stable => |v| .{ .write_stable = v }, - .alloc => |v| .{ .write_alloc = v }, - }, .unlocked); - }, + // If we have no queued data, there is nothing else to do. + if (self.keyboard.sequence_queued.items.len == 0) return; - .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), - } + // Run the proper action first + switch (action) { + .flush => for (self.keyboard.sequence_queued.items) |write_req| { + self.queueIo(switch (write_req) { + .small => |v| .{ .write_small = v }, + .stable => |v| .{ .write_stable = v }, + .alloc => |v| .{ .write_alloc = v }, + }, .unlocked); + }, - switch (mem) { - .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), - .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), - } + .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), + } + + // Memory handling of the sequence after the action + switch (mem) { + .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), + .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 6911cd9f7..0df5c91b0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1521,6 +1521,11 @@ class: ?[:0]const u8 = null, /// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or /// press an unbound key which will send both keys to the program. /// +/// * If an unbound key is pressed during a sequence and a `catch_all` +/// binding exists that would `ignore` the input, the entire sequence +/// is dropped and nothing happens. Otherwise, the entire sequence is +/// encoded and sent to the running program as if no keybind existed. +/// /// * If a prefix in a sequence is previously bound, the sequence will /// override the previous binding. For example, if `ctrl+a` is bound to /// `new_window` and `ctrl+a>n` is bound to `new_tab`, pressing `ctrl+a` From 7ce88b6811065683d74405a66ab41d034c7c2bd1 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 24 Dec 2025 19:41:05 +0100 Subject: [PATCH 258/605] macOS: fix initial surface color scheme in quickTerminal --- 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 d79c89d2d..1750e949d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1298,7 +1298,7 @@ extension BaseTerminalController: NSMenuItemValidation { } else { scheme = GHOSTTY_COLOR_SCHEME_LIGHT } - guard scheme != appliedColorScheme else { + guard scheme != appliedColorScheme, !surfaceTree.isEmpty else { return } for surfaceView in surfaceTree { From 574ee470bd870314d6d13daef6668b84b0746873 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:29:14 +0100 Subject: [PATCH 259/605] macOS: move `NSGlassEffectView` into `TerminalViewContainer` --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../QuickTerminalController.swift | 13 +- .../Terminal/TerminalController.swift | 4 +- .../Terminal/TerminalViewContainer.swift | 127 ++++++++++++++++++ .../Window Styles/TerminalWindow.swift | 62 +-------- macos/Sources/Ghostty/Ghostty.Config.swift | 12 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 + 7 files changed, 155 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalViewContainer.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a810e621..779d95e5b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ Features/Terminal/TerminalRestorable.swift, Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, + Features/Terminal/TerminalViewContainer.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8a642034f..07c0c4c19 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self - )) + ) // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController { // If we have window transparency then set it transparent. Otherwise set it opaque. // Also check if the user has overridden transparency to be fully opaque. - if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { + if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -617,7 +617,9 @@ class QuickTerminalController: BaseTerminalController { // Terminal.app more easily. window.backgroundColor = .white.withAlphaComponent(0.001) - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + if !derivedConfig.backgroundBlur.isGlassStyle { + ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + } } else { window.isOpaque = true window.backgroundColor = .windowBackgroundColor @@ -722,6 +724,7 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur init() { self.quickTerminalScreen = .main @@ -730,6 +733,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = .move self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 + self.backgroundBlur = .disabled } init(_ config: Ghostty.Config) { @@ -739,6 +743,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bccdd9c69..c5481851b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -936,11 +936,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self, - )) + ) // If we have a default size, we want to apply it. if let defaultSize { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift new file mode 100644 index 000000000..f4e2fc080 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -0,0 +1,127 @@ +import AppKit +import SwiftUI + +/// Use this container to achieve a glass effect at the window level. +/// Modifying `NSThemeFrame` can sometimes be unpredictable. +class TerminalViewContainer: NSView { + private let terminalView: NSView + + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + private var derivedConfig: DerivedConfig + + init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { + self.derivedConfig = DerivedConfig(config: ghostty.config) + self.terminalView = NSHostingView(rootView: TerminalView( + ghostty: ghostty, + viewModel: viewModel, + delegate: delegate + )) + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + addSubview(terminalView) + terminalView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + terminalView.topAnchor.constraint(equalTo: topAnchor), + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), + terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateGlassEffectIfNeeded() + } + + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + let newValue = DerivedConfig(config: config) + guard newValue != derivedConfig else { return } + derivedConfig = newValue + DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) + } +} + +// MARK: Glass + +private extension TerminalViewContainer { +#if compiler(>=6.2) + @available(macOS 26.0, *) + func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { + if let existed = glassEffectView as? NSGlassEffectView { + return existed + } + guard let themeFrameView = window?.contentView?.superview else { + return nil + } + let effectView = NSGlassEffectView() + addSubview(effectView, positioned: .below, relativeTo: terminalView) + effectView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + glassEffectView = effectView + return effectView + } +#endif // compiler(>=6.2) + + func updateGlassEffectIfNeeded() { +#if compiler(>=6.2) + guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + return + } + guard let effectView = addGlassEffectViewIfNeeded() else { + return + } + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + break + } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + effectView.tintColor = backgroundColor + .withAlphaComponent(derivedConfig.backgroundOpacity) + if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { + effectView.cornerRadius = cornerRadius + } +#endif // compiler(>=6.2) + } + + struct DerivedConfig: Equatable { + var backgroundOpacity: Double = 0 + var backgroundBlur: Ghostty.Config.BackgroundBlur + var backgroundColor: Color = .clear + + init(config: Ghostty.Config) { + self.backgroundBlur = config.backgroundBlur + self.backgroundOpacity = config.backgroundOpacity + self.backgroundColor = config.backgroundColor + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4196df97f..9debd2cb3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -474,7 +474,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - surfaceConfig.backgroundOpacity < 1 + (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) { isOpaque = false @@ -483,15 +483,8 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { - setupGlassLayer() - } else if let appDelegate = NSApp.delegate as? AppDelegate { - // If we had a prior glass layer we should remove it - if #available(macOS 26.0, *) { - removeGlassLayer() - } - + // We don't need to set blur when using glass + if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -499,11 +492,6 @@ class TerminalWindow: NSWindow { } else { isOpaque = true - // Remove liquid glass when not transparent - if #available(macOS 26.0, *) { - removeGlassLayer() - } - let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -581,50 +569,6 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - -#if compiler(>=6.2) - // MARK: Glass - - @available(macOS 26.0, *) - private func setupGlassLayer() { - // Remove existing glass effect view - removeGlassLayer() - - // Get the window content view (parent of the NSHostingView) - guard let contentView else { return } - guard let windowContentView = contentView.superview else { return } - - // Create NSGlassEffectView for native glass effect - let effectView = NSGlassEffectView() - - // Map Ghostty config to NSGlassEffectView style - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular - case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear - default: - // Should not reach here since we check for glass style before calling - // setupGlassLayer() - assertionFailure() - } - - effectView.cornerRadius = derivedConfig.windowCornerRadius - effectView.tintColor = preferredBackgroundColor - effectView.frame = windowContentView.bounds - effectView.autoresizingMask = [.width, .height] - - // Position BELOW the terminal content to act as background - windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) - glassEffectView = effectView - } - - @available(macOS 26.0, *) - private func removeGlassLayer() { - glassEffectView?.removeFromSuperview() - glassEffectView = nil - } -#endif // compiler(>=6.2) // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ea545f7a..71b9eb17d 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -648,9 +648,17 @@ extension Ghostty.Config { case 0: self = .disabled case -1: - self = .macosGlassRegular + if #available(macOS 26.0, *) { + self = .macosGlassRegular + } else { + self = .disabled + } case -2: - self = .macosGlassClear + if #available(macOS 26.0, *) { + self = .macosGlassClear + } else { + self = .disabled + } default: self = .radius(Int(value)) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 37cc9282e..77e1c43d4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1654,6 +1654,7 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1662,6 +1663,7 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 + self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1671,6 +1673,7 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From e41dbe84fc29209b60b43963d604b8c5cc3dd941 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 24 Dec 2025 16:23:16 -0600 Subject: [PATCH 260/605] shaping: Use position offsets for HarfBuzz --- pkg/harfbuzz/main.zig | 1 + src/font/shaper/harfbuzz.zig | 43 ++++++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/pkg/harfbuzz/main.zig b/pkg/harfbuzz/main.zig index d0e8ac2f3..08a4f9c2a 100644 --- a/pkg/harfbuzz/main.zig +++ b/pkg/harfbuzz/main.zig @@ -13,6 +13,7 @@ pub const coretext = @import("coretext.zig"); pub const MemoryMode = blob.MemoryMode; pub const Blob = blob.Blob; pub const Buffer = buffer.Buffer; +pub const GlyphPosition = buffer.GlyphPosition; pub const Direction = common.Direction; pub const Script = common.Script; pub const Language = common.Language; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index e4a9301e8..fa4e79ccd 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -150,10 +150,19 @@ pub const Shaper = struct { .cluster = info_v.cluster, }; + // Under both FreeType and CoreText the harfbuzz scale is + // in 26.6 fixed point units, so we round to the nearest + // whole value here. + const x_offset = cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); + const y_offset = cell_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); + + // For debugging positions, turn this on: + //debugPositions(cell_offset, pos_v); + try self.cell_buf.append(self.alloc, .{ .x = @intCast(info_v.cluster), - .x_offset = @intCast(cell_offset.x), - .y_offset = @intCast(cell_offset.y), + .x_offset = @intCast(x_offset), + .y_offset = @intCast(y_offset), .glyph_index = info_v.codepoint, }); @@ -195,6 +204,36 @@ pub const Shaper = struct { self.shaper.hb_buf.guessSegmentProperties(); } }; + + fn debugPositions( + cell_offset: anytype, + pos_v: harfbuzz.GlyphPosition, + ) void { + const x_offset = cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); + const y_offset = cell_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); + const advance_x_offset = cell_offset.x; + const advance_y_offset = cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + // It'd be nice if we could log the original codepoints that went in to + // shaping this glyph, but at this point HarfBuzz has replaced + // `info_v.codepoint` with the glyph index (and that's only one of the + // codepoints anyway). We could have some way to map the cluster back + // to the original codepoints, but since that would only be used for + // debugging, we don't do that. + if (@abs(x_offset_diff) > 0 or @abs(y_offset_diff) > 0) { + log.warn("position differs from advance: cluster={d} pos=({d},{d}) adv=({d},{d}) diff=({d},{d})", .{ + cell_offset.cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + }); + } + } }; test "run iterator" { From 017021787c32f364b547d48d1911c40273d119d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 24 Dec 2025 14:22:21 -0800 Subject: [PATCH 261/605] config: RepeatableCommand cval --- include/ghostty.h | 6 +++ src/config/Config.zig | 88 ++++++++++++++++++++++++++++++++++++++++--- src/input/command.zig | 23 ++++++++++- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 48915b179..d6e6fba70 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -454,6 +454,12 @@ typedef struct { size_t len; } ghostty_config_color_list_s; +// config.RepeatableCommand +typedef struct { + const ghostty_command_s* commands; + size_t len; +} ghostty_config_command_list_s; + // config.Palette typedef struct { ghostty_config_color_s colors[256]; diff --git a/src/config/Config.zig b/src/config/Config.zig index 0df5c91b0..92caa5744 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8041,15 +8041,37 @@ pub const SplitPreserveZoom = packed struct { }; pub const RepeatableCommand = struct { - value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + const Self = @This(); - pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + value_c: std.ArrayListUnmanaged(inputpkg.Command.C) = .empty, + + /// ghostty_config_command_list_s + pub const C = extern struct { + commands: [*]inputpkg.Command.C, + len: usize, + }; + + pub fn cval(self: *const Self) C { + return .{ + .commands = self.value_c.items.ptr, + .len = self.value_c.items.len, + }; + } + + pub fn init(self: *Self, alloc: Allocator) !void { self.value = .empty; + self.value_c = .empty; + errdefer { + self.value.deinit(alloc); + self.value_c.deinit(alloc); + } try self.value.appendSlice(alloc, inputpkg.command.defaults); + try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC); } pub fn parseCLI( - self: *RepeatableCommand, + self: *Self, alloc: Allocator, input_: ?[]const u8, ) !void { @@ -8057,26 +8079,36 @@ pub const RepeatableCommand = struct { const input = input_ orelse ""; if (input.len == 0) { self.value.clearRetainingCapacity(); + self.value_c.clearRetainingCapacity(); return; } + // Reserve space in our lists + try self.value.ensureUnusedCapacity(alloc, 1); + try self.value_c.ensureUnusedCapacity(alloc, 1); + const cmd = try cli.args.parseAutoStruct( inputpkg.Command, alloc, input, null, ); - try self.value.append(alloc, cmd); + const cmd_c = try cmd.cval(alloc); + self.value.appendAssumeCapacity(cmd); + self.value_c.appendAssumeCapacity(cmd_c); } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { const value = try self.value.clone(alloc); for (value.items) |*item| { item.* = try item.clone(alloc); } - return .{ .value = value }; + return .{ + .value = value, + .value_c = try self.value_c.clone(alloc), + }; } /// Compare if two of our value are equal. Required by Config. @@ -8232,6 +8264,50 @@ pub const RepeatableCommand = struct { try testing.expectEqualStrings("kurwa", item.action.text); } } + + test "RepeatableCommand cval" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + + try testing.expectEqual(@as(usize, 2), list.value.items.len); + try testing.expectEqual(@as(usize, 2), list.value_c.items.len); + + const cv = list.cval(); + try testing.expectEqual(@as(usize, 2), cv.len); + + // First entry + try testing.expectEqualStrings("Foo", std.mem.sliceTo(cv.commands[0].title, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action_key, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action, 0)); + + // Second entry + try testing.expectEqualStrings("Bar", std.mem.sliceTo(cv.commands[1].title, 0)); + try testing.expectEqualStrings("bobr", std.mem.sliceTo(cv.commands[1].description, 0)); + try testing.expectEqualStrings("text", std.mem.sliceTo(cv.commands[1].action_key, 0)); + try testing.expectEqualStrings("text:ale bydle", std.mem.sliceTo(cv.commands[1].action, 0)); + } + + test "RepeatableCommand cval cleared" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try testing.expectEqual(@as(usize, 1), list.cval().len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.cval().len); + } }; /// OSC 4, 10, 11, and 12 default color reporting format. diff --git a/src/input/command.zig b/src/input/command.zig index 67086f7ec..936f2211c 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -43,7 +43,7 @@ pub const Command = struct { return true; } - /// Convert this command to a C struct. + /// Convert this command to a C struct at comptime. pub fn comptimeCval(self: Command) C { assert(@inComptime()); @@ -55,6 +55,27 @@ pub const Command = struct { }; } + /// Convert this command to a C struct at runtime. + /// + /// This shares memory with the original command. + /// + /// The action string is allocated using the provided allocator. You can + /// free the slice directly if you need to but we recommend an arena + /// for this. + pub fn cval(self: Command, alloc: Allocator) Allocator.Error!C { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + self.action.format(&buf.writer) catch return error.OutOfMemory; + const action = try buf.toOwnedSliceSentinel(0); + + return .{ + .action_key = @tagName(self.action), + .action = action.ptr, + .title = self.title, + .description = self.description, + }; + } + /// Implements a comparison function for std.mem.sortUnstable /// and similar functions. The sorting is defined by Ghostty /// to be what we prefer. If a caller wants some other sorting, From 12523ca61c2d3d3dfbe0a0f36183e11b61e88d2e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 24 Dec 2025 14:28:12 -0800 Subject: [PATCH 262/605] macOS: command-palette-entry is now visible in macOS --- .../Command Palette/TerminalCommandPalette.swift | 15 ++++++++++++++- macos/Sources/Ghostty/Ghostty.Config.swift | 10 ++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 902186ad3..6efb588cd 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,7 +64,7 @@ struct TerminalCommandPaletteView: View { // Sort the rest. We replace ":" with a character that sorts before space // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker // for stable ordering when titles are equal. - options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in + options.append(contentsOf: (jumpOptions + terminalOptions + customEntries).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) @@ -135,6 +135,19 @@ struct TerminalCommandPaletteView: View { } } + /// Custom commands from the command-palette-entry configuration. + private var customEntries: [CommandOption] { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } + return appDelegate.ghostty.config.commandPaletteEntries.map { c in + CommandOption( + title: c.title, + description: c.description + ) { + onAction(c.action) + } + } + } + /// Commands for jumping to other terminal surfaces. private var jumpOptions: [CommandOption] { TerminalController.all.flatMap { controller -> [CommandOption] in diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ea545f7a..5aa79a149 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -622,6 +622,16 @@ extension Ghostty { let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue } + + var commandPaletteEntries: [Ghostty.Command] { + guard let config = self.config else { return [] } + var v: ghostty_config_command_list_s = .init() + let key = "command-palette-entry" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] } + guard v.len > 0 else { return [] } + let buffer = UnsafeBufferPointer(start: v.commands, count: v.len) + return buffer.map { Ghostty.Command(cValue: $0) } + } } } From f7f29934f30cb469d15f909fdd68466352e0e2c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 24 Dec 2025 14:41:40 -0800 Subject: [PATCH 263/605] macos: ghostty.command should be part of iOS build --- macos/Ghostty.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a810e621..91c2300cc 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ Features/Update/UpdateSimulator.swift, Features/Update/UpdateViewModel.swift, "Ghostty/FullscreenMode+Extension.swift", - Ghostty/Ghostty.Command.swift, Ghostty/Ghostty.Error.swift, Ghostty/Ghostty.Event.swift, Ghostty/Ghostty.Input.swift, From bf73f753048a4b4262b71a7aea5f6bfc75c2720e Mon Sep 17 00:00:00 2001 From: rezky_nightky Date: Fri, 26 Dec 2025 00:27:08 +0700 Subject: [PATCH 264/605] chore: fixed some typo Author: rezky_nightky Repository: ghostty Branch: main Signing: GPG (4B65AAC2) HashAlgo: BLAKE3 [ Block Metadata ] BlockHash: c37f4ee817412728a8058ba6087f5ca6aaff5a845560447d595d8055972d0eac PrevHash: 3510917a780936278debe21786b7bae3a2162cb3857957314c3b8702e921b3d4 PatchHash: 5e5bb4ab35df304ea13c3d297c6d9a965156052c82bccf852b1f00b7bcaa7dd4 FilesChanged: 18 Lines: +92 / -92 Timestamp: 2025-12-25T17:27:08Z Signature1: c1970dbb94600d1e24dfe8efcc00f001664db7b777902df9632a689b1d9d1498 Signature2: 30babb1e3ca07264931e067bfe36c676fb7988c2e06f8c54e0c9538fe7c7fc9a --- .../TitlebarTabsVenturaTerminalWindow.swift | 4 +- .../TransparentTitlebarTerminalWindow.swift | 32 +++--- macos/Sources/Ghostty/Ghostty.Input.swift | 100 +++++++++--------- macos/Sources/Helpers/Fullscreen.swift | 10 +- src/Surface.zig | 4 +- src/config/theme.zig | 2 +- src/font/Atlas.zig | 2 +- src/font/shaper/coretext.zig | 2 +- src/font/sprite/Face.zig | 2 +- src/input/Binding.zig | 2 +- src/input/key_encode.zig | 2 +- src/input/mouse.zig | 2 +- src/inspector/Inspector.zig | 2 +- src/renderer/generic.zig | 2 +- src/terminal/Terminal.zig | 2 +- src/terminal/kitty/graphics_unicode.zig | 4 +- src/terminal/ref_counted_set.zig | 8 +- src/terminal/tmux/viewer.zig | 2 +- 18 files changed, 92 insertions(+), 92 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index c0aad46b3..39db13c6d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -5,7 +5,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. override var supportsUpdateAccessory: Bool { false } - + /// 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. fileprivate var isLightTheme: Bool = false @@ -395,7 +395,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // Hide the window drag handle. windowDragHandle?.isHidden = true - // Reenable the main toolbar title + // Re-enable the main toolbar title if let toolbar = toolbar as? TerminalToolbar { toolbar.titleIsHidden = false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 57b889b82..a72436d7f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -7,16 +7,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { /// 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 observation for tab group window changes. private var tabGroupWindowsObservation: NSKeyValueObservation? private var tabBarVisibleObservation: NSKeyValueObservation? - + deinit { tabGroupWindowsObservation?.invalidate() tabBarVisibleObservation?.invalidate() } - + // MARK: NSWindow override func awakeFromNib() { @@ -29,7 +29,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { override func becomeMain() { super.becomeMain() - + guard let lastSurfaceConfig else { return } syncAppearance(lastSurfaceConfig) @@ -42,7 +42,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } } } - + override func update() { super.update() @@ -67,7 +67,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // Save our config in case we need to reapply lastSurfaceConfig = surfaceConfig - // Everytime we change appearance, set KVO up again in case any of our + // Every time we change appearance, set KVO up again in case any of our // references changed (e.g. tabGroup is new). setupKVO() @@ -99,7 +99,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { ? NSColor.clear.cgColor : 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 @@ -108,14 +108,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { @available(macOS 13.0, *) private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { guard let titlebarContainer else { return } - + // Setup the titlebar background color to match ours titlebarContainer.wantsLayer = true titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor - + // See the docs for the function that sets this to true on why effectViewIsHidden = false - + // Necessary to not draw the border around the title titlebarAppearsTransparent = true } @@ -141,7 +141,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // Remove existing observation if any tabGroupWindowsObservation?.invalidate() tabGroupWindowsObservation = nil - + // Check if tabGroup is available guard let tabGroup else { return } @@ -170,7 +170,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // Remove existing observation if any tabBarVisibleObservation?.invalidate() tabBarVisibleObservation = nil - + // Set up KVO observation for isTabBarVisible tabBarVisibleObservation = tabGroup?.observe( \.isTabBarVisible, @@ -181,18 +181,18 @@ 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 diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 6b4eb0ae4..44011d5b9 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -68,7 +68,7 @@ extension Ghostty { if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } // Handle sided input. We can't tell that both are pressed in the - // Ghostty structure but thats okay -- we don't use that information. + // Ghostty structure but that's okay -- we don't use that information. let rawFlags = flags.rawValue if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } @@ -139,7 +139,7 @@ extension Ghostty.Input { case GHOSTTY_ACTION_REPEAT: self.action = .repeat default: self.action = .press } - + // Convert key from keycode guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil } self.key = key @@ -150,18 +150,18 @@ extension Ghostty.Input { } else { self.text = nil } - + // Set composing state self.composing = cValue.composing - + // Convert modifiers self.mods = Mods(cMods: cValue.mods) self.consumedMods = Mods(cMods: cValue.consumed_mods) - + // Set unshifted codepoint self.unshiftedCodepoint = cValue.unshifted_codepoint } - + /// Executes a closure with a temporary C representation of this KeyEvent. /// /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct @@ -180,7 +180,7 @@ extension Ghostty.Input { keyEvent.mods = mods.cMods keyEvent.consumed_mods = consumedMods.cMods keyEvent.unshifted_codepoint = unshiftedCodepoint - + // Handle text with proper memory management if let text = text { return text.withCString { textPtr in @@ -203,7 +203,7 @@ extension Ghostty.Input { case release case press case `repeat` - + var cAction: ghostty_input_action_e { switch self { case .release: GHOSTTY_ACTION_RELEASE @@ -232,7 +232,7 @@ extension Ghostty.Input { let action: MouseState let button: MouseButton let mods: Mods - + init( action: MouseState, button: MouseButton, @@ -242,7 +242,7 @@ extension Ghostty.Input { self.button = button self.mods = mods } - + /// Creates a MouseEvent from C enum values. /// /// This initializer converts C-style mouse input enums to Swift types. @@ -259,7 +259,7 @@ extension Ghostty.Input { case GHOSTTY_MOUSE_PRESS: self.action = .press default: return nil } - + // Convert button switch button { case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown @@ -268,7 +268,7 @@ extension Ghostty.Input { case GHOSTTY_MOUSE_MIDDLE: self.button = .middle default: return nil } - + // Convert modifiers self.mods = Mods(cMods: mods) } @@ -279,7 +279,7 @@ extension Ghostty.Input { let x: Double let y: Double let mods: Mods - + init( x: Double, y: Double, @@ -316,7 +316,7 @@ extension Ghostty.Input { enum MouseState: String, CaseIterable { case release case press - + var cMouseState: ghostty_input_mouse_state_e { switch self { case .release: GHOSTTY_MOUSE_RELEASE @@ -344,7 +344,7 @@ extension Ghostty.Input { case left case right case middle - + var cMouseButton: ghostty_input_mouse_button_e { switch self { case .unknown: GHOSTTY_MOUSE_UNKNOWN @@ -382,18 +382,18 @@ extension Ghostty.Input { /// for scroll events, matching the Zig `ScrollMods` packed struct. struct ScrollMods { let rawValue: Int32 - + /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse) var precision: Bool { rawValue & 0b0000_0001 != 0 } - + /// The momentum phase of the scroll event for inertial scrolling var momentum: Momentum { let momentumBits = (rawValue >> 1) & 0b0000_0111 return Momentum(rawValue: UInt8(momentumBits)) ?? .none } - + init(precision: Bool = false, momentum: Momentum = .none) { var value: Int32 = 0 if precision { @@ -402,11 +402,11 @@ extension Ghostty.Input { value |= Int32(momentum.rawValue) << 1 self.rawValue = value } - + init(rawValue: Int32) { self.rawValue = rawValue } - + var cScrollMods: ghostty_input_scroll_mods_t { rawValue } @@ -425,7 +425,7 @@ extension Ghostty.Input { case ended = 4 case cancelled = 5 case mayBegin = 6 - + var cMomentum: ghostty_input_mouse_momentum_e { switch self { case .none: GHOSTTY_MOUSE_MOMENTUM_NONE @@ -442,7 +442,7 @@ extension Ghostty.Input { extension Ghostty.Input.Momentum: AppEnum { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum") - + static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [ .none: "None", .began: "Began", @@ -479,7 +479,7 @@ extension Ghostty.Input { /// `ghostty_input_mods_e` struct Mods: OptionSet { let rawValue: UInt32 - + static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue) static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue) static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue) @@ -490,23 +490,23 @@ extension Ghostty.Input { static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue) static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue) static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue) - + var cMods: ghostty_input_mods_e { ghostty_input_mods_e(rawValue) } - + init(rawValue: UInt32) { self.rawValue = rawValue } - + init(cMods: ghostty_input_mods_e) { self.rawValue = cMods.rawValue } - + init(nsFlags: NSEvent.ModifierFlags) { self.init(cMods: Ghostty.ghosttyMods(nsFlags)) } - + var nsFlags: NSEvent.ModifierFlags { Ghostty.eventModifierFlags(mods: cMods) } @@ -1120,43 +1120,43 @@ extension Ghostty.Input.Key: AppEnum { return [ // Letters (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 (0-9) .digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9, - + // Common Control Keys .space, .enter, .tab, .backspace, .escape, .delete, - + // Arrow Keys .arrowUp, .arrowDown, .arrowLeft, .arrowRight, - + // Navigation Keys .home, .end, .pageUp, .pageDown, .insert, - + // Function Keys (F1-F20) .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12, .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20, - + // Modifier Keys .shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight, .metaLeft, .metaRight, .capsLock, - + // Punctuation & Symbols .minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash, .semicolon, .quote, .comma, .period, .slash, - + // Numpad .numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5, .numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract, .numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual, .numpadEnter, .numpadComma, - + // Media Keys .audioVolumeUp, .audioVolumeDown, .audioVolumeMute, - + // International Keys .intlBackslash, .intlRo, .intlYen, - + // Other .contextMenu ] @@ -1167,11 +1167,11 @@ extension Ghostty.Input.Key: AppEnum { .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", - + // Numbers (0-9) .digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4", .digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9", - + // Common Control Keys .space: "Space", .enter: "Enter", @@ -1179,26 +1179,26 @@ extension Ghostty.Input.Key: AppEnum { .backspace: "Backspace", .escape: "Escape", .delete: "Delete", - + // Arrow Keys .arrowUp: "Up Arrow", .arrowDown: "Down Arrow", .arrowLeft: "Left Arrow", .arrowRight: "Right Arrow", - + // Navigation Keys .home: "Home", .end: "End", .pageUp: "Page Up", .pageDown: "Page Down", .insert: "Insert", - + // Function Keys (F1-F20) .f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6", .f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12", .f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17", .f18: "F18", .f19: "F19", .f20: "F20", - + // Modifier Keys .shiftLeft: "Left Shift", .shiftRight: "Right Shift", @@ -1209,7 +1209,7 @@ extension Ghostty.Input.Key: AppEnum { .metaLeft: "Left Command", .metaRight: "Right Command", .capsLock: "Caps Lock", - + // Punctuation & Symbols .minus: "Minus (-)", .equal: "Equal (=)", @@ -1222,7 +1222,7 @@ extension Ghostty.Input.Key: AppEnum { .comma: "Comma (,)", .period: "Period (.)", .slash: "Slash (/)", - + // Numpad .numLock: "Num Lock", .numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2", @@ -1236,17 +1236,17 @@ extension Ghostty.Input.Key: AppEnum { .numpadEqual: "Numpad Equal", .numpadEnter: "Numpad Enter", .numpadComma: "Numpad Comma", - + // Media Keys .audioVolumeUp: "Volume Up", .audioVolumeDown: "Volume Down", .audioVolumeMute: "Volume Mute", - + // International Keys .intlBackslash: "International Backslash", .intlRo: "International Ro", .intlYen: "International Yen", - + // Other .contextMenu: "Context Menu" ] diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 78c967661..8ab476267 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -130,7 +130,7 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle { class NonNativeFullscreen: FullscreenBase, FullscreenStyle { var fullscreenMode: FullscreenMode { .nonNative } - + // Non-native fullscreen never supports tabs because tabs require // the "titled" style and we don't have it for non-native fullscreen. var supportsTabs: Bool { false } @@ -223,7 +223,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Being untitled let's our content take up the full frame. window.styleMask.remove(.titled) - // We dont' want the non-native fullscreen window to be resizable + // We don't want the non-native fullscreen window to be resizable // from the edges. window.styleMask.remove(.resizable) @@ -277,7 +277,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if let window = window as? TerminalWindow, window.isTabBar(c) { continue } - + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { window.addTitlebarAccessoryViewController(c) } @@ -286,7 +286,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing "titled" also clears our toolbar window.toolbar = savedState.toolbar window.toolbarStyle = savedState.toolbarStyle - + // 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. @@ -412,7 +412,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.toolbar = window.toolbar self.toolbarStyle = window.toolbarStyle self.dock = window.screen?.hasDock ?? false - + self.titlebarAccessoryViewControllers = if (window.hasTitleBar) { // Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash. window.titlebarAccessoryViewControllers diff --git a/src/Surface.zig b/src/Surface.zig index fd658a43b..c9223d0ad 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1222,7 +1222,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { break :gui false; }) return; - // If a native GUI notification was not showm. update our terminal to + // If a native GUI notification was not shown, update our terminal to // note the abnormal exit. self.childExitedAbnormally(info) catch |err| { log.err("error handling abnormal child exit err={}", .{err}); @@ -1232,7 +1232,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { return; } - // We output a message so that the user knows whats going on and + // We output a message so that the user knows what's 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 diff --git a/src/config/theme.zig b/src/config/theme.zig index 7ba6e5885..8776fb1bf 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -221,7 +221,7 @@ pub fn open( // Unlikely scenario: the theme doesn't exist. In this case, we reset // our iterator, reiterate over in order to build a better error message. - // This does double allocate some memory but for errors I think thats + // This does double allocate some memory but for errors I think that's // fine. it.reset(); while (try it.next()) |loc| { diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 0648c0edf..7dcff8416 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -562,7 +562,7 @@ test "exact fit" { try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); } -test "doesnt fit" { +test "doesn't fit" { const alloc = testing.allocator; var atlas = try init(alloc, 32, .grayscale); defer atlas.deinit(alloc); diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 6b01d79aa..17d7801ff 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -52,7 +52,7 @@ pub const Shaper = struct { /// Cached attributes dict for creating CTTypesetter objects. /// The values in this never change so we can avoid overhead - /// by just creating it once and saving it for re-use. + /// by just creating it once and saving it for reuse. typesetter_attr_dict: *macos.foundation.Dictionary, /// List where we cache fonts, so we don't have to remake them for diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 94bfa2f0b..596a92044 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -405,7 +405,7 @@ fn testDrawRanges( const padding_x = width / 4; const padding_y = height / 4; - // Canvas to draw glyphs on, we'll re-use this for all glyphs. + // Canvas to draw glyphs on, we'll reuse this for all glyphs. var canvas = try font.sprite.Canvas.init( alloc, width, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 83c6ef38f..e7507b112 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2020,7 +2020,7 @@ pub const Set = struct { /// /// `buffer_stream` is a FixedBufferStream used for temporary storage /// that is shared between calls to nested levels of the set. - /// For example, 'a>b>c=x' and 'a>b>d=y' will re-use the 'a>b' written + /// For example, 'a>b>c=x' and 'a>b>d=y' will reuse the 'a>b' written /// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'. pub fn formatEntries( self: Value, diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index 736df58a0..3716c226e 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -153,7 +153,7 @@ fn kitty( // IME confirmation still sends an enter key so if we have enter // and UTF8 text we just send it directly since we assume that is - // whats happening. See legacy()'s similar logic for more details + // what's happening. See legacy()'s similar logic for more details // on how to verify this. if (event.utf8.len > 0) utf8: { switch (event.key) { diff --git a/src/input/mouse.zig b/src/input/mouse.zig index 2be2b9a26..bdf967ed2 100644 --- a/src/input/mouse.zig +++ b/src/input/mouse.zig @@ -10,7 +10,7 @@ pub const ButtonState = enum(c_int) { press, }; -/// Possible mouse buttons. We only track up to 11 because thats the maximum +/// Possible mouse buttons. We only track up to 11 because that's the maximum /// button input that terminal mouse tracking handles without becoming /// ambiguous. /// diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 86a7b473c..dc498b58d 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -1213,7 +1213,7 @@ fn renderTermioWindow(self: *Inspector) void { cimgui.c.igText("%s", ev.str.ptr); // If the event is selected, we render info about it. For now - // we put this in the last column because thats the widest and + // we put this in the last column because that's the widest and // imgui has no way to make a column span. if (ev.imgui_selected) { { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 39eec7b43..4ebe501f7 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2099,7 +2099,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // We also need to reset the shaper cache so shaper info - // from the previous font isn't re-used for the new font. + // from the previous font isn't reused for the new font. const font_shaper_cache = font.ShaperCache.init(); self.font_shaper_cache.deinit(self.alloc); self.font_shaper_cache = font_shaper_cache; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9d8b585..8bb167cd1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -675,7 +675,7 @@ fn printCell( // TODO: this case was not handled in the old terminal implementation // but it feels like we should do something. investigate other - // terminals (xterm mainly) and see whats up. + // terminals (xterm mainly) and see what's up. .spacer_head => {}, } } diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index ceadf63ee..a223797ba 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -256,7 +256,7 @@ pub const Placement = struct { if (img_scale_source.y < img_scaled.y_offset) { // If our source rect y is within the offset area, we need to // adjust our source rect and destination since the source texture - // doesnt actually have the offset area blank. + // doesn't actually have the offset area blank. const offset: f64 = img_scaled.y_offset - img_scale_source.y; img_scale_source.height -= offset; y_offset = offset; @@ -286,7 +286,7 @@ pub const Placement = struct { if (img_scale_source.x < img_scaled.x_offset) { // If our source rect x is within the offset area, we need to // adjust our source rect and destination since the source texture - // doesnt actually have the offset area blank. + // doesn't actually have the offset area blank. const offset: f64 = img_scaled.x_offset - img_scale_source.x; img_scale_source.width -= offset; x_offset = offset; diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index e67682ff5..883dd2f0d 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -215,7 +215,7 @@ pub fn RefCountedSet( OutOfMemory, /// The set needs to be rehashed, as there are many dead - /// items with lower IDs which are inaccessible for re-use. + /// items with lower IDs which are inaccessible for reuse. NeedsRehash, }; @@ -437,7 +437,7 @@ pub fn RefCountedSet( } /// Delete an item, removing any references from - /// the table, and freeing its ID to be re-used. + /// the table, and freeing its ID to be reused. fn deleteItem(self: *Self, base: anytype, id: Id, ctx: Context) void { const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -585,7 +585,7 @@ pub fn RefCountedSet( const item = &items[id]; // If there's a dead item then we resurrect it - // for our value so that we can re-use its ID, + // for our value so that we can reuse its ID, // unless its ID is greater than the one we're // given (i.e. prefer smaller IDs). if (item.meta.ref == 0) { @@ -645,7 +645,7 @@ pub fn RefCountedSet( } // Our chosen ID may have changed if we decided - // to re-use a dead item's ID, so we make sure + // to reuse a dead item's ID, so we make sure // the chosen bucket contains the correct ID. table[new_item.meta.bucket] = chosen_id; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 0fcaaf207..62a0f1d00 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -208,7 +208,7 @@ pub const Viewer = struct { /// caller is responsible for diffing the new window list against /// the prior one. Remember that for a given Viewer, window IDs /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) - /// never re-uses window IDs within a server process lifetime. + /// never reuses window IDs within a server process lifetime. windows: []const Window, pub fn format(self: Action, writer: *std.Io.Writer) !void { From f54ac110802a71ac26b7e6c62008085425a7da09 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 25 Dec 2025 13:36:43 -0800 Subject: [PATCH 265/605] terminal: search will re-scroll to navigate to a single match Fixes #9958 Replaces #9989 This changes the search navigation logic to always scroll if there is a selected search result so long as the search result isn't already within the viewport. --- src/terminal/PageList.zig | 9 +++++++ src/terminal/search/Thread.zig | 48 +++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9e14e2a75..07b264ef5 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3821,6 +3821,15 @@ pub const PageIterator = struct { pub fn fullPage(self: Chunk) bool { return self.start == 0 and self.end == self.node.data.size.rows; } + + /// Returns true if this chunk overlaps with the given other chunk + /// in any way. + pub fn overlaps(self: Chunk, other: Chunk) bool { + if (self.node != other.node) return false; + if (self.end <= other.start) return false; + if (self.start >= other.end) return false; + return true; + } }; }; diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 8f2d73f16..3f5377417 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -257,18 +257,46 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { self.opts.mutex.lock(); defer self.opts.mutex.unlock(); - // The selection will trigger a selection change notification - // if it did change. - if (try screen_search.select(sel)) scroll: { - if (screen_search.selected) |m| { - // Selection changed, let's scroll the viewport to see it - // since we have the lock anyways. - const screen = self.opts.terminal.screens.get( - s.last_screen.key, - ) orelse break :scroll; - screen.scroll(.{ .pin = m.highlight.start.* }); + // Make the selection. Ignore the result because we don't + // care if the selection didn't change. + _ = try screen_search.select(sel); + + // Grab our match if we have one. If we don't have a selection + // then we do nothing. + const flattened = screen_search.selectedMatch() orelse return; + + // No matter what we reset our selected match cache. This will + // trigger a callback which will trigger the renderer to wake up + // so it can be notified the screen scrolled. + s.last_screen.selected = null; + + // Grab the current screen and see if this match is visible within + // the viewport already. If it is, we do nothing. + const screen = self.opts.terminal.screens.get( + s.last_screen.key, + ) orelse return; + + // Grab the viewport. Viewports and selections are usually small + // so this check isn't very expensive, despite appearing O(N^2), + // both Ns are usually equal to 1. + var it = screen.pages.pageIterator( + .right_down, + .{ .viewport = .{} }, + null, + ); + const hl_chunks = flattened.chunks.slice(); + while (it.next()) |chunk| { + for (0..hl_chunks.len) |i| { + const hl_chunk = hl_chunks.get(i); + if (chunk.overlaps(.{ + .node = hl_chunk.node, + .start = hl_chunk.start, + .end = hl_chunk.end, + })) return; } } + + screen.scroll(.{ .pin = flattened.startPin() }); } /// Change the search term to the given value. From 2415116ad0680c9ae2d18f8445e067b24830ca49 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 25 Dec 2025 13:52:59 -0800 Subject: [PATCH 266/605] Revert "macOS: move `NSGlassEffectView` into `TerminalViewContainer` (#10046)" This reverts commit b8490f40c5a88d3ef879043d05a5493f24e123d4, reversing changes made to 050278feaeb79fe9d849fca48e445acadcd74397. --- macos/Ghostty.xcodeproj/project.pbxproj | 1 - .../QuickTerminalController.swift | 13 +- .../Terminal/BaseTerminalController.swift | 2 +- .../Terminal/TerminalController.swift | 4 +- .../Terminal/TerminalViewContainer.swift | 127 ------------------ .../Window Styles/TerminalWindow.swift | 62 ++++++++- macos/Sources/Ghostty/Ghostty.Config.swift | 12 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 - 8 files changed, 68 insertions(+), 156 deletions(-) delete mode 100644 macos/Sources/Features/Terminal/TerminalViewContainer.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index feda1bed0..91c2300cc 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -118,7 +118,6 @@ Features/Terminal/TerminalRestorable.swift, Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, - Features/Terminal/TerminalViewContainer.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 07c0c4c19..8a642034f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = TerminalViewContainer( + window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self - ) + )) // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController { // If we have window transparency then set it transparent. Otherwise set it opaque. // Also check if the user has overridden transparency to be fully opaque. - if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) { + if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -617,9 +617,7 @@ class QuickTerminalController: BaseTerminalController { // Terminal.app more easily. window.backgroundColor = .white.withAlphaComponent(0.001) - if !derivedConfig.backgroundBlur.isGlassStyle { - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) - } + ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) } else { window.isOpaque = true window.backgroundColor = .windowBackgroundColor @@ -724,7 +722,6 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double - let backgroundBlur: Ghostty.Config.BackgroundBlur init() { self.quickTerminalScreen = .main @@ -733,7 +730,6 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = .move self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 - self.backgroundBlur = .disabled } init(_ config: Ghostty.Config) { @@ -743,7 +739,6 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity - self.backgroundBlur = config.backgroundBlur } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1750e949d..d79c89d2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1298,7 +1298,7 @@ extension BaseTerminalController: NSMenuItemValidation { } else { scheme = GHOSTTY_COLOR_SCHEME_LIGHT } - guard scheme != appliedColorScheme, !surfaceTree.isEmpty else { + guard scheme != appliedColorScheme else { return } for surfaceView in surfaceTree { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c5481851b..bccdd9c69 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -936,11 +936,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = TerminalViewContainer( + window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self, - ) + )) // If we have a default size, we want to apply it. if let defaultSize { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift deleted file mode 100644 index f4e2fc080..000000000 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ /dev/null @@ -1,127 +0,0 @@ -import AppKit -import SwiftUI - -/// Use this container to achieve a glass effect at the window level. -/// Modifying `NSThemeFrame` can sometimes be unpredictable. -class TerminalViewContainer: NSView { - private let terminalView: NSView - - /// Glass effect view for liquid glass background when transparency is enabled - private var glassEffectView: NSView? - private var derivedConfig: DerivedConfig - - init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { - self.derivedConfig = DerivedConfig(config: ghostty.config) - self.terminalView = NSHostingView(rootView: TerminalView( - ghostty: ghostty, - viewModel: viewModel, - delegate: delegate - )) - super.init(frame: .zero) - setup() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setup() { - addSubview(terminalView) - terminalView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - terminalView.topAnchor.constraint(equalTo: topAnchor), - terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), - terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), - terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - - NotificationCenter.default.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil - ) - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - updateGlassEffectIfNeeded() - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - let newValue = DerivedConfig(config: config) - guard newValue != derivedConfig else { return } - derivedConfig = newValue - DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) - } -} - -// MARK: Glass - -private extension TerminalViewContainer { -#if compiler(>=6.2) - @available(macOS 26.0, *) - func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { - if let existed = glassEffectView as? NSGlassEffectView { - return existed - } - guard let themeFrameView = window?.contentView?.superview else { - return nil - } - let effectView = NSGlassEffectView() - addSubview(effectView, positioned: .below, relativeTo: terminalView) - effectView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - glassEffectView = effectView - return effectView - } -#endif // compiler(>=6.2) - - func updateGlassEffectIfNeeded() { -#if compiler(>=6.2) - guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { - glassEffectView?.removeFromSuperview() - glassEffectView = nil - return - } - guard let effectView = addGlassEffectViewIfNeeded() else { - return - } - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular - case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear - default: - break - } - let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) - effectView.tintColor = backgroundColor - .withAlphaComponent(derivedConfig.backgroundOpacity) - if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { - effectView.cornerRadius = cornerRadius - } -#endif // compiler(>=6.2) - } - - struct DerivedConfig: Equatable { - var backgroundOpacity: Double = 0 - var backgroundBlur: Ghostty.Config.BackgroundBlur - var backgroundColor: Color = .clear - - init(config: Ghostty.Config) { - self.backgroundBlur = config.backgroundBlur - self.backgroundOpacity = config.backgroundOpacity - self.backgroundColor = config.backgroundColor - } - } -} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9debd2cb3..4196df97f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -474,7 +474,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) + surfaceConfig.backgroundOpacity < 1 { isOpaque = false @@ -483,8 +483,15 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - // We don't need to set blur when using glass - if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { + // Add liquid glass behind terminal content + if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { + setupGlassLayer() + } else if let appDelegate = NSApp.delegate as? AppDelegate { + // If we had a prior glass layer we should remove it + if #available(macOS 26.0, *) { + removeGlassLayer() + } + ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -492,6 +499,11 @@ class TerminalWindow: NSWindow { } else { isOpaque = true + // Remove liquid glass when not transparent + if #available(macOS 26.0, *) { + removeGlassLayer() + } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -569,6 +581,50 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } + +#if compiler(>=6.2) + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + // Remove existing glass effect view + removeGlassLayer() + + // Get the window content view (parent of the NSHostingView) + guard let contentView else { return } + guard let windowContentView = contentView.superview else { return } + + // Create NSGlassEffectView for native glass effect + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + // Should not reach here since we check for glass style before calling + // setupGlassLayer() + assertionFailure() + } + + effectView.cornerRadius = derivedConfig.windowCornerRadius + effectView.tintColor = preferredBackgroundColor + effectView.frame = windowContentView.bounds + effectView.autoresizingMask = [.width, .height] + + // Position BELOW the terminal content to act as background + windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) + glassEffectView = effectView + } + + @available(macOS 26.0, *) + private func removeGlassLayer() { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + } +#endif // compiler(>=6.2) // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b3a8700e9..5aa79a149 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -658,17 +658,9 @@ extension Ghostty.Config { case 0: self = .disabled case -1: - if #available(macOS 26.0, *) { - self = .macosGlassRegular - } else { - self = .disabled - } + self = .macosGlassRegular case -2: - if #available(macOS 26.0, *) { - self = .macosGlassClear - } else { - self = .disabled - } + self = .macosGlassClear default: self = .radius(Int(value)) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 77e1c43d4..37cc9282e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1654,7 +1654,6 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double - let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1663,7 +1662,6 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 - self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1673,7 +1671,6 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity - self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From 1c90af3569d3591f735678987639885783e97e20 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:29:14 +0100 Subject: [PATCH 267/605] macOS: move `NSGlassEffectView` into `TerminalViewContainer` --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../QuickTerminalController.swift | 13 +- .../Terminal/TerminalController.swift | 4 +- .../Terminal/TerminalViewContainer.swift | 127 ++++++++++++++++++ .../Window Styles/TerminalWindow.swift | 62 +-------- macos/Sources/Ghostty/Ghostty.Config.swift | 12 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 + 7 files changed, 155 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalViewContainer.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 91c2300cc..feda1bed0 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ Features/Terminal/TerminalRestorable.swift, Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, + Features/Terminal/TerminalViewContainer.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8a642034f..07c0c4c19 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self - )) + ) // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController { // If we have window transparency then set it transparent. Otherwise set it opaque. // Also check if the user has overridden transparency to be fully opaque. - if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { + if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -617,7 +617,9 @@ class QuickTerminalController: BaseTerminalController { // Terminal.app more easily. window.backgroundColor = .white.withAlphaComponent(0.001) - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + if !derivedConfig.backgroundBlur.isGlassStyle { + ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + } } else { window.isOpaque = true window.backgroundColor = .windowBackgroundColor @@ -722,6 +724,7 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur init() { self.quickTerminalScreen = .main @@ -730,6 +733,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = .move self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 + self.backgroundBlur = .disabled } init(_ config: Ghostty.Config) { @@ -739,6 +743,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bccdd9c69..c5481851b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -936,11 +936,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self, - )) + ) // If we have a default size, we want to apply it. if let defaultSize { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift new file mode 100644 index 000000000..f4e2fc080 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -0,0 +1,127 @@ +import AppKit +import SwiftUI + +/// Use this container to achieve a glass effect at the window level. +/// Modifying `NSThemeFrame` can sometimes be unpredictable. +class TerminalViewContainer: NSView { + private let terminalView: NSView + + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + private var derivedConfig: DerivedConfig + + init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { + self.derivedConfig = DerivedConfig(config: ghostty.config) + self.terminalView = NSHostingView(rootView: TerminalView( + ghostty: ghostty, + viewModel: viewModel, + delegate: delegate + )) + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + addSubview(terminalView) + terminalView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + terminalView.topAnchor.constraint(equalTo: topAnchor), + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), + terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateGlassEffectIfNeeded() + } + + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + let newValue = DerivedConfig(config: config) + guard newValue != derivedConfig else { return } + derivedConfig = newValue + DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) + } +} + +// MARK: Glass + +private extension TerminalViewContainer { +#if compiler(>=6.2) + @available(macOS 26.0, *) + func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { + if let existed = glassEffectView as? NSGlassEffectView { + return existed + } + guard let themeFrameView = window?.contentView?.superview else { + return nil + } + let effectView = NSGlassEffectView() + addSubview(effectView, positioned: .below, relativeTo: terminalView) + effectView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + glassEffectView = effectView + return effectView + } +#endif // compiler(>=6.2) + + func updateGlassEffectIfNeeded() { +#if compiler(>=6.2) + guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + return + } + guard let effectView = addGlassEffectViewIfNeeded() else { + return + } + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + break + } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + effectView.tintColor = backgroundColor + .withAlphaComponent(derivedConfig.backgroundOpacity) + if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { + effectView.cornerRadius = cornerRadius + } +#endif // compiler(>=6.2) + } + + struct DerivedConfig: Equatable { + var backgroundOpacity: Double = 0 + var backgroundBlur: Ghostty.Config.BackgroundBlur + var backgroundColor: Color = .clear + + init(config: Ghostty.Config) { + self.backgroundBlur = config.backgroundBlur + self.backgroundOpacity = config.backgroundOpacity + self.backgroundColor = config.backgroundColor + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4196df97f..9debd2cb3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -474,7 +474,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - surfaceConfig.backgroundOpacity < 1 + (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) { isOpaque = false @@ -483,15 +483,8 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { - setupGlassLayer() - } else if let appDelegate = NSApp.delegate as? AppDelegate { - // If we had a prior glass layer we should remove it - if #available(macOS 26.0, *) { - removeGlassLayer() - } - + // We don't need to set blur when using glass + if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -499,11 +492,6 @@ class TerminalWindow: NSWindow { } else { isOpaque = true - // Remove liquid glass when not transparent - if #available(macOS 26.0, *) { - removeGlassLayer() - } - let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -581,50 +569,6 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - -#if compiler(>=6.2) - // MARK: Glass - - @available(macOS 26.0, *) - private func setupGlassLayer() { - // Remove existing glass effect view - removeGlassLayer() - - // Get the window content view (parent of the NSHostingView) - guard let contentView else { return } - guard let windowContentView = contentView.superview else { return } - - // Create NSGlassEffectView for native glass effect - let effectView = NSGlassEffectView() - - // Map Ghostty config to NSGlassEffectView style - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular - case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear - default: - // Should not reach here since we check for glass style before calling - // setupGlassLayer() - assertionFailure() - } - - effectView.cornerRadius = derivedConfig.windowCornerRadius - effectView.tintColor = preferredBackgroundColor - effectView.frame = windowContentView.bounds - effectView.autoresizingMask = [.width, .height] - - // Position BELOW the terminal content to act as background - windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) - glassEffectView = effectView - } - - @available(macOS 26.0, *) - private func removeGlassLayer() { - glassEffectView?.removeFromSuperview() - glassEffectView = nil - } -#endif // compiler(>=6.2) // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 5aa79a149..b3a8700e9 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -658,9 +658,17 @@ extension Ghostty.Config { case 0: self = .disabled case -1: - self = .macosGlassRegular + if #available(macOS 26.0, *) { + self = .macosGlassRegular + } else { + self = .disabled + } case -2: - self = .macosGlassClear + if #available(macOS 26.0, *) { + self = .macosGlassClear + } else { + self = .disabled + } default: self = .radius(Int(value)) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 37cc9282e..77e1c43d4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1654,6 +1654,7 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1662,6 +1663,7 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 + self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1671,6 +1673,7 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From 88e471e015083e6970b81e66e3e6b4719004d5ed Mon Sep 17 00:00:00 2001 From: Zongyuan Li Date: Fri, 26 Dec 2025 18:33:00 +0800 Subject: [PATCH 268/605] fix(iOS): fix iOS app startup failure Fixes #7643 This commit address the issue with 3 minor fixes: 1. Initialize ghostty lib before app start, or global allocator will be null. 2. `addSublayer` should be called on CALayer object, which is the property 'layer' of UIView 3. According to apple's [document](https://developer.apple.com/documentation/metal/mtlstoragemode/managed?language=objc), managed storage mode is not supported by iOS. So always use shared mode. FYI, another [fix](https://github.com/mitchellh/libxev/pull/204) in libxev is also required to make iOS app work. --- macos/Sources/App/iOS/iOSApp.swift | 10 +++++++++- src/renderer/Metal.zig | 10 +++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/iOS/iOSApp.swift b/macos/Sources/App/iOS/iOSApp.swift index 4af94491c..a1aafcc7d 100644 --- a/macos/Sources/App/iOS/iOSApp.swift +++ b/macos/Sources/App/iOS/iOSApp.swift @@ -1,8 +1,16 @@ import SwiftUI +import GhosttyKit @main struct Ghostty_iOSApp: App { - @StateObject private var ghostty_app = Ghostty.App() + @StateObject private var ghostty_app: Ghostty.App + + init() { + if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS { + preconditionFailure("Initialize ghostty backend failed") + } + _ghostty_app = StateObject(wrappedValue: Ghostty.App()) + } var body: some Scene { WindowGroup { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 2aac285c6..6c7432d21 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -76,8 +76,11 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { errdefer queue.release(); // Grab metadata about the device. - const default_storage_mode: mtl.MTLResourceOptions.StorageMode = - if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed; + const default_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) { + // manage mode is not supported by iOS + .ios => .shared, + else => if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed, + }; const max_texture_size = queryMaxTextureSize(device); log.debug( "device properties default_storage_mode={} max_texture_size={}", @@ -123,7 +126,8 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { }, .ios => { - info.view.msgSend(void, objc.sel("addSublayer"), .{layer.layer.value}); + const view_layer = objc.Object.fromId(info.view.getProperty(?*anyopaque, "layer")); + view_layer.msgSend(void, objc.sel("addSublayer:"), .{layer.layer.value}); }, else => @compileError("unsupported target for Metal"), From 6ab884d69f8854351dd140ed9a9fabf9c526488c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:35:25 +0100 Subject: [PATCH 269/605] macOS: fix intrinsicContentSize of `TerminalViewContainer` --- .../Sources/Features/Terminal/TerminalViewContainer.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index f4e2fc080..1765edec3 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -26,6 +26,13 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } + /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` + /// work in ``TerminalController/windowDidLoad()``, + /// we override this to provide the correct size. + override var intrinsicContentSize: NSSize { + terminalView.intrinsicContentSize + } + private func setup() { addSubview(terminalView) terminalView.translatesAutoresizingMaskIntoConstraints = false From 79cc22e18685709aa2d8e1657a7492e5f9030d6d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 07:28:41 -0800 Subject: [PATCH 270/605] terminal: fix crash when sliding window sees empty node Related to #10063 This fixes a crash that can happen if the SlidingWindow search portion sees a zero-byte page. We have more fixes to implement in the circular buffer handling but putting the fix at this layer also prevents some unnecessary allocations for zero-byte data. --- src/terminal/search/sliding_window.zig | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 3d64042ce..c3c29e085 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -575,9 +575,16 @@ pub const SlidingWindow = struct { ); } + // If our written data is empty, then there is nothing to + // add to our data set. + const written = encoded.written(); + if (written.len == 0) { + self.assertIntegrity(); + return 0; + } + // Get our written data. If we're doing a reverse search then we // need to reverse all our encodings. - const written = encoded.written(); switch (self.direction) { .forward => {}, .reverse => { @@ -1637,3 +1644,33 @@ test "SlidingWindow single append reversed soft wrapped" { try testing.expect(w.next() == null); try testing.expect(w.next() == null); } + +// This tests a real bug that occurred where a whitespace-only page +// that encodes to zero bytes would crash. +test "SlidingWindow append whitespace only node" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "x"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + + // By setting the empty page to wrap we get a zero-byte page. + // This is invasive but its otherwise hard to reproduce naturally + // without creating a slow test. + const node: *PageList.List.Node = s.pages.pages.first.?; + const last_row = node.data.getRow(node.data.size.rows - 1); + last_row.wrap = true; + + try testing.expect(s.pages.pages.first == s.pages.pages.last); + _ = try w.append(node); + + // No matches expected + try testing.expect(w.next() == null); +} From eb5d2e034bed4875deb84278aa3db9d34b29c243 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 10:33:50 -0800 Subject: [PATCH 271/605] datastruct/circ_buf: fix n=0 edge cases From #10063 This fixes and tests various edge cases around noop operations. --- src/datastruct/circ_buf.zig | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 0caa9e85d..3e373cb94 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -217,6 +217,13 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { pub fn deleteOldest(self: *Self, n: usize) void { assert(n <= self.storage.len); + // Special case n == 0 otherwise we will accidentally break + // our circular buffer. + if (n == 0) { + @branchHint(.cold); + return; + } + // Clear the values back to default const slices = self.getPtrSlice(0, n); inline for (slices) |slice| @memset(slice, default); @@ -233,6 +240,12 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// the end of our buffer. This never "rotates" the buffer because /// the offset can only be within the size of the buffer. pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T { + // Special case the empty slice fast-path. + if (slice_len == 0) { + @branchHint(.cold); + return .{ &.{}, &.{} }; + } + // Note: this assertion is very important, it hints the compiler // which generates ~10% faster code than without it. assert(offset + slice_len <= self.capacity()); @@ -779,3 +792,75 @@ test "CircBuf resize shrink" { try testing.expectEqual(@as(u8, 3), slices[0][2]); } } + +test "CircBuf append empty slice" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + // Appending an empty slice to empty buffer should be a no-op + buf.appendSliceAssumeCapacity(""); + try testing.expectEqual(@as(usize, 0), buf.len()); + try testing.expect(!buf.full); + + // Buffer should still work normally after appending empty slice + buf.appendSliceAssumeCapacity("hi"); + try testing.expectEqual(@as(usize, 2), buf.len()); + + // Appending an empty slice to non-empty buffer should also be a no-op + buf.appendSliceAssumeCapacity(""); + try testing.expectEqual(@as(usize, 2), buf.len()); +} + +test "CircBuf getPtrSlice zero length" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + // getPtrSlice with zero length on empty buffer should return empty slices + const slices = buf.getPtrSlice(0, 0); + try testing.expectEqual(@as(usize, 0), slices[0].len); + try testing.expectEqual(@as(usize, 0), slices[1].len); + try testing.expectEqual(@as(usize, 0), buf.len()); + + // Fill buffer partially + buf.appendSliceAssumeCapacity("abc"); + try testing.expectEqual(@as(usize, 3), buf.len()); + + // getPtrSlice with zero length on non-empty buffer should also work + const slices2 = buf.getPtrSlice(0, 0); + try testing.expectEqual(@as(usize, 0), slices2[0].len); + try testing.expectEqual(@as(usize, 0), slices2[1].len); + try testing.expectEqual(@as(usize, 3), buf.len()); +} + +test "CircBuf deleteOldest zero" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + // deleteOldest(0) on empty buffer should be a no-op + buf.deleteOldest(0); + try testing.expectEqual(@as(usize, 0), buf.len()); + + // Fill buffer + buf.appendSliceAssumeCapacity("hello"); + try testing.expectEqual(@as(usize, 5), buf.len()); + + // deleteOldest(0) on non-empty buffer should be a no-op + buf.deleteOldest(0); + try testing.expectEqual(@as(usize, 5), buf.len()); + + // Verify data is unchanged + var it = buf.iterator(.forward); + try testing.expectEqual(@as(u8, 'h'), it.next().?.*); +} From 26b104c9e0cc2c013480cc378cf62836cc24d64f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 10:43:35 -0800 Subject: [PATCH 272/605] terminal: Fix possible crash on RenderState with invalid mouse point Fixes #10032 --- src/terminal/render.zig | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 093476f2c..9d75fe4b7 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -816,6 +816,12 @@ pub const RenderState = struct { const row_pins = row_slice.items(.pin); const row_cells = row_slice.items(.cells); + // Our viewport point is sent in by the caller and can't be trusted. + // If it is outside the valid area then just return empty because + // we can't possibly have a link there. + if (viewport_point.x >= self.cols or + viewport_point.y >= row_pins.len) return result; + // Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; const link_page: *page.Page = &link_pin.node.data; @@ -1360,6 +1366,44 @@ test "linkCells with scrollback spanning pages" { try testing.expectEqual(@as(usize, 4), cells.count()); } +test "linkCells with invalid viewport point" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Row out of bound + { + var cells = try state.linkCells( + alloc, + .{ .x = 0, .y = t.rows + 10 }, + ); + defer cells.deinit(alloc); + try testing.expectEqual(0, cells.count()); + } + + // Col out of bound + { + var cells = try state.linkCells( + alloc, + .{ .x = t.cols + 10, .y = 0 }, + ); + defer cells.deinit(alloc); + try testing.expectEqual(0, cells.count()); + } +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; From 14f592b8d4651c1257763fbcb7ab04f96988bb8d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 11:01:44 -0800 Subject: [PATCH 273/605] macOS: Don't duplicate command palette entries for terminal commands This is a regression introduced when we added macOS support for custom entries. I mistakingly thought that only custom entries were in the config, but we do initialize it with all! --- include/ghostty.h | 1 - .../App Intents/Entities/CommandEntity.swift | 23 ++++++------- .../TerminalCommandPalette.swift | 32 ++++--------------- macos/Sources/Ghostty/Ghostty.Surface.swift | 11 ------- src/apprt/embedded.zig | 17 ---------- 5 files changed, 19 insertions(+), 65 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index d6e6fba70..0ad15cf69 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1050,7 +1050,6 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); -void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); diff --git a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift index f7abcc6de..3c7745e7c 100644 --- a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift @@ -1,4 +1,5 @@ import AppIntents +import Cocoa // MARK: AppEntity @@ -94,23 +95,23 @@ struct CommandQuery: EntityQuery { @MainActor func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } + let commands = appDelegate.ghostty.config.commandPaletteEntries + // Extract unique terminal IDs to avoid fetching duplicates let terminalIds = Set(identifiers.map(\.terminalId)) let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds)) - // Build a cache of terminals and their available commands - // This avoids repeated command fetching for the same terminal - typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command]) - let commandMap: [TerminalEntity.ID: Tuple] = + // Build a lookup from terminal ID to terminal entity + let terminalMap: [TerminalEntity.ID: TerminalEntity] = terminals.reduce(into: [:]) { result, terminal in - guard let commands = try? terminal.surfaceModel?.commands() else { return } - result[terminal.id] = (terminal: terminal, commands: commands) + result[terminal.id] = terminal } - + // Map each identifier to its corresponding CommandEntity. If a command doesn't // exist it maps to nil and is removed via compactMap. return identifiers.compactMap { id in - guard let (terminal, commands) = commandMap[id.terminalId], + guard let terminal = terminalMap[id.terminalId], let command = commands.first(where: { $0.actionKey == id.actionKey }) else { return nil } @@ -121,8 +122,8 @@ struct CommandQuery: EntityQuery { @MainActor func suggestedEntities() async throws -> [CommandEntity] { - guard let terminal = commandPaletteIntent?.terminal, - let surface = terminal.surfaceModel else { return [] } - return try surface.commands().map { CommandEntity($0, for: terminal) } + guard let appDelegate = NSApp.delegate as? AppDelegate, + let terminal = commandPaletteIntent?.terminal else { return [] } + return appDelegate.ghostty.config.commandPaletteEntries.map { CommandEntity($0, for: terminal) } } } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 6efb588cd..e0237f257 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,7 +64,7 @@ struct TerminalCommandPaletteView: View { // Sort the rest. We replace ":" with a character that sorts before space // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker // for stable ordering when titles are equal. - options.append(contentsOf: (jumpOptions + terminalOptions + customEntries).sorted { a, b in + options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) @@ -83,11 +83,11 @@ struct TerminalCommandPaletteView: View { /// Commands for installing or canceling available updates. private var updateOptions: [CommandOption] { var options: [CommandOption] = [] - + guard let updateViewModel, updateViewModel.state.isInstallable else { return options } - + // We override the update available one only because we want to properly // convey it'll go all the way through. let title: String @@ -96,7 +96,7 @@ struct TerminalCommandPaletteView: View { } else { title = updateViewModel.text } - + options.append(CommandOption( title: title, description: updateViewModel.description, @@ -106,37 +106,19 @@ struct TerminalCommandPaletteView: View { ) { (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() }) - + options.append(CommandOption( title: "Cancel or Skip Update", description: "Dismiss the current update process" ) { updateViewModel.state.cancel() }) - + return options } - /// Commands exposed by the terminal surface. - private var terminalOptions: [CommandOption] { - guard let surface = surfaceView.surfaceModel else { return [] } - do { - return try surface.commands().map { c in - CommandOption( - title: c.title, - description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, - ) { - onAction(c.action) - } - } - } catch { - return [] - } - } - /// Custom commands from the command-palette-entry configuration. - private var customEntries: [CommandOption] { + private var terminalOptions: [CommandOption] { guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } return appDelegate.ghostty.config.commandPaletteEntries.map { c in CommandOption( diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index c7198e147..e86952e50 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -134,16 +134,5 @@ extension Ghostty { ghostty_surface_binding_action(surface, cString, UInt(len - 1)) } } - - /// Command options for this surface. - @MainActor - func commands() throws -> [Command] { - var ptr: UnsafeMutablePointer? = nil - var count: Int = 0 - ghostty_surface_commands(surface, &ptr, &count) - guard let ptr else { throw Error.apiFailed } - let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported } - } } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1cb9231bc..64900cef1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1700,23 +1700,6 @@ pub const CAPI = struct { return @intCast(@as(input.Mods.Backing, @bitCast(result))); } - /// Returns the current possible commands for a surface - /// in the output parameter. The memory is owned by libghostty - /// and doesn't need to be freed. - export fn ghostty_surface_commands( - surface: *Surface, - out: *[*]const input.Command.C, - len: *usize, - ) void { - // In the future we may use this information to filter - // some commands. - _ = surface; - - const commands = input.command.defaultsC; - out.* = commands.ptr; - len.* = commands.len; - } - /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. From 03ecc9fdbfb7e36f4d6beea2b8db6e5baab96c83 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:16:19 +0000 Subject: [PATCH 274/605] 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 373c97aba..fc94c081c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", - .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", + .hash = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ad3c39da..a8d21dd96 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { + "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", - "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", + "hash": "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 9627286dd..eb9b90bea 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; + name = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz"; - hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz"; + hash = "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 484e1949b..5b1946049 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -5,7 +5,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 3b17b64a6..0edc6ab43 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", - "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", - "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", + "dest": "vendor/p/N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU", + "sha256": "70c2040d91587629426af84be69590ea345eac65054992232f06f1368b2c49e8" }, { "type": "archive", From e63a4ab77464068ba1e6ad920664a7b39c41f5c0 Mon Sep 17 00:00:00 2001 From: -k Date: Sun, 28 Dec 2025 07:21:58 -0500 Subject: [PATCH 275/605] build: fix pkgs for FBSD port runs --- pkg/cimgui/build.zig | 4 ++++ pkg/freetype/build.zig | 4 ++++ pkg/glslang/build.zig | 4 ++++ pkg/spirv-cross/build.zig | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig index b94f11943..890873ef9 100644 --- a/pkg/cimgui/build.zig +++ b/pkg/cimgui/build.zig @@ -72,6 +72,10 @@ pub fn build(b: *std.Build) !void { }); } + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (imgui_) |imgui| { lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items }); lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items }); diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index a25dc18da..e0a041be7 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -90,6 +90,10 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu "-fno-sanitize=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + const dynamic_link_opts = options.dynamic_link_opts; // Zlib diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index 746a41497..da9a82e31 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -66,6 +66,10 @@ fn buildGlslang( "-fno-sanitize-trap=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (upstream_) |upstream| { lib.addCSourceFiles(.{ .root = upstream.path(""), diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index 003ec43cf..31af1974e 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -74,6 +74,10 @@ fn buildSpirvCross( "-fno-sanitize-trap=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (b.lazyDependency("spirv_cross", .{})) |upstream| { lib.addIncludePath(upstream.path("")); module.addIncludePath(upstream.path("")); From b7a12effce2b99186650518018785e3fb07fe66f Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 20 Dec 2025 10:59:17 -0800 Subject: [PATCH 276/605] Only use macOS 26.0 workarounds on macOS 26.0 --- .../TitlebarTabsTahoeTerminalWindow.swift | 3 +- macos/Sources/Ghostty/SurfaceScrollView.swift | 31 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 5d910d2e0..b18fff291 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -322,7 +322,8 @@ extension TitlebarTabsTahoeTerminalWindow { } 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. This appears fixed in 26.1 Beta but keep it safe for 26.0. + // know. On macOS 26.1+ the view no longer disappears, but the + // toolbar still logs an ambiguous content size warning. Color.clear.frame(width: 1, height: 1) } } diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 157136136..b55f2e231 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -120,18 +120,20 @@ class SurfaceScrollView: NSView { self?.handleScrollerStyleChange() }) - // Listen for frame change events. See the docstring for - // handleFrameChange for why this is necessary. - observers.append(NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: nil, - // Since this observer is used to immediately override the event - // that produced the notification, we let it run synchronously on - // the posting thread. - queue: nil - ) { [weak self] notification in - self?.handleFrameChange(notification) - }) + // Listen for frame change events on macOS 26.0. See the docstring for + // handleFrameChangeForNSScrollPocket for why this is necessary. + if #unavailable(macOS 26.1) { if #available(macOS 26.0, *) { + observers.append(NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: nil, + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil + ) { [weak self] notification in + self?.handleFrameChangeForNSScrollPocket(notification) + }) + }} // Listen for derived config changes to update scrollbar settings live surfaceView.$derivedConfig @@ -328,7 +330,10 @@ class SurfaceScrollView: NSView { /// and reset their frame to zero. /// /// See also https://developer.apple.com/forums/thread/798392. - private func handleFrameChange(_ notification: Notification) { + /// + /// This bug is only present in macOS 26.0. + @available(macOS, introduced: 26.0, obsoleted: 26.1) + private func handleFrameChangeForNSScrollPocket(_ notification: Notification) { guard let window = window as? HiddenTitlebarTerminalWindow else { return } guard !window.styleMask.contains(.fullScreen) else { return } guard let view = notification.object as? NSView else { return } From 6d361933780e5f1462bad3c9b56865caceaeb0f9 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Sun, 28 Dec 2025 12:30:36 -0800 Subject: [PATCH 277/605] deps: update z2d to v0.10.0 Release notes at: https://github.com/vancluever/z2d/blob/v0.10.0/CHANGELOG.md Mainly a maintenance update with regards to Ghostty use, with a couple of minor changes required to some type references to match new API semantics. --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- src/font/sprite/canvas.zig | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index fc94c081c..191c1bec9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -21,8 +21,8 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", - .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", .lazy = true, }, .zig_objc = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index a8d21dd96..6ab567279 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -139,10 +139,10 @@ "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", "hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=" }, - "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { + "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": { "name": "z2d", - "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", - "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index eb9b90bea..905ee1ec3 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -307,11 +307,11 @@ in }; } { - name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; + name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ"; path = fetchZigArtifact { name = "z2d"; - url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz"; - hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; + url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz"; + hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 5b1946049..09cf8007c 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -26,10 +26,10 @@ https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.ta https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz -https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz +https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 0edc6ab43..03907fba1 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -169,9 +169,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", - "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", - "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", + "sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7" }, { "type": "archive", diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index 19d27eb45..7904f20a5 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -360,7 +360,7 @@ pub const Canvas = struct { pub fn strokePath( self: *Canvas, path: z2d.Path, - opts: z2d.painter.StrokeOpts, + opts: z2d.painter.StrokeOptions, color: Color, ) z2d.painter.StrokeError!void { try z2d.painter.stroke( @@ -380,7 +380,7 @@ pub const Canvas = struct { pub fn innerStrokePath( self: *Canvas, path: z2d.Path, - opts: z2d.painter.StrokeOpts, + opts: z2d.painter.StrokeOptions, color: Color, ) (z2d.painter.StrokeError || z2d.painter.FillError)!void { // On one surface we fill the shape, this will be a mask we @@ -459,7 +459,7 @@ pub const Canvas = struct { pub fn fillPath( self: *Canvas, path: z2d.Path, - opts: z2d.painter.FillOpts, + opts: z2d.painter.FillOptions, color: Color, ) z2d.painter.FillError!void { try z2d.painter.fill( From 8a419e5526b1fc69eef06cfae1457b217ae900fe Mon Sep 17 00:00:00 2001 From: cyppe Date: Mon, 29 Dec 2025 09:58:09 +0100 Subject: [PATCH 278/605] gtk: pass through keypress when clipboard has no text When paste_from_clipboard is triggered but the clipboard contains no text (e.g., an image), send the raw Ctrl+V keypress to the terminal instead of silently returning. This allows applications to handle their own clipboard reading (e.g., via wl-paste for images on Wayland). --- src/apprt/gtk/class/surface.zig | 34 +++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index a14d53c32..965f518c9 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3814,21 +3814,34 @@ const Clipboard = struct { const self = req.self; defer self.unref(); + const surface = self.private().core_surface orelse return; + var gerr: ?*glib.Error = null; const cstr_ = clipboard.readTextFinish(res, &gerr); + + // If clipboard has no text (error, null, or empty), pass through the + // original keypress so applications can handle their own clipboard + // (e.g., reading images via wl-paste). We send raw Ctrl+V (0x16) + // directly using the text action to bypass bracketed paste encoding. if (gerr) |err| { defer err.free(); - log.warn( - "failed to read clipboard err={s}", - .{err.f_message orelse "(no message)"}, - ); + log.debug("clipboard has no text format: {s}", .{err.f_message orelse "(no message)"}); + passthroughKeypress(surface); return; } - const cstr = cstr_ orelse return; + + const cstr = cstr_ orelse { + passthroughKeypress(surface); + return; + }; defer glib.free(cstr); const str = std.mem.sliceTo(cstr, 0); - const surface = self.private().core_surface orelse return; + if (str.len == 0) { + passthroughKeypress(surface); + return; + } + surface.completeClipboardRequest( req.state, str, @@ -3862,6 +3875,15 @@ const Clipboard = struct { ); } + /// Send raw Ctrl+V (ASCII 22) to the terminal, bypassing paste encoding. + /// This allows applications to handle their own clipboard reading + /// (e.g., for image paste via wl-paste on Wayland). + fn passthroughKeypress(surface: *CoreSurface) void { + _ = surface.performBindingAction(.{ .text = "\\x16" }) catch |err| { + log.warn("error sending passthrough keypress: {}", .{err}); + }; + } + /// The request we send as userdata to the clipboard read. const Request = struct { /// "Self" is reffed so we can't dispose it until the clipboard From 0da650e7dd6d08a376b5d212036d0b9d3231b9e3 Mon Sep 17 00:00:00 2001 From: cyppe Date: Mon, 29 Dec 2025 15:44:46 +0100 Subject: [PATCH 279/605] gtk: support performable keybinds for clipboard paste Make clipboardRequest return bool to indicate whether the action could be performed. For paste requests, synchronously check if the clipboard contains text formats before starting the async read. This allows 'performable:paste_from_clipboard' keybinds to pass through when the clipboard contains non-text content (e.g., images), enabling terminal applications to handle their own clipboard reading. Changes: - Surface.startClipboardRequest now returns bool - paste_from_clipboard/paste_from_selection actions return the result - GTK apprt checks clipboard formats synchronously before async read - Embedded apprt always returns true (can't check synchronously) - All other call sites discard the return value with _ --- src/Surface.zig | 22 +++++++----- src/apprt/embedded.zig | 6 +++- src/apprt/gtk/Surface.zig | 4 +-- src/apprt/gtk/class/surface.zig | 62 ++++++++++++++++----------------- 4 files changed, 50 insertions(+), 44 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index c9223d0ad..614f40475 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1026,7 +1026,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { return; } - try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); + _ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); }, .clipboard_write => |w| switch (w.req) { @@ -4173,7 +4173,7 @@ pub fn mouseButtonCallback( .selection else .standard; - try self.startClipboardRequest(clipboard, .{ .paste = {} }); + _ = try self.startClipboardRequest(clipboard, .{ .paste = {} }); } // Right-click down selects word for context menus. If the apprt @@ -4251,7 +4251,7 @@ pub fn mouseButtonCallback( // request so we need to unlock. self.renderer_state.mutex.unlock(); defer self.renderer_state.mutex.lock(); - try self.startClipboardRequest(.standard, .paste); + _ = try self.startClipboardRequest(.standard, .paste); // We don't need to clear selection because we didn't have // one to begin with. @@ -4266,7 +4266,7 @@ pub fn mouseButtonCallback( // request so we need to unlock. self.renderer_state.mutex.unlock(); defer self.renderer_state.mutex.lock(); - try self.startClipboardRequest(.standard, .paste); + _ = try self.startClipboardRequest(.standard, .paste); }, } @@ -5330,12 +5330,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool return true; }, - .paste_from_clipboard => try self.startClipboardRequest( + .paste_from_clipboard => return try self.startClipboardRequest( .standard, .{ .paste = {} }, ), - .paste_from_selection => try self.startClipboardRequest( + .paste_from_selection => return try self.startClipboardRequest( .selection, .{ .paste = {} }, ), @@ -6049,11 +6049,15 @@ pub fn completeClipboardRequest( /// This starts a clipboard request, with some basic validation. For example, /// an OSC 52 request is not actually requested if OSC 52 is disabled. +/// +/// Returns true if the request was started, false if it was not (e.g., clipboard +/// doesn't contain text for paste requests). This allows performable keybinds +/// to pass through when the action cannot be performed. fn startClipboardRequest( self: *Surface, loc: apprt.Clipboard, req: apprt.ClipboardRequest, -) !void { +) !bool { switch (req) { .paste => {}, // always allowed .osc_52_read => if (self.config.clipboard_read == .deny) { @@ -6061,14 +6065,14 @@ fn startClipboardRequest( "application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}, ); - return; + return false; }, // No clipboard write code paths travel through this function .osc_52_write => unreachable, } - try self.rt_surface.clipboardRequest(loc, req); + return try self.rt_surface.clipboardRequest(loc, req); } fn completeClipboardPaste( diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 64900cef1..a1b6a6e9b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -652,7 +652,7 @@ pub const Surface = struct { self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !void { + ) !bool { // We need to allocate to get a pointer to store our clipboard request // so that it is stable until the read_clipboard callback and call // complete_clipboard_request. This sucks but clipboard requests aren't @@ -667,6 +667,10 @@ pub const Surface = struct { @intCast(@intFromEnum(clipboard_type)), state_ptr, ); + + // Embedded apprt can't synchronously check clipboard content types, + // so we always return true to indicate the request was started. + return true; } fn completeClipboardRequest( diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 009ce018d..918e77146 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -73,8 +73,8 @@ pub fn clipboardRequest( self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, -) !void { - try self.surface.clipboardRequest( +) !bool { + return try self.surface.clipboardRequest( clipboard_type, state, ); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 965f518c9..cbd444936 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1666,8 +1666,8 @@ pub const Surface = extern struct { self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !void { - try Clipboard.request( + ) !bool { + return try Clipboard.request( self, clipboard_type, state, @@ -3623,16 +3623,34 @@ const Clipboard = struct { /// Request data from the clipboard (read the clipboard). This /// completes asynchronously and will call the `completeClipboardRequest` /// core surface API when done. + /// + /// Returns true if the request was started, false if the clipboard + /// doesn't contain text (allowing performable keybinds to pass through). pub fn request( self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) Allocator.Error!void { + ) Allocator.Error!bool { // Get our requested clipboard const clipboard = get( self.private().gl_area.as(gtk.Widget), clipboard_type, - ) orelse return; + ) orelse return false; + + // For paste requests, check if clipboard has text format available. + // This is a synchronous check that allows performable keybinds to + // pass through when the clipboard contains non-text content (e.g., images). + if (state == .paste) { + const formats = clipboard.getFormats(); + if (formats.containMimeType("text/plain") == 0 and + formats.containMimeType("UTF8_STRING") == 0 and + formats.containMimeType("TEXT") == 0 and + formats.containMimeType("STRING") == 0) + { + log.debug("clipboard has no text format, not starting paste request", .{}); + return false; + } + } // Allocate our userdata const alloc = Application.default().allocator(); @@ -3652,6 +3670,8 @@ const Clipboard = struct { clipboardReadText, ud, ); + + return true; } /// Paste explicit text directly into the surface, regardless of the @@ -3814,34 +3834,21 @@ const Clipboard = struct { const self = req.self; defer self.unref(); - const surface = self.private().core_surface orelse return; - var gerr: ?*glib.Error = null; const cstr_ = clipboard.readTextFinish(res, &gerr); - - // If clipboard has no text (error, null, or empty), pass through the - // original keypress so applications can handle their own clipboard - // (e.g., reading images via wl-paste). We send raw Ctrl+V (0x16) - // directly using the text action to bypass bracketed paste encoding. if (gerr) |err| { defer err.free(); - log.debug("clipboard has no text format: {s}", .{err.f_message orelse "(no message)"}); - passthroughKeypress(surface); + log.warn( + "failed to read clipboard err={s}", + .{err.f_message orelse "(no message)"}, + ); return; } - - const cstr = cstr_ orelse { - passthroughKeypress(surface); - return; - }; + const cstr = cstr_ orelse return; defer glib.free(cstr); const str = std.mem.sliceTo(cstr, 0); - if (str.len == 0) { - passthroughKeypress(surface); - return; - } - + const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, str, @@ -3875,15 +3882,6 @@ const Clipboard = struct { ); } - /// Send raw Ctrl+V (ASCII 22) to the terminal, bypassing paste encoding. - /// This allows applications to handle their own clipboard reading - /// (e.g., for image paste via wl-paste on Wayland). - fn passthroughKeypress(surface: *CoreSurface) void { - _ = surface.performBindingAction(.{ .text = "\\x16" }) catch |err| { - log.warn("error sending passthrough keypress: {}", .{err}); - }; - } - /// The request we send as userdata to the clipboard read. const Request = struct { /// "Self" is reffed so we can't dispose it until the clipboard From ab232b30604a49e6ad586294392434295333050f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 13:49:34 -0800 Subject: [PATCH 280/605] macos: move Ghostty surface view into dedicated folder --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++++---- .../Ghostty/{ => Surface View}/InspectorView.swift | 0 .../Ghostty/{ => Surface View}/SurfaceProgressBar.swift | 0 .../Ghostty/{ => Surface View}/SurfaceScrollView.swift | 0 .../Sources/Ghostty/{ => Surface View}/SurfaceView.swift | 0 .../Ghostty/{ => Surface View}/SurfaceView_AppKit.swift | 3 --- .../Ghostty/{ => Surface View}/SurfaceView_UIKit.swift | 0 7 files changed, 4 insertions(+), 7 deletions(-) rename macos/Sources/Ghostty/{ => Surface View}/InspectorView.swift (100%) rename macos/Sources/Ghostty/{ => Surface View}/SurfaceProgressBar.swift (100%) rename macos/Sources/Ghostty/{ => Surface View}/SurfaceScrollView.swift (100%) rename macos/Sources/Ghostty/{ => Surface View}/SurfaceView.swift (100%) rename macos/Sources/Ghostty/{ => Surface View}/SurfaceView_AppKit.swift (99%) rename macos/Sources/Ghostty/{ => Surface View}/SurfaceView_UIKit.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index feda1bed0..49f668e12 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -142,10 +142,10 @@ Ghostty/Ghostty.Event.swift, Ghostty/Ghostty.Input.swift, Ghostty/Ghostty.Surface.swift, - Ghostty/InspectorView.swift, "Ghostty/NSEvent+Extension.swift", - Ghostty/SurfaceScrollView.swift, - Ghostty/SurfaceView_AppKit.swift, + "Ghostty/Surface View/InspectorView.swift", + "Ghostty/Surface View/SurfaceScrollView.swift", + "Ghostty/Surface View/SurfaceView_AppKit.swift", Helpers/AppInfo.swift, Helpers/CodableBridge.swift, Helpers/Cursor.swift, @@ -187,7 +187,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/iOS/iOSApp.swift, - Ghostty/SurfaceView_UIKit.swift, + "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; }; diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift similarity index 100% rename from macos/Sources/Ghostty/InspectorView.swift rename to macos/Sources/Ghostty/Surface View/InspectorView.swift diff --git a/macos/Sources/Ghostty/SurfaceProgressBar.swift b/macos/Sources/Ghostty/Surface View/SurfaceProgressBar.swift similarity index 100% rename from macos/Sources/Ghostty/SurfaceProgressBar.swift rename to macos/Sources/Ghostty/Surface View/SurfaceProgressBar.swift diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift similarity index 100% rename from macos/Sources/Ghostty/SurfaceScrollView.swift rename to macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift similarity index 100% rename from macos/Sources/Ghostty/SurfaceView.swift rename to macos/Sources/Ghostty/Surface View/SurfaceView.swift diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift similarity index 99% rename from macos/Sources/Ghostty/SurfaceView_AppKit.swift rename to macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 77e1c43d4..37cc9282e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1654,7 +1654,6 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double - let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1663,7 +1662,6 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 - self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1673,7 +1671,6 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity - self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift similarity index 100% rename from macos/Sources/Ghostty/SurfaceView_UIKit.swift rename to macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift From 88adffd734431a2f610d14bc5344420186981418 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 13:59:49 -0800 Subject: [PATCH 281/605] macOS: add handle to the top of surfaces that can be used to drag UI only --- .../Ghostty/Surface View/SurfaceView.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 2d5039d29..a5e677c54 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -224,6 +224,10 @@ extension Ghostty { .opacity(overlayOpacity) } } + + // Grab handle for dragging the window. We want this to appear at the very + // top Z-index os it isn't faded by the unfocused overlay. + SurfaceGrabHandle() } } @@ -952,6 +956,49 @@ extension Ghostty { } #endif + /// A grab handle overlay at the top of the surface for dragging the window. + /// Only appears when hovering in the top region of the surface. + struct SurfaceGrabHandle: View { + private let handleHeight: CGFloat = 10 + + @State private var isHovering: Bool = false + @State private var isDragging: Bool = false + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.white.opacity(isHovering || isDragging ? 0.15 : 0)) + .frame(height: handleHeight) + .overlay(alignment: .center) { + if isHovering || isDragging { + Capsule() + .fill(Color.white.opacity(0.4)) + .frame(width: 40, height: 4) + } + } + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + .gesture( + DragGesture() + .onChanged { _ in + isDragging = true + } + .onEnded { _ in + isDragging = false + } + ) + .backport.pointerStyle(isHovering ? .grabIdle : nil) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + /// Visual overlay that shows a border around the edges when the bell rings with border feature enabled. struct BellBorderOverlay: View { let bell: Bool From 57bb636655aa129d7a01d028c565d15dd82ecdc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 14:23:55 -0800 Subject: [PATCH 282/605] surfaceview as transferable --- .../SurfaceView+Transferable.swift | 20 +++++++++++++++++++ .../Ghostty/Surface View/SurfaceView.swift | 14 ++++--------- 2 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift new file mode 100644 index 000000000..af20c9f37 --- /dev/null +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -0,0 +1,20 @@ +import CoreTransferable +import UniformTypeIdentifiers + +extension Ghostty.SurfaceView: Transferable { + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(contentType: .ghosttySurfaceId) { surface in + withUnsafeBytes(of: surface.id.uuid) { Data($0) } + } importing: { data in + throw TransferError.importNotSupported + } + } + + enum TransferError: Error { + case importNotSupported + } +} + +extension UTType { + static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId") +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index a5e677c54..140266ec5 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -227,7 +227,7 @@ extension Ghostty { // Grab handle for dragging the window. We want this to appear at the very // top Z-index os it isn't faded by the unfocused overlay. - SurfaceGrabHandle() + SurfaceGrabHandle(surfaceView: surfaceView) } } @@ -961,6 +961,8 @@ extension Ghostty { struct SurfaceGrabHandle: View { private let handleHeight: CGFloat = 10 + let surfaceView: SurfaceView + @State private var isHovering: Bool = false @State private var isDragging: Bool = false @@ -982,20 +984,12 @@ extension Ghostty { isHovering = hovering } } - .gesture( - DragGesture() - .onChanged { _ in - isDragging = true - } - .onEnded { _ in - isDragging = false - } - ) .backport.pointerStyle(isHovering ? .grabIdle : nil) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) + .draggable(surfaceView) } } From 50456886237db7be7da3b9ae3f2c9dc122d418e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 12:51:26 -0800 Subject: [PATCH 283/605] macos: move grab handle to separate file --- .../Surface View/SurfaceGrabHandle.swift | 40 +++++++++++++++++++ .../Ghostty/Surface View/SurfaceView.swift | 37 ----------------- 2 files changed, 40 insertions(+), 37 deletions(-) create mode 100644 macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift new file mode 100644 index 000000000..6e99bc281 --- /dev/null +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -0,0 +1,40 @@ +import SwiftUI + +extension Ghostty { + /// A grab handle overlay at the top of the surface for dragging the window. + /// Only appears when hovering in the top region of the surface. + struct SurfaceGrabHandle: View { + private let handleHeight: CGFloat = 10 + + let surfaceView: SurfaceView + + @State private var isHovering: Bool = false + @State private var isDragging: Bool = false + + var body: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.white.opacity(isHovering || isDragging ? 0.15 : 0)) + .frame(height: handleHeight) + .overlay(alignment: .center) { + if isHovering || isDragging { + Capsule() + .fill(Color.white.opacity(0.4)) + .frame(width: 40, height: 4) + } + } + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + .backport.pointerStyle(isHovering ? .grabIdle : nil) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .draggable(surfaceView) + } + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 140266ec5..872b89d30 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -956,43 +956,6 @@ extension Ghostty { } #endif - /// A grab handle overlay at the top of the surface for dragging the window. - /// Only appears when hovering in the top region of the surface. - struct SurfaceGrabHandle: View { - private let handleHeight: CGFloat = 10 - - let surfaceView: SurfaceView - - @State private var isHovering: Bool = false - @State private var isDragging: Bool = false - - var body: some View { - VStack(spacing: 0) { - Rectangle() - .fill(Color.white.opacity(isHovering || isDragging ? 0.15 : 0)) - .frame(height: handleHeight) - .overlay(alignment: .center) { - if isHovering || isDragging { - Capsule() - .fill(Color.white.opacity(0.4)) - .frame(width: 40, height: 4) - } - } - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isHovering = hovering - } - } - .backport.pointerStyle(isHovering ? .grabIdle : nil) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .draggable(surfaceView) - } - } - /// Visual overlay that shows a border around the edges when the bell rings with border feature enabled. struct BellBorderOverlay: View { let bell: Bool From 304e2612abf9779ce486313580c10b334b2e81be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 13:01:49 -0800 Subject: [PATCH 284/605] macOS: work on drop destination --- .../Splits/TerminalSplitTreeView.swift | 10 ++++-- .../SurfaceView+Transferable.swift | 33 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 103413c70..44123de6d 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -32,8 +32,14 @@ struct TerminalSplitSubtreeView: View { Ghostty.InspectableSurface( surfaceView: leafView, isSplit: !isRoot) - .accessibilityElement(children: .contain) - .accessibilityLabel("Terminal pane") + .dropDestination(for: Ghostty.SurfaceView.self) { views, point in + Ghostty.logger.warning("BABY WHAT!") + return false + } isTargeted: { targeted in + Ghostty.logger.warning("BABY TARGETED=\(targeted)") + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Terminal pane") case .split(let split): let splitViewDirection: SplitViewDirection = switch (split.direction) { diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index af20c9f37..da4b420d5 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -1,3 +1,4 @@ +import AppKit import CoreTransferable import UniformTypeIdentifiers @@ -6,12 +7,40 @@ extension Ghostty.SurfaceView: Transferable { DataRepresentation(contentType: .ghosttySurfaceId) { surface in withUnsafeBytes(of: surface.id.uuid) { Data($0) } } importing: { data in - throw TransferError.importNotSupported + guard data.count == 16 else { + throw TransferError.invalidData + } + + let uuid = data.withUnsafeBytes { + $0.load(as: UUID.self) + } + + guard let imported = await Self.find(uuid: uuid) else { + throw TransferError.invalidData + } + + return imported } } enum TransferError: Error { - case importNotSupported + case invalidData + } + + @MainActor + static func find(uuid: UUID) -> Self? { + for window in NSApp.windows { + guard let controller = window.windowController as? BaseTerminalController else { + continue + } + for surface in controller.surfaceTree { + if surface.id == uuid { + return surface as? Self + } + } + } + + return nil } } From ddfd4fe7c241acec9ad858c918429321d0135bba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 13:14:01 -0800 Subject: [PATCH 285/605] macos: export our ghostty surface ID type --- macos/Ghostty-Info.plist | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 2bf3b0bae..5960dc0e7 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -100,5 +100,20 @@ SUPublicEDKey wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok= + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.mitchellh.ghosttySurfaceId + UTTypeDescription + Ghostty Surface Identifier + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + + From 43d87cf9f8f1f6ced10ea78a17dc11db313cdeee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 13:41:09 -0800 Subject: [PATCH 286/605] macos: setup drop UI on our split views --- .../Splits/TerminalSplitTreeView.swift | 145 ++++++++++++++++-- 1 file changed, 134 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 44123de6d..5c291dcba 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -29,17 +29,7 @@ struct TerminalSplitSubtreeView: View { var body: some View { switch (node) { case .leaf(let leafView): - Ghostty.InspectableSurface( - surfaceView: leafView, - isSplit: !isRoot) - .dropDestination(for: Ghostty.SurfaceView.self) { views, point in - Ghostty.logger.warning("BABY WHAT!") - return false - } isTargeted: { targeted in - Ghostty.logger.warning("BABY TARGETED=\(targeted)") - } - .accessibilityElement(children: .contain) - .accessibilityLabel("Terminal pane") + TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot) case .split(let split): let splitViewDirection: SplitViewDirection = switch (split.direction) { @@ -70,3 +60,136 @@ struct TerminalSplitSubtreeView: View { } } } + +struct TerminalSplitLeaf: View { + let surfaceView: Ghostty.SurfaceView + let isSplit: Bool + + @State private var dropZone: DropZone = .none + + var body: some View { + Ghostty.InspectableSurface( + surfaceView: surfaceView, + isSplit: isSplit) + .background { + // We use background for the drop delegate and overlay for the visual indicator + // so that we don't block mouse events from reaching the surface view. The + // background receives drop events while the overlay (with allowsHitTesting + // disabled) only provides visual feedback. + GeometryReader { geometry in + Color.clear + .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( + dropZone: $dropZone, + viewSize: geometry.size + )) + } + } + .overlay { + if dropZone != .none { + GeometryReader { geometry in + dropZoneOverlay(for: dropZone, in: geometry) + } + .allowsHitTesting(false) + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Terminal pane") + } + + @ViewBuilder + private func dropZoneOverlay(for zone: DropZone, in geometry: GeometryProxy) -> some View { + let overlayColor = Color.accentColor.opacity(0.3) + + switch zone { + case .none: + EmptyView() + case .top: + VStack(spacing: 0) { + Rectangle() + .fill(overlayColor) + .frame(height: geometry.size.height / 2) + Spacer() + } + case .bottom: + VStack(spacing: 0) { + Spacer() + Rectangle() + .fill(overlayColor) + .frame(height: geometry.size.height / 2) + } + case .left: + HStack(spacing: 0) { + Rectangle() + .fill(overlayColor) + .frame(width: geometry.size.width / 2) + Spacer() + } + case .right: + HStack(spacing: 0) { + Spacer() + Rectangle() + .fill(overlayColor) + .frame(width: geometry.size.width / 2) + } + } + } + + enum DropZone: Equatable { + case none + case top + case bottom + case left + case right + } + + struct SplitDropDelegate: DropDelegate { + @Binding var dropZone: DropZone + let viewSize: CGSize + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.ghosttySurfaceId]) + } + + func dropEntered(info: DropInfo) { + _ = dropUpdated(info: info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dropZone = calculateDropZone(at: info.location) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + dropZone = .none + } + + func performDrop(info: DropInfo) -> Bool { + dropZone = .none + return false + } + + /// Determines which drop zone the cursor is in based on proximity to edges. + /// + /// Divides the view into four triangular regions by drawing diagonals from + /// corner to corner. The drop zone is determined by which edge the cursor + /// is closest to, creating natural triangular hit regions for each side. + private func calculateDropZone(at point: CGPoint) -> DropZone { + guard viewSize.width > 0, viewSize.height > 0 else { return .none } + + let relX = point.x / viewSize.width + let relY = point.y / viewSize.height + + let distToLeft = relX + let distToRight = 1 - relX + let distToTop = relY + let distToBottom = 1 - relY + + let minDist = min(distToLeft, distToRight, distToTop, distToBottom) + + if minDist == distToLeft { return .left } + if minDist == distToRight { return .right } + if minDist == distToTop { return .top } + return .bottom + } + } +} From 0a80f77766a2f234233e870eac1d0aa4e317ba1c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 14:22:46 -0800 Subject: [PATCH 287/605] macos: wire up onDrop --- .../Splits/TerminalSplitTreeView.swift | 55 ++++++++++++------- .../Features/Terminal/TerminalView.swift | 6 +- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 5c291dcba..9a09ed743 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -1,15 +1,18 @@ import SwiftUI +import os struct TerminalSplitTreeView: View { let tree: SplitTree let onResize: (SplitTree.Node, Double) -> Void + let onDrop: (Ghostty.SurfaceView, TerminalSplitLeaf.DropZone) -> Void var body: some View { if let node = tree.zoomed ?? tree.root { TerminalSplitSubtreeView( node: node, isRoot: node == tree.root, - onResize: onResize) + onResize: onResize, + onDrop: onDrop) // 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 behaviors. @@ -25,11 +28,12 @@ struct TerminalSplitSubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false let onResize: (SplitTree.Node, Double) -> Void + let onDrop: (Ghostty.SurfaceView, TerminalSplitLeaf.DropZone) -> Void var body: some View { switch (node) { case .leaf(let leafView): - TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot) + TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, onDrop: onDrop) case .split(let split): let splitViewDirection: SplitViewDirection = switch (split.direction) { @@ -47,10 +51,10 @@ struct TerminalSplitSubtreeView: View { dividerColor: ghostty.config.splitDividerColor, resizeIncrements: .init(width: 1, height: 1), left: { - TerminalSplitSubtreeView(node: split.left, onResize: onResize) + TerminalSplitSubtreeView(node: split.left, onResize: onResize, onDrop: onDrop) }, right: { - TerminalSplitSubtreeView(node: split.right, onResize: onResize) + TerminalSplitSubtreeView(node: split.right, onResize: onResize, onDrop: onDrop) }, onEqualize: { guard let surface = node.leftmostLeaf().surface else { return } @@ -64,8 +68,9 @@ struct TerminalSplitSubtreeView: View { struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool + let onDrop: (Ghostty.SurfaceView, DropZone) -> Void - @State private var dropZone: DropZone = .none + @State private var dropState: DropState = .idle var body: some View { Ghostty.InspectableSurface( @@ -79,15 +84,16 @@ struct TerminalSplitLeaf: View { GeometryReader { geometry in Color.clear .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( - dropZone: $dropZone, - viewSize: geometry.size + dropState: $dropState, + viewSize: geometry.size, + onDrop: { zone in onDrop(surfaceView, zone) } )) } } .overlay { - if dropZone != .none { + if case .dropping(let zone) = dropState { GeometryReader { geometry in - dropZoneOverlay(for: dropZone, in: geometry) + dropZoneOverlay(for: zone, in: geometry) } .allowsHitTesting(false) } @@ -101,8 +107,6 @@ struct TerminalSplitLeaf: View { let overlayColor = Color.accentColor.opacity(0.3) switch zone { - case .none: - EmptyView() case .top: VStack(spacing: 0) { Rectangle() @@ -134,38 +138,49 @@ struct TerminalSplitLeaf: View { } } - enum DropZone: Equatable { - case none + enum DropZone: String, Equatable { case top case bottom case left case right } + enum DropState: Equatable { + case idle + case dropping(DropZone) + } + struct SplitDropDelegate: DropDelegate { - @Binding var dropZone: DropZone + @Binding var dropState: DropState let viewSize: CGSize + let onDrop: (DropZone) -> Void func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [.ghosttySurfaceId]) } func dropEntered(info: DropInfo) { - _ = dropUpdated(info: info) + dropState = .dropping(calculateDropZone(at: info.location)) } func dropUpdated(info: DropInfo) -> DropProposal? { - dropZone = calculateDropZone(at: info.location) + // For some reason dropUpdated is sent after performDrop is called + // and we don't want to reset our drop zone to show it so we have + // to guard on the state here. + guard case .dropping = dropState else { return DropProposal(operation: .forbidden) } + dropState = .dropping(calculateDropZone(at: info.location)) return DropProposal(operation: .move) } func dropExited(info: DropInfo) { - dropZone = .none + dropState = .idle } func performDrop(info: DropInfo) -> Bool { - dropZone = .none - return false + let zone = calculateDropZone(at: info.location) + dropState = .idle + onDrop(zone) + return true } /// Determines which drop zone the cursor is in based on proximity to edges. @@ -174,8 +189,6 @@ struct TerminalSplitLeaf: View { /// corner to corner. The drop zone is determined by which edge the cursor /// is closest to, creating natural triangular hit regions for each side. private func calculateDropZone(at point: CGPoint) -> DropZone { - guard viewSize.width > 0, viewSize.height > 0 else { return .none } - let relX = point.x / viewSize.width let relY = point.y / viewSize.height diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index fd53a617b..8e70c2a55 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -1,5 +1,6 @@ import SwiftUI import GhosttyKit +import os /// This delegate is notified of actions and property changes regarding the terminal view. This /// delegate is optional and can be used by a TerminalView caller to react to changes such as @@ -81,7 +82,10 @@ struct TerminalView: View { TerminalSplitTreeView( tree: viewModel.surfaceTree, - onResize: { delegate?.splitDidResize(node: $0, to: $1) }) + onResize: { delegate?.splitDidResize(node: $0, to: $1) }, + onDrop: { surface, zone in + Ghostty.logger.info("Drop on surface \(surface) in zone \(zone.rawValue)") + }) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } From 5d8c9357c096b7e907437acdb8dd1efe1eab2b54 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 14:26:55 -0800 Subject: [PATCH 288/605] macos: move around some functions --- .../Splits/TerminalSplitTreeView.swift | 151 +++++++++--------- 1 file changed, 75 insertions(+), 76 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 9a09ed743..bcb425908 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -4,7 +4,7 @@ import os struct TerminalSplitTreeView: View { let tree: SplitTree let onResize: (SplitTree.Node, Double) -> Void - let onDrop: (Ghostty.SurfaceView, TerminalSplitLeaf.DropZone) -> Void + let onDrop: (Ghostty.SurfaceView, TerminalSplitDropZone) -> Void var body: some View { if let node = tree.zoomed ?? tree.root { @@ -28,7 +28,7 @@ struct TerminalSplitSubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false let onResize: (SplitTree.Node, Double) -> Void - let onDrop: (Ghostty.SurfaceView, TerminalSplitLeaf.DropZone) -> Void + let onDrop: (Ghostty.SurfaceView, TerminalSplitDropZone) -> Void var body: some View { switch (node) { @@ -68,7 +68,7 @@ struct TerminalSplitSubtreeView: View { struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool - let onDrop: (Ghostty.SurfaceView, DropZone) -> Void + let onDrop: (Ghostty.SurfaceView, TerminalSplitDropZone) -> Void @State private var dropState: DropState = .idle @@ -93,7 +93,7 @@ struct TerminalSplitLeaf: View { .overlay { if case .dropping(let zone) = dropState { GeometryReader { geometry in - dropZoneOverlay(for: zone, in: geometry) + zone.overlay(in: geometry) } .allowsHitTesting(false) } @@ -102,11 +102,78 @@ struct TerminalSplitLeaf: View { .accessibilityLabel("Terminal pane") } - @ViewBuilder - private func dropZoneOverlay(for zone: DropZone, in geometry: GeometryProxy) -> some View { - let overlayColor = Color.accentColor.opacity(0.3) + private enum DropState: Equatable { + case idle + case dropping(TerminalSplitDropZone) + } + + private struct SplitDropDelegate: DropDelegate { + @Binding var dropState: DropState + let viewSize: CGSize + let onDrop: (TerminalSplitDropZone) -> Void - switch zone { + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.ghosttySurfaceId]) + } + + func dropEntered(info: DropInfo) { + dropState = .dropping(.calculate(at: info.location, in: viewSize)) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + // For some reason dropUpdated is sent after performDrop is called + // and we don't want to reset our drop zone to show it so we have + // to guard on the state here. + guard case .dropping = dropState else { return DropProposal(operation: .forbidden) } + dropState = .dropping(.calculate(at: info.location, in: viewSize)) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + dropState = .idle + } + + func performDrop(info: DropInfo) -> Bool { + dropState = .idle + onDrop(.calculate(at: info.location, in: viewSize)) + return true + } + } +} + +enum TerminalSplitDropZone: String, Equatable { + case top + case bottom + case left + case right + + /// Determines which drop zone the cursor is in based on proximity to edges. + /// + /// Divides the view into four triangular regions by drawing diagonals from + /// corner to corner. The drop zone is determined by which edge the cursor + /// is closest to, creating natural triangular hit regions for each side. + static func calculate(at point: CGPoint, in size: CGSize) -> TerminalSplitDropZone { + let relX = point.x / size.width + let relY = point.y / size.height + + let distToLeft = relX + let distToRight = 1 - relX + let distToTop = relY + let distToBottom = 1 - relY + + let minDist = min(distToLeft, distToRight, distToTop, distToBottom) + + if minDist == distToLeft { return .left } + if minDist == distToRight { return .right } + if minDist == distToTop { return .top } + return .bottom + } + + @ViewBuilder + func overlay(in geometry: GeometryProxy) -> some View { + let overlayColor = Color.accentColor.opacity(0.3) + + switch self { case .top: VStack(spacing: 0) { Rectangle() @@ -137,72 +204,4 @@ struct TerminalSplitLeaf: View { } } } - - enum DropZone: String, Equatable { - case top - case bottom - case left - case right - } - - enum DropState: Equatable { - case idle - case dropping(DropZone) - } - - struct SplitDropDelegate: DropDelegate { - @Binding var dropState: DropState - let viewSize: CGSize - let onDrop: (DropZone) -> Void - - func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.ghosttySurfaceId]) - } - - func dropEntered(info: DropInfo) { - dropState = .dropping(calculateDropZone(at: info.location)) - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - // For some reason dropUpdated is sent after performDrop is called - // and we don't want to reset our drop zone to show it so we have - // to guard on the state here. - guard case .dropping = dropState else { return DropProposal(operation: .forbidden) } - dropState = .dropping(calculateDropZone(at: info.location)) - return DropProposal(operation: .move) - } - - func dropExited(info: DropInfo) { - dropState = .idle - } - - func performDrop(info: DropInfo) -> Bool { - let zone = calculateDropZone(at: info.location) - dropState = .idle - onDrop(zone) - return true - } - - /// Determines which drop zone the cursor is in based on proximity to edges. - /// - /// Divides the view into four triangular regions by drawing diagonals from - /// corner to corner. The drop zone is determined by which edge the cursor - /// is closest to, creating natural triangular hit regions for each side. - private func calculateDropZone(at point: CGPoint) -> DropZone { - let relX = point.x / viewSize.width - let relY = point.y / viewSize.height - - let distToLeft = relX - let distToRight = 1 - relX - let distToTop = relY - let distToBottom = 1 - relY - - let minDist = min(distToLeft, distToRight, distToTop, distToBottom) - - if minDist == distToLeft { return .left } - if minDist == distToRight { return .right } - if minDist == distToTop { return .top } - return .bottom - } - } } From 485b8613429fb88ba6181ab326e5b99f1afc7a43 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 14:37:25 -0800 Subject: [PATCH 289/605] macos: drag preview is an image of the surface --- .../Surface View/SurfaceGrabHandle.swift | 31 ++++++++++++++++++- .../Surface View/SurfaceView+Image.swift | 28 +++++++++++++++++ .../Surface View/SurfaceView_AppKit.swift | 1 + 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 6e99bc281..d35045315 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -5,6 +5,7 @@ extension Ghostty { /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { private let handleHeight: CGFloat = 10 + private let previewScale: CGFloat = 0.2 let surfaceView: SurfaceView @@ -34,7 +35,35 @@ extension Ghostty { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .draggable(surfaceView) + .draggable(surfaceView) { + SurfaceDragPreview(surfaceView: surfaceView, scale: previewScale) + } + } + } + + /// A miniature preview of the surface view for drag operations that updates periodically. + private struct SurfaceDragPreview: View { + let surfaceView: SurfaceView + let scale: CGFloat + + var body: some View { + // We need to use a TimelineView to ensure that this doesn't + // cache forever. This will NOT let the view live update while + // being dragged; macOS doesn't seem to allow that. But it will + // make sure on new drags the screenshot is updated. + TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in + if let snapshot = surfaceView.asImage { + Image(nsImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + width: snapshot.size.width * scale, + height: snapshot.size.height * scale + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 10) + } + } } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift new file mode 100644 index 000000000..11b7b4694 --- /dev/null +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Image.swift @@ -0,0 +1,28 @@ +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +extension Ghostty.SurfaceView { + #if canImport(AppKit) + /// A snapshot image of the current surface view. + var asImage: NSImage? { + guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else { + return nil + } + cacheDisplay(in: bounds, to: bitmapRep) + let image = NSImage(size: bounds.size) + image.addRepresentation(bitmapRep) + return image + } + #elseif canImport(UIKit) + /// A snapshot image of the current surface view. + var asImage: UIImage? { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + return renderer.image { _ in + drawHierarchy(in: bounds, afterScreenUpdates: true) + } + } + #endif +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 37cc9282e..37d868a9f 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -2213,6 +2213,7 @@ extension Ghostty.SurfaceView { return NSAttributedString(string: plainString, attributes: attributes) } + } /// Caches a value for some period of time, evicting it automatically when that time expires. From d92fe44d0d32d2bb33283d9e1118652f835440b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 14:42:27 -0800 Subject: [PATCH 290/605] macos: fix various iOS build errors --- .../Ghostty/Surface View/SurfaceGrabHandle.swift | 12 ++++++++++++ .../Surface View/SurfaceView+Transferable.swift | 4 ++++ .../Ghostty/Surface View/SurfaceView_UIKit.swift | 6 ++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index d35045315..8f4347644 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -53,6 +53,7 @@ extension Ghostty { // make sure on new drags the screenshot is updated. TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in if let snapshot = surfaceView.asImage { + #if canImport(AppKit) Image(nsImage: snapshot) .resizable() .aspectRatio(contentMode: .fit) @@ -62,6 +63,17 @@ extension Ghostty { ) .clipShape(RoundedRectangle(cornerRadius: 8)) .shadow(radius: 10) + #elseif canImport(UIKit) + Image(uiImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + width: snapshot.size.width * scale, + height: snapshot.size.height * scale + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 10) + #endif } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index da4b420d5..da3050eae 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -1,4 +1,6 @@ +#if canImport(AppKit) import AppKit +#endif import CoreTransferable import UniformTypeIdentifiers @@ -29,6 +31,7 @@ extension Ghostty.SurfaceView: Transferable { @MainActor static func find(uuid: UUID) -> Self? { + #if canImport(AppKit) for window in NSApp.windows { guard let controller = window.windowController as? BaseTerminalController else { continue @@ -39,6 +42,7 @@ extension Ghostty.SurfaceView: Transferable { } } } + #endif return nil } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift index eb8a60fd9..f9baf56c9 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift @@ -4,8 +4,10 @@ import GhosttyKit extension Ghostty { /// The UIView implementation for a terminal surface. class SurfaceView: UIView, ObservableObject { + typealias ID = UUID + /// Unique ID per surface - let uuid: UUID + let id: UUID // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go @@ -63,7 +65,7 @@ extension Ghostty { private(set) var surface: ghostty_surface_t? init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { - self.uuid = uuid ?? .init() + self.id = uuid ?? .init() // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer From 8f8b5846c66fb889877dfc21e4c89dea6afafbfc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 14:47:09 -0800 Subject: [PATCH 291/605] macos: hook up onDrop to move splits --- .../Splits/TerminalSplitTreeView.swift | 33 +++++++++++++++---- .../Terminal/BaseTerminalController.swift | 32 ++++++++++++++++++ .../Features/Terminal/TerminalView.swift | 7 ++-- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index bcb425908..1f7969c03 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -4,7 +4,7 @@ import os struct TerminalSplitTreeView: View { let tree: SplitTree let onResize: (SplitTree.Node, Double) -> Void - let onDrop: (Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void var body: some View { if let node = tree.zoomed ?? tree.root { @@ -28,7 +28,7 @@ struct TerminalSplitSubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false let onResize: (SplitTree.Node, Double) -> Void - let onDrop: (Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void var body: some View { switch (node) { @@ -68,7 +68,7 @@ struct TerminalSplitSubtreeView: View { struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool - let onDrop: (Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void @State private var dropState: DropState = .idle @@ -86,7 +86,8 @@ struct TerminalSplitLeaf: View { .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( dropState: $dropState, viewSize: geometry.size, - onDrop: { zone in onDrop(surfaceView, zone) } + destinationSurface: surfaceView, + onDrop: onDrop )) } } @@ -110,7 +111,8 @@ struct TerminalSplitLeaf: View { private struct SplitDropDelegate: DropDelegate { @Binding var dropState: DropState let viewSize: CGSize - let onDrop: (TerminalSplitDropZone) -> Void + let destinationSurface: Ghostty.SurfaceView + let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [.ghosttySurfaceId]) @@ -134,8 +136,27 @@ struct TerminalSplitLeaf: View { } func performDrop(info: DropInfo) -> Bool { + let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize) dropState = .idle - onDrop(.calculate(at: info.location, in: viewSize)) + + // Load the dropped surface asynchronously using Transferable + let providers = info.itemProviders(for: [.ghosttySurfaceId]) + guard let provider = providers.first else { return false } + _ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in + switch result { + case .success(let sourceSurface): + DispatchQueue.main.async { + // Don't allow dropping on self + guard let destinationSurface else { return } + guard sourceSurface !== destinationSurface else { return } + onDrop(sourceSurface, destinationSurface, zone) + } + + case .failure: + break + } + } + return true } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d79c89d2d..7f023a8e1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -827,6 +827,38 @@ class BaseTerminalController: NSWindowController, } } + func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) { + // Find the source node in the tree + guard let sourceNode = surfaceTree.root?.node(view: source) else { + Ghostty.logger.warning("source surface not found in tree during drop") + return + } + + // Map drop zone to split direction + let direction: SplitTree.NewDirection = switch zone { + case .top: .up + case .bottom: .down + case .left: .left + case .right: .right + } + + // Remove source from its current position first + let treeWithoutSource = surfaceTree.remove(sourceNode) + + // Insert source at destination in the appropriate direction + do { + let newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) + replaceSurfaceTree( + newTree, + moveFocusTo: source, + moveFocusFrom: focusedSurface, + undoAction: "Move Split") + } catch { + Ghostty.logger.warning("failed to insert surface during drop: \(error)") + 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/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 8e70c2a55..6fc0c1d4b 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -20,6 +20,9 @@ protocol TerminalViewDelegate: AnyObject { /// A split is resizing to a given value. func splitDidResize(node: SplitTree.Node, to newRatio: Double) + + /// A surface was dropped onto another surface to create a split. + func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) } /// The view model is a required implementation for TerminalView callers. This contains @@ -83,8 +86,8 @@ struct TerminalView: View { TerminalSplitTreeView( tree: viewModel.surfaceTree, onResize: { delegate?.splitDidResize(node: $0, to: $1) }, - onDrop: { surface, zone in - Ghostty.logger.info("Drop on surface \(surface) in zone \(zone.rawValue)") + onDrop: { source, destination, zone in + delegate?.splitDidDrop(source: source, destination: destination, zone: zone) }) .environmentObject(ghostty) .focused($focused) From 5916755388cac95a56406ef69027cc8d639de6a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Dec 2025 14:59:18 -0800 Subject: [PATCH 292/605] macos: drop splits across windows --- .../Terminal/BaseTerminalController.swift | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 7f023a8e1..e5a80fdea 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -828,12 +828,6 @@ class BaseTerminalController: NSWindowController, } func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) { - // Find the source node in the tree - guard let sourceNode = surfaceTree.root?.node(view: source) else { - Ghostty.logger.warning("source surface not found in tree during drop") - return - } - // Map drop zone to split direction let direction: SplitTree.NewDirection = switch zone { case .top: .up @@ -842,20 +836,63 @@ class BaseTerminalController: NSWindowController, case .right: .right } - // Remove source from its current position first - let treeWithoutSource = surfaceTree.remove(sourceNode) + // Check if source is in our tree + if let sourceNode = surfaceTree.root?.node(view: source) { + // Source is in our tree - same window move + let treeWithoutSource = surfaceTree.remove(sourceNode) - // Insert source at destination in the appropriate direction + do { + let newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) + replaceSurfaceTree( + newTree, + moveFocusTo: source, + moveFocusFrom: focusedSurface, + undoAction: "Move Split") + } catch { + Ghostty.logger.warning("failed to insert surface during drop: \(error)") + } + + return + } + + // Source is not in our tree - search other windows + var sourceController: BaseTerminalController? + var sourceNode: SplitTree.Node? + for window in NSApp.windows { + guard let controller = window.windowController as? BaseTerminalController else { continue } + guard controller !== self else { continue } + if let node = controller.surfaceTree.root?.node(view: source) { + sourceController = controller + sourceNode = node + break + } + } + + guard let sourceController, let sourceNode else { + Ghostty.logger.warning("source surface not found in any window during drop") + return + } + + // TODO: Undo for cross window move. + + // Remove from source controller's tree + let sourceTreeWithoutNode = sourceController.surfaceTree.remove(sourceNode) + sourceController.replaceSurfaceTree( + sourceTreeWithoutNode, + moveFocusTo: nil, + moveFocusFrom: nil, + undoAction: nil) + + // Insert into our tree do { - let newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) + let newTree = try surfaceTree.insert(view: source, at: destination, direction: direction) replaceSurfaceTree( newTree, moveFocusTo: source, moveFocusFrom: focusedSurface, undoAction: "Move Split") } catch { - Ghostty.logger.warning("failed to insert surface during drop: \(error)") - return + Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)") } } From 60bc5a9ae772053177f5b825dbc6d69bbcf5c1a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 06:12:19 -0800 Subject: [PATCH 293/605] macos: clean up some SwiftUI nesting --- .../Splits/TerminalSplitTreeView.swift | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 1f7969c03..60acf6ab6 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -73,34 +73,25 @@ struct TerminalSplitLeaf: View { @State private var dropState: DropState = .idle var body: some View { - Ghostty.InspectableSurface( - surfaceView: surfaceView, - isSplit: isSplit) - .background { - // We use background for the drop delegate and overlay for the visual indicator - // so that we don't block mouse events from reaching the surface view. The - // background receives drop events while the overlay (with allowsHitTesting - // disabled) only provides visual feedback. - GeometryReader { geometry in - Color.clear - .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( - dropState: $dropState, - viewSize: geometry.size, - destinationSurface: surfaceView, - onDrop: onDrop - )) - } - } - .overlay { - if case .dropping(let zone) = dropState { - GeometryReader { geometry in + GeometryReader { geometry in + Ghostty.InspectableSurface( + surfaceView: surfaceView, + isSplit: isSplit) + .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( + dropState: $dropState, + viewSize: geometry.size, + destinationSurface: surfaceView, + onDrop: onDrop + )) + .overlay { + if case .dropping(let zone) = dropState { zone.overlay(in: geometry) + .allowsHitTesting(false) } - .allowsHitTesting(false) } + .accessibilityElement(children: .contain) + .accessibilityLabel("Terminal pane") } - .accessibilityElement(children: .contain) - .accessibilityLabel("Terminal pane") } private enum DropState: Equatable { From 9724541a335d0f9cdbf223cd1c538afc1991271d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 07:07:44 -0800 Subject: [PATCH 294/605] macos: unify split callbacks into a single tagged enum --- .../Splits/TerminalSplitTreeView.swift | 48 +++++++++++++------ .../Terminal/BaseTerminalController.swift | 18 +++++-- .../Features/Terminal/TerminalView.swift | 14 ++---- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 60acf6ab6..12a9b990b 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -1,18 +1,37 @@ import SwiftUI import os +enum TerminalSplitOperation { + case resize(Resize) + case drop(Drop) + + struct Resize { + let node: SplitTree.Node + let ratio: Double + } + + struct Drop { + /// The surface being dragged. + let payload: Ghostty.SurfaceView + + /// The surface it was dragged onto + let destination: Ghostty.SurfaceView + + /// The zone it was dropped to determine how to split the destination. + let zone: TerminalSplitDropZone + } +} + struct TerminalSplitTreeView: View { let tree: SplitTree - let onResize: (SplitTree.Node, Double) -> Void - let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let action: (TerminalSplitOperation) -> Void var body: some View { if let node = tree.zoomed ?? tree.root { TerminalSplitSubtreeView( node: node, isRoot: node == tree.root, - onResize: onResize, - onDrop: onDrop) + action: action) // 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 behaviors. @@ -27,13 +46,12 @@ struct TerminalSplitSubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false - let onResize: (SplitTree.Node, Double) -> Void - let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let action: (TerminalSplitOperation) -> Void var body: some View { switch (node) { case .leaf(let leafView): - TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, onDrop: onDrop) + TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action) case .split(let split): let splitViewDirection: SplitViewDirection = switch (split.direction) { @@ -46,15 +64,15 @@ struct TerminalSplitSubtreeView: View { .init(get: { CGFloat(split.ratio) }, set: { - onResize(node, $0) + action(.resize(.init(node: node, ratio: $0))) }), dividerColor: ghostty.config.splitDividerColor, resizeIncrements: .init(width: 1, height: 1), left: { - TerminalSplitSubtreeView(node: split.left, onResize: onResize, onDrop: onDrop) + TerminalSplitSubtreeView(node: split.left, action: action) }, right: { - TerminalSplitSubtreeView(node: split.right, onResize: onResize, onDrop: onDrop) + TerminalSplitSubtreeView(node: split.right, action: action) }, onEqualize: { guard let surface = node.leftmostLeaf().surface else { return } @@ -68,7 +86,7 @@ struct TerminalSplitSubtreeView: View { struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool - let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let action: (TerminalSplitOperation) -> Void @State private var dropState: DropState = .idle @@ -81,7 +99,7 @@ struct TerminalSplitLeaf: View { dropState: $dropState, viewSize: geometry.size, destinationSurface: surfaceView, - onDrop: onDrop + action: action )) .overlay { if case .dropping(let zone) = dropState { @@ -103,7 +121,7 @@ struct TerminalSplitLeaf: View { @Binding var dropState: DropState let viewSize: CGSize let destinationSurface: Ghostty.SurfaceView - let onDrop: (_ source: Ghostty.SurfaceView, _ destination: Ghostty.SurfaceView, TerminalSplitDropZone) -> Void + let action: (TerminalSplitOperation) -> Void func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [.ghosttySurfaceId]) @@ -133,6 +151,8 @@ struct TerminalSplitLeaf: View { // Load the dropped surface asynchronously using Transferable let providers = info.itemProviders(for: [.ghosttySurfaceId]) guard let provider = providers.first else { return false } + + // Capture action before the async closure _ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in switch result { case .success(let sourceSurface): @@ -140,7 +160,7 @@ struct TerminalSplitLeaf: View { // Don't allow dropping on self guard let destinationSurface else { return } guard sourceSurface !== destinationSurface else { return } - onDrop(sourceSurface, destinationSurface, zone) + action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone))) } case .failure: diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e5a80fdea..20e0d6b4f 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -817,17 +817,25 @@ class BaseTerminalController: NSWindowController, self.window?.contentResizeIncrements = to } - func splitDidResize(node: SplitTree.Node, to newRatio: Double) { + func performSplitAction(_ action: TerminalSplitOperation) { + switch action { + case .resize(let resize): + splitDidResize(node: resize.node, to: resize.ratio) + case .drop(let drop): + splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone) + } + } + + private func splitDidResize(node: SplitTree.Node, to newRatio: Double) { let resizedNode = node.resize(to: newRatio) do { surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) } catch { Ghostty.logger.warning("failed to replace node during split resize: \(error)") - return } } - func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) { + private func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) { // Map drop zone to split direction let direction: SplitTree.NewDirection = switch zone { case .top: .up @@ -851,7 +859,7 @@ class BaseTerminalController: NSWindowController, } catch { Ghostty.logger.warning("failed to insert surface during drop: \(error)") } - + return } @@ -872,7 +880,7 @@ class BaseTerminalController: NSWindowController, Ghostty.logger.warning("source surface not found in any window during drop") return } - + // TODO: Undo for cross window move. // Remove from source controller's tree diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 6fc0c1d4b..e117e0647 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -17,12 +17,9 @@ protocol TerminalViewDelegate: AnyObject { /// 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) - - /// A surface was dropped onto another surface to create a split. - func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) + + /// A split tree operation + func performSplitAction(_ action: TerminalSplitOperation) } /// The view model is a required implementation for TerminalView callers. This contains @@ -85,10 +82,7 @@ struct TerminalView: View { TerminalSplitTreeView( tree: viewModel.surfaceTree, - onResize: { delegate?.splitDidResize(node: $0, to: $1) }, - onDrop: { source, destination, zone in - delegate?.splitDidDrop(source: source, destination: destination, zone: zone) - }) + action: { delegate?.performSplitAction($0) }) .environmentObject(ghostty) .focused($focused) .onAppear { self.focused = true } From 1dd8e3ef4a15a78b5158c07f9f70233ef26bcf32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 07:23:14 -0800 Subject: [PATCH 295/605] macos: add GhosttyDelegate for global operations --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../App/macOS/AppDelegate+Ghostty.swift | 18 +++++++++++++++ macos/Sources/Ghostty/GhosttyDelegate.swift | 8 +++++++ .../SurfaceView+Transferable.swift | 22 +++++++++---------- 4 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/App/macOS/AppDelegate+Ghostty.swift create mode 100644 macos/Sources/Ghostty/GhosttyDelegate.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 49f668e12..a2516fe10 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/macOS/AppDelegate.swift, + "App/macOS/AppDelegate+Ghostty.swift", App/macOS/main.swift, App/macOS/MainMenu.xib, Features/About/About.xib, diff --git a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift new file mode 100644 index 000000000..7cc74ba7d --- /dev/null +++ b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift @@ -0,0 +1,18 @@ +import AppKit + +extension AppDelegate: Ghostty.Delegate { + func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? { + for window in NSApp.windows { + guard let controller = window.windowController as? BaseTerminalController else { + continue + } + for surface in controller.surfaceTree { + if surface.id == id { + return surface + } + } + } + + return nil + } +} diff --git a/macos/Sources/Ghostty/GhosttyDelegate.swift b/macos/Sources/Ghostty/GhosttyDelegate.swift new file mode 100644 index 000000000..0ce3dced4 --- /dev/null +++ b/macos/Sources/Ghostty/GhosttyDelegate.swift @@ -0,0 +1,8 @@ +extension Ghostty { + /// This is a delegate that should be applied to your global app delegate for GhosttyKit + /// to perform app-global operations. + protocol Delegate { + /// Look up a surface within the application by ID. + func ghosttySurface(id: UUID) -> SurfaceView? + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index da3050eae..d5d47f601 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -4,6 +4,7 @@ import AppKit import CoreTransferable import UniformTypeIdentifiers +/// Conformance to `Transferable` enables drag-and-drop. extension Ghostty.SurfaceView: Transferable { static var transferRepresentation: some TransferRepresentation { DataRepresentation(contentType: .ghosttySurfaceId) { surface in @@ -32,22 +33,19 @@ extension Ghostty.SurfaceView: Transferable { @MainActor static func find(uuid: UUID) -> Self? { #if canImport(AppKit) - for window in NSApp.windows { - guard let controller = window.windowController as? BaseTerminalController else { - continue - } - for surface in controller.surfaceTree { - if surface.id == uuid { - return surface as? Self - } - } - } - #endif - + guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil } + return del.ghosttySurface(id: uuid) as? Self + #elseif canImport(UIKit) + // We should be able to use UIApplication here. return nil + #else + return nil + #endif } } extension UTType { + /// A format that encodes the bare UUID only for the surface. This can be used if you have + /// a way to look up a surface by ID. static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId") } From 524575787515d2720893c16a7de0514cf6bb24eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 13:02:45 -0800 Subject: [PATCH 296/605] macos: all sorts of cleanups --- .../App/macOS/AppDelegate+Ghostty.swift | 5 + .../Splits/TerminalSplitTreeView.swift | 9 +- .../Terminal/BaseTerminalController.swift | 111 ++++++++++-------- .../Terminal/TerminalController.swift | 2 +- 4 files changed, 77 insertions(+), 50 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift index 7cc74ba7d..4d798a1a5 100644 --- a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift +++ b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift @@ -1,11 +1,16 @@ import AppKit +// MARK: Ghostty Delegate + +/// This implements the Ghostty app delegate protocol which is used by the Ghostty +/// APIs for app-global information. extension AppDelegate: Ghostty.Delegate { func ghosttySurface(id: UUID) -> Ghostty.SurfaceView? { for window in NSApp.windows { guard let controller = window.windowController as? BaseTerminalController else { continue } + for surface in controller.surfaceTree { if surface.id == id { return surface diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 12a9b990b..cffab8c6c 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -1,6 +1,9 @@ import SwiftUI -import os +/// A single operation within the split tree. +/// +/// Rather than binding the split tree (which is immutable), any mutable operations are +/// exposed via this enum to the embedder to handle. enum TerminalSplitOperation { case resize(Resize) case drop(Drop) @@ -41,7 +44,7 @@ struct TerminalSplitTreeView: View { } } -struct TerminalSplitSubtreeView: View { +fileprivate struct TerminalSplitSubtreeView: View { @EnvironmentObject var ghostty: Ghostty.App let node: SplitTree.Node @@ -83,7 +86,7 @@ struct TerminalSplitSubtreeView: View { } } -struct TerminalSplitLeaf: View { +fileprivate struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool let action: (TerminalSplitOperation) -> Void diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 20e0d6b4f..e278f653b 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -466,33 +466,33 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: newView, from: oldView) } } - + // Setup our undo - if let undoManager { - if let undoAction { - undoManager.setActionName(undoAction) + guard let undoManager else { return } + if let undoAction { + undoManager.setActionName(undoAction) + } + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + target.surfaceTree = oldTree + if let oldView { + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) + } } + undoManager.registerUndo( - withTarget: self, - expiresAfter: undoExpiration + withTarget: target, + expiresAfter: target.undoExpiration ) { target in - target.surfaceTree = oldTree - if let oldView { - DispatchQueue.main.async { - Ghostty.moveFocus(to: oldView, from: target.focusedSurface) - } - } - - undoManager.registerUndo( - withTarget: target, - expiresAfter: target.undoExpiration - ) { target in - target.replaceSurfaceTree( - newTree, - moveFocusTo: newView, - moveFocusFrom: target.focusedSurface, - undoAction: undoAction) - } + target.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: target.focusedSurface, + undoAction: undoAction) } } } @@ -835,7 +835,11 @@ class BaseTerminalController: NSWindowController, } } - private func splitDidDrop(source: Ghostty.SurfaceView, destination: Ghostty.SurfaceView, zone: TerminalSplitDropZone) { + private func splitDidDrop( + source: Ghostty.SurfaceView, + destination: Ghostty.SurfaceView, + zone: TerminalSplitDropZone + ) { // Map drop zone to split direction let direction: SplitTree.NewDirection = switch zone { case .top: .up @@ -843,12 +847,12 @@ class BaseTerminalController: NSWindowController, case .left: .left case .right: .right } - + // Check if source is in our tree if let sourceNode = surfaceTree.root?.node(view: source) { // Source is in our tree - same window move let treeWithoutSource = surfaceTree.remove(sourceNode) - + do { let newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) replaceSurfaceTree( @@ -859,10 +863,10 @@ class BaseTerminalController: NSWindowController, } catch { Ghostty.logger.warning("failed to insert surface during drop: \(error)") } - + return } - + // Source is not in our tree - search other windows var sourceController: BaseTerminalController? var sourceNode: SplitTree.Node? @@ -875,33 +879,48 @@ class BaseTerminalController: NSWindowController, break } } - + guard let sourceController, let sourceNode else { Ghostty.logger.warning("source surface not found in any window during drop") return } - - // TODO: Undo for cross window move. - - // Remove from source controller's tree + + // Remove from source controller's tree and add it to our tree. + // We do this first because if there is an error then we can + // abort. let sourceTreeWithoutNode = sourceController.surfaceTree.remove(sourceNode) - sourceController.replaceSurfaceTree( - sourceTreeWithoutNode, - moveFocusTo: nil, - moveFocusFrom: nil, - undoAction: nil) - - // Insert into our tree + let newTree: SplitTree do { - let newTree = try surfaceTree.insert(view: source, at: destination, direction: direction) - replaceSurfaceTree( - newTree, - moveFocusTo: source, - moveFocusFrom: focusedSurface, - undoAction: "Move Split") + newTree = try surfaceTree.insert(view: source, at: destination, direction: direction) } catch { Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)") + return } + + // If our old sourceTree became empty, disable undo, because this will + // close the window and we don't have a way to restore that currently. + if sourceTreeWithoutNode.isEmpty { + undoManager?.disableUndoRegistration() + } + defer { + if sourceTreeWithoutNode.isEmpty { + undoManager?.enableUndoRegistration() + } + } + + // Treat our undo below as a full group. + undoManager?.beginUndoGrouping() + undoManager?.setActionName("Move Split") + defer { + undoManager?.endUndoGrouping() + } + + sourceController.replaceSurfaceTree( + sourceTreeWithoutNode) + replaceSurfaceTree( + newTree, + moveFocusTo: source, + moveFocusFrom: focusedSurface) } func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c5481851b..ae0b44e4a 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -671,7 +671,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// 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() { + func closeWindowImmediately() { guard let window = window else { return } registerUndoForCloseWindow() From 9b7124cf62140356817a0bc2c1f10b318fd99cab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 13:31:10 -0800 Subject: [PATCH 297/605] macos: use preference key to detect self dragging --- .../Features/Splits/TerminalSplitTreeView.swift | 7 ++++++- .../Ghostty/Surface View/SurfaceGrabHandle.swift | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index cffab8c6c..eb946673b 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -92,6 +92,7 @@ fileprivate struct TerminalSplitLeaf: View { let action: (TerminalSplitOperation) -> Void @State private var dropState: DropState = .idle + @State private var isSelfDragging: Bool = false var body: some View { GeometryReader { geometry in @@ -105,11 +106,15 @@ fileprivate struct TerminalSplitLeaf: View { action: action )) .overlay { - if case .dropping(let zone) = dropState { + if !isSelfDragging, case .dropping(let zone) = dropState { zone.overlay(in: geometry) .allowsHitTesting(false) } } + .onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in + Ghostty.logger.warning("BABY WE DRAGGING \(String(describing: value))") + isSelfDragging = value == surfaceView.id + } .accessibilityElement(children: .contain) .accessibilityLabel("Terminal pane") } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 8f4347644..2d2ce59e3 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,6 +1,16 @@ import SwiftUI extension Ghostty { + /// A preference key that propagates the ID of the SurfaceView currently being dragged, + /// or nil if no surface is being dragged. + struct DraggingSurfaceKey: PreferenceKey { + static var defaultValue: SurfaceView.ID? = nil + + static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) { + value = nextValue() ?? value + } + } + /// A grab handle overlay at the top of the surface for dragging the window. /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { @@ -38,6 +48,7 @@ extension Ghostty { .draggable(surfaceView) { SurfaceDragPreview(surfaceView: surfaceView, scale: previewScale) } + .preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil) } } From be97b5bede3cbdf73b1d46e56ddd2124ed9023b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 13:59:41 -0800 Subject: [PATCH 298/605] macOS: convert Surface dragging to use NSDraggingSource --- macos/Ghostty.xcodeproj/project.pbxproj | 2 + .../Splits/TerminalSplitTreeView.swift | 1 - .../Surface View/SurfaceDragSource.swift | 196 ++++++++++++++++++ .../Surface View/SurfaceGrabHandle.swift | 67 +----- .../SurfaceView+Transferable.swift | 5 + .../Ghostty/Surface View/SurfaceView.swift | 4 + 6 files changed, 215 insertions(+), 60 deletions(-) create mode 100644 macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a2516fe10..100ddeaf5 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -145,6 +145,8 @@ Ghostty/Ghostty.Surface.swift, "Ghostty/NSEvent+Extension.swift", "Ghostty/Surface View/InspectorView.swift", + "Ghostty/Surface View/SurfaceDragSource.swift", + "Ghostty/Surface View/SurfaceGrabHandle.swift", "Ghostty/Surface View/SurfaceScrollView.swift", "Ghostty/Surface View/SurfaceView_AppKit.swift", Helpers/AppInfo.swift, diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index eb946673b..73d61439c 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -112,7 +112,6 @@ fileprivate struct TerminalSplitLeaf: View { } } .onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in - Ghostty.logger.warning("BABY WE DRAGGING \(String(describing: value))") isSelfDragging = value == surfaceView.id } .accessibilityElement(children: .contain) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift new file mode 100644 index 000000000..534834af3 --- /dev/null +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -0,0 +1,196 @@ +import AppKit +import SwiftUI + +extension Ghostty { + /// A preference key that propagates the ID of the SurfaceView currently being dragged, + /// or nil if no surface is being dragged. + struct DraggingSurfaceKey: PreferenceKey { + static var defaultValue: SurfaceView.ID? = nil + + static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) { + value = nextValue() ?? value + } + } + + /// A SwiftUI view that provides drag source functionality for terminal surfaces. + /// + /// This view wraps an AppKit-based drag source to enable drag-and-drop reordering + /// of terminal surfaces within split views. When the user drags this view, it initiates + /// an `NSDraggingSession` with the surface's UUID encoded in the pasteboard, allowing + /// drop targets to identify which surface is being moved. + /// + /// The view also publishes the dragging state via `DraggingSurfaceKey` preference, + /// enabling parent views to react to ongoing drag operations. + struct SurfaceDragSource: View { + /// The surface view that will be dragged. + let surfaceView: SurfaceView + + /// Binding that reflects whether a drag session is currently active. + @Binding var isDragging: Bool + + /// Binding that reflects whether the mouse is hovering over this view. + @Binding var isHovering: Bool + + var body: some View { + SurfaceDragSourceViewRepresentable( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering) + .preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil) + } + } + + /// An NSViewRepresentable that provides AppKit-based drag source functionality. + /// This gives us control over the drag lifecycle, particularly detecting drag start. + fileprivate struct SurfaceDragSourceViewRepresentable: NSViewRepresentable { + let surfaceView: SurfaceView + @Binding var isDragging: Bool + @Binding var isHovering: Bool + + func makeNSView(context: Context) -> SurfaceDragSourceView { + let view = SurfaceDragSourceView() + view.surfaceView = surfaceView + view.onDragStateChanged = { dragging in + isDragging = dragging + } + view.onHoverChanged = { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + return view + } + + func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) { + nsView.surfaceView = surfaceView + nsView.onDragStateChanged = { dragging in + isDragging = dragging + } + nsView.onHoverChanged = { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + isHovering = hovering + } + } + } + } + + /// The underlying NSView that handles drag operations. + /// + /// This view manages mouse tracking and drag initiation for surface reordering. + /// It uses a local event loop to detect drag gestures and initiates an + /// `NSDraggingSession` when the user drags beyond the threshold distance. + fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource { + /// Scale factor applied to the surface snapshot for the drag preview image. + private static let previewScale: CGFloat = 0.2 + + /// The surface view that will be dragged. Its UUID is encoded into the + /// pasteboard for drop targets to identify which surface is being moved. + var surfaceView: SurfaceView? + + /// Callback invoked when the drag state changes. Called with `true` when + /// a drag session begins, and `false` when it ends (completed or cancelled). + var onDragStateChanged: ((Bool) -> Void)? + + /// Callback invoked when the mouse enters or exits this view's bounds. + /// Used to update the hover state for visual feedback in the parent view. + var onHoverChanged: ((Bool) -> Void)? + + /// Whether we are currently in a mouse tracking loop (between mouseDown + /// and either mouseUp or drag initiation). Used to determine cursor state. + private var isTracking: Bool = false + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + // Add our tracking area for mouse events + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, + userInfo: nil + )) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand) + } + + override func mouseEntered(with event: NSEvent) { + onHoverChanged?(true) + } + + override func mouseExited(with event: NSEvent) { + onHoverChanged?(false) + } + + override func mouseDragged(with event: NSEvent) { + guard !isTracking, let surfaceView = surfaceView else { return } + + // Create the pasteboard item with the surface ID + let data = withUnsafeBytes(of: surfaceView.id.uuid) { Data($0) } + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setData(data, forType: .ghosttySurfaceId) + let item = NSDraggingItem(pasteboardWriter: pasteboardItem) + + // Create a scaled preview image from the surface snapshot + if let snapshot = surfaceView.asImage { + let imageSize = NSSize( + width: snapshot.size.width * Self.previewScale, + height: snapshot.size.height * Self.previewScale + ) + let scaledImage = NSImage(size: imageSize) + scaledImage.lockFocus() + snapshot.draw( + in: NSRect(origin: .zero, size: imageSize), + from: NSRect(origin: .zero, size: snapshot.size), + operation: .copy, + fraction: 1.0 + ) + scaledImage.unlockFocus() + + item.setDraggingFrame( + NSRect(origin: .zero, size: imageSize), + contents: scaledImage + ) + } + + onDragStateChanged?(true) + beginDraggingSession(with: [item], event: event, source: self) + } + + // MARK: NSDraggingSource + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + return context == .withinApplication ? .move : [] + } + + func draggingSession( + _ session: NSDraggingSession, + willBeginAt screenPoint: NSPoint + ) { + isTracking = true + } + + func draggingSession( + _ session: NSDraggingSession, + movedTo screenPoint: NSPoint + ) { + NSCursor.closedHand.set() + } + + func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + isTracking = false + onDragStateChanged?(false) + } + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 2d2ce59e3..0b0b394ce 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,21 +1,11 @@ +import AppKit import SwiftUI -extension Ghostty { - /// A preference key that propagates the ID of the SurfaceView currently being dragged, - /// or nil if no surface is being dragged. - struct DraggingSurfaceKey: PreferenceKey { - static var defaultValue: SurfaceView.ID? = nil - - static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) { - value = nextValue() ?? value - } - } - +extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { private let handleHeight: CGFloat = 10 - private let previewScale: CGFloat = 0.2 let surfaceView: SurfaceView @@ -35,58 +25,17 @@ extension Ghostty { } } .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - isHovering = hovering - } + .overlay { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) } - .backport.pointerStyle(isHovering ? .grabIdle : nil) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .draggable(surfaceView) { - SurfaceDragPreview(surfaceView: surfaceView, scale: previewScale) - } - .preference(key: DraggingSurfaceKey.self, value: isDragging ? surfaceView.id : nil) - } - } - - /// A miniature preview of the surface view for drag operations that updates periodically. - private struct SurfaceDragPreview: View { - let surfaceView: SurfaceView - let scale: CGFloat - - var body: some View { - // We need to use a TimelineView to ensure that this doesn't - // cache forever. This will NOT let the view live update while - // being dragged; macOS doesn't seem to allow that. But it will - // make sure on new drags the screenshot is updated. - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in - if let snapshot = surfaceView.asImage { - #if canImport(AppKit) - Image(nsImage: snapshot) - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: snapshot.size.width * scale, - height: snapshot.size.height * scale - ) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(radius: 10) - #elseif canImport(UIKit) - Image(uiImage: snapshot) - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: snapshot.size.width * scale, - height: snapshot.size.height * scale - ) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(radius: 10) - #endif - } - } } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index d5d47f601..7eef69a71 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -49,3 +49,8 @@ extension UTType { /// a way to look up a surface by ID. static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId") } + +extension NSPasteboard.PasteboardType { + /// Pasteboard type for dragging surface IDs. + static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier) +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 872b89d30..c224d373e 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -225,9 +225,13 @@ extension Ghostty { } } + #if canImport(AppKit) // Grab handle for dragging the window. We want this to appear at the very // top Z-index os it isn't faded by the unfocused overlay. + // + // This is disabled except on macOS because it uses AppKit drag/drop APIs. SurfaceGrabHandle(surfaceView: surfaceView) + #endif } } From 7b743164ef1d5f7569fb451d23054937aae2faee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 14:39:41 -0800 Subject: [PATCH 299/605] macos: fix iOS builds --- macos/Sources/Ghostty/GhosttyDelegate.swift | 2 ++ .../Sources/Ghostty/Surface View/SurfaceView+Transferable.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/macos/Sources/Ghostty/GhosttyDelegate.swift b/macos/Sources/Ghostty/GhosttyDelegate.swift index 0ce3dced4..a9d255737 100644 --- a/macos/Sources/Ghostty/GhosttyDelegate.swift +++ b/macos/Sources/Ghostty/GhosttyDelegate.swift @@ -1,3 +1,5 @@ +import Foundation + extension Ghostty { /// This is a delegate that should be applied to your global app delegate for GhosttyKit /// to perform app-global operations. diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index 7eef69a71..509713309 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -50,7 +50,9 @@ extension UTType { static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId") } +#if canImport(AppKit) extension NSPasteboard.PasteboardType { /// Pasteboard type for dragging surface IDs. static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier) } +#endif From e1f22472f623c821771a049c62cd12538eb0db96 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Dec 2025 15:04:27 -0800 Subject: [PATCH 300/605] macos: convert the transferable to a nsdraggingitem --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Surface View/SurfaceDragSource.swift | 6 +- .../Extensions/Transferable+Extension.swift | 58 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/Transferable+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 100ddeaf5..ad2c54ca9 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -169,6 +169,7 @@ "Helpers/Extensions/NSView+Extension.swift", "Helpers/Extensions/NSWindow+Extension.swift", "Helpers/Extensions/NSWorkspace+Extension.swift", + "Helpers/Extensions/Transferable+Extension.swift", "Helpers/Extensions/UndoManager+Extension.swift", "Helpers/Extensions/View+Extension.swift", Helpers/Fullscreen.swift, diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 534834af3..e40a1fc9b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -129,10 +129,8 @@ extension Ghostty { override func mouseDragged(with event: NSEvent) { guard !isTracking, let surfaceView = surfaceView else { return } - // Create the pasteboard item with the surface ID - let data = withUnsafeBytes(of: surfaceView.id.uuid) { Data($0) } - let pasteboardItem = NSPasteboardItem() - pasteboardItem.setData(data, forType: .ghosttySurfaceId) + // Create our dragging item from our transferable + guard let pasteboardItem = surfaceView.pasteboardItem() else { return } let item = NSDraggingItem(pasteboardWriter: pasteboardItem) // Create a scaled preview image from the surface snapshot diff --git a/macos/Sources/Helpers/Extensions/Transferable+Extension.swift b/macos/Sources/Helpers/Extensions/Transferable+Extension.swift new file mode 100644 index 000000000..3bcc9057f --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Transferable+Extension.swift @@ -0,0 +1,58 @@ +import AppKit +import CoreTransferable +import UniformTypeIdentifiers + +extension Transferable { + /// Converts this Transferable to an NSPasteboardItem with lazy data loading. + /// Data is only fetched when the pasteboard consumer requests it. This allows + /// bridging a Transferable to NSDraggingSource. + func pasteboardItem() -> NSPasteboardItem? { + let itemProvider = NSItemProvider() + itemProvider.register(self) + + let types = itemProvider.registeredTypeIdentifiers.compactMap { UTType($0) } + guard !types.isEmpty else { return nil } + + let item = NSPasteboardItem() + let dataProvider = TransferableDataProvider(itemProvider: itemProvider) + let pasteboardTypes = types.map { NSPasteboard.PasteboardType($0.identifier) } + item.setDataProvider(dataProvider, forTypes: pasteboardTypes) + + return item + } +} + +private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProvider { + private let itemProvider: NSItemProvider + + init(itemProvider: NSItemProvider) { + self.itemProvider = itemProvider + super.init() + } + + func pasteboard( + _ pasteboard: NSPasteboard?, + item: NSPasteboardItem, + provideDataForType type: NSPasteboard.PasteboardType + ) { + // NSPasteboardItemDataProvider requires synchronous data return, but + // NSItemProvider.loadDataRepresentation is async. We use a semaphore + // to block until the async load completes. This is safe because AppKit + // calls this method on a background thread during drag operations. + let semaphore = DispatchSemaphore(value: 0) + + var result: Data? + itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in + result = data + semaphore.signal() + } + + // Wait for the data to load + semaphore.wait() + + // Set it. I honestly don't know what happens here if this fails. + if let data = result { + item.setData(data, forType: type) + } + } +} From e56dce3d84e62ee40e40426ab91c9c871bbb303b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 06:12:41 -0800 Subject: [PATCH 301/605] macos: don't create drop zone at all if self dragging --- .../Splits/TerminalSplitTreeView.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 73d61439c..2a42dc599 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -99,12 +99,20 @@ fileprivate struct TerminalSplitLeaf: View { Ghostty.InspectableSurface( surfaceView: surfaceView, isSplit: isSplit) - .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( - dropState: $dropState, - viewSize: geometry.size, - destinationSurface: surfaceView, - action: action - )) + .background { + // If we're dragging ourself, we hide the entire drop zone. This makes + // it so that a released drop animates back to its source properly + // so it is a proper invalid drop zone. + if !isSelfDragging { + Color.clear + .onDrop(of: [.ghosttySurfaceId], delegate: SplitDropDelegate( + dropState: $dropState, + viewSize: geometry.size, + destinationSurface: surfaceView, + action: action + )) + } + } .overlay { if !isSelfDragging, case .dropping(let zone) = dropState { zone.overlay(in: geometry) @@ -113,6 +121,9 @@ fileprivate struct TerminalSplitLeaf: View { } .onPreferenceChange(Ghostty.DraggingSurfaceKey.self) { value in isSelfDragging = value == surfaceView.id + if isSelfDragging { + dropState = .idle + } } .accessibilityElement(children: .contain) .accessibilityLabel("Terminal pane") From dbeeb952cc68072f13cef1004eda332114ef524d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 06:15:51 -0800 Subject: [PATCH 302/605] macos: fix dragging point --- .../Ghostty/Surface View/SurfaceDragSource.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index e40a1fc9b..ce243b7b3 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -149,8 +149,17 @@ extension Ghostty { ) scaledImage.unlockFocus() + // Position the drag image so the mouse is at the center of the image. + // I personally like the top middle or top left corner best but + // this matches macOS native tab dragging behavior (at least, as of + // macOS 26.2 on Dec 29, 2025). + let mouseLocation = convert(event.locationInWindow, from: nil) + let origin = NSPoint( + x: mouseLocation.x - imageSize.width / 2, + y: mouseLocation.y - imageSize.height / 2 + ) item.setDraggingFrame( - NSRect(origin: .zero, size: imageSize), + NSRect(origin: origin, size: imageSize), contents: scaledImage ) } From 1b1ff3d76c17af6571a03c0fb027efef73153959 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 06:25:47 -0800 Subject: [PATCH 303/605] macos: add some unit tests --- .../Helpers/TransferablePasteboardTests.swift | 124 +++++++++++++++++ .../Splits/TerminalSplitDropZoneTests.swift | 128 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 macos/Tests/Helpers/TransferablePasteboardTests.swift create mode 100644 macos/Tests/Splits/TerminalSplitDropZoneTests.swift diff --git a/macos/Tests/Helpers/TransferablePasteboardTests.swift b/macos/Tests/Helpers/TransferablePasteboardTests.swift new file mode 100644 index 000000000..055dd5785 --- /dev/null +++ b/macos/Tests/Helpers/TransferablePasteboardTests.swift @@ -0,0 +1,124 @@ +import Testing +import AppKit +import CoreTransferable +import UniformTypeIdentifiers +@testable import Ghostty + +struct TransferablePasteboardTests { + // MARK: - Test Helpers + + /// A simple Transferable type for testing pasteboard conversion. + private struct DummyTransferable: Transferable, Equatable { + let payload: String + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(contentType: .utf8PlainText) { value in + value.payload.data(using: .utf8)! + } importing: { data in + let string = String(data: data, encoding: .utf8)! + return DummyTransferable(payload: string) + } + } + } + + /// A Transferable type that registers multiple content types. + private struct MultiTypeTransferable: Transferable { + let text: String + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(contentType: .utf8PlainText) { value in + value.text.data(using: .utf8)! + } importing: { data in + MultiTypeTransferable(text: String(data: data, encoding: .utf8)!) + } + DataRepresentation(contentType: .plainText) { value in + value.text.data(using: .utf8)! + } importing: { data in + MultiTypeTransferable(text: String(data: data, encoding: .utf8)!) + } + } + } + + // MARK: - Basic Functionality + + @Test func pasteboardItemIsCreated() { + let transferable = DummyTransferable(payload: "hello") + let item = transferable.pasteboardItem() + #expect(item != nil) + } + + @Test func pasteboardItemContainsExpectedType() { + let transferable = DummyTransferable(payload: "hello") + guard let item = transferable.pasteboardItem() else { + Issue.record("Expected pasteboard item to be created") + return + } + + let expectedType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier) + #expect(item.types.contains(expectedType)) + } + + @Test func pasteboardItemProvidesCorrectData() { + let transferable = DummyTransferable(payload: "test data") + guard let item = transferable.pasteboardItem() else { + Issue.record("Expected pasteboard item to be created") + return + } + + let pasteboardType = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier) + + // Write to a pasteboard to trigger data provider + let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.writeObjects([item]) + + // Read back the data + guard let data = pasteboard.data(forType: pasteboardType) else { + Issue.record("Expected data to be available on pasteboard") + return + } + + let string = String(data: data, encoding: .utf8) + #expect(string == "test data") + } + + // MARK: - Multiple Content Types + + @Test func multipleTypesAreRegistered() { + let transferable = MultiTypeTransferable(text: "multi") + guard let item = transferable.pasteboardItem() else { + Issue.record("Expected pasteboard item to be created") + return + } + + let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier) + let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier) + + #expect(item.types.contains(utf8Type)) + #expect(item.types.contains(plainType)) + } + + @Test func multipleTypesProvideCorrectData() { + let transferable = MultiTypeTransferable(text: "shared content") + guard let item = transferable.pasteboardItem() else { + Issue.record("Expected pasteboard item to be created") + return + } + + let pasteboard = NSPasteboard(name: .init("test-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.writeObjects([item]) + + // Both types should provide the same content + let utf8Type = NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier) + let plainType = NSPasteboard.PasteboardType(UTType.plainText.identifier) + + if let utf8Data = pasteboard.data(forType: utf8Type) { + #expect(String(data: utf8Data, encoding: .utf8) == "shared content") + } + + if let plainData = pasteboard.data(forType: plainType) { + #expect(String(data: plainData, encoding: .utf8) == "shared content") + } + } +} diff --git a/macos/Tests/Splits/TerminalSplitDropZoneTests.swift b/macos/Tests/Splits/TerminalSplitDropZoneTests.swift new file mode 100644 index 000000000..5c956fcc8 --- /dev/null +++ b/macos/Tests/Splits/TerminalSplitDropZoneTests.swift @@ -0,0 +1,128 @@ +import Testing +import Foundation +@testable import Ghostty + +struct TerminalSplitDropZoneTests { + private let standardSize = CGSize(width: 100, height: 100) + + // MARK: - Basic Edge Detection + + @Test func topEdge() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 5), in: standardSize) + #expect(zone == .top) + } + + @Test func bottomEdge() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 95), in: standardSize) + #expect(zone == .bottom) + } + + @Test func leftEdge() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 5, y: 50), in: standardSize) + #expect(zone == .left) + } + + @Test func rightEdge() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 95, y: 50), in: standardSize) + #expect(zone == .right) + } + + // MARK: - Corner Tie-Breaking + // When distances are equal, the check order determines the result: + // left -> right -> top -> bottom + + @Test func topLeftCornerSelectsLeft() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 0), in: standardSize) + #expect(zone == .left) + } + + @Test func topRightCornerSelectsRight() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 0), in: standardSize) + #expect(zone == .right) + } + + @Test func bottomLeftCornerSelectsLeft() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 0, y: 100), in: standardSize) + #expect(zone == .left) + } + + @Test func bottomRightCornerSelectsRight() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 100), in: standardSize) + #expect(zone == .right) + } + + // MARK: - Center Point (All Distances Equal) + + @Test func centerSelectsLeft() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 50), in: standardSize) + #expect(zone == .left) + } + + // MARK: - Non-Square Aspect Ratio + + @Test func rectangularViewTopEdge() { + let size = CGSize(width: 200, height: 100) + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 100, y: 10), in: size) + #expect(zone == .top) + } + + @Test func rectangularViewLeftEdge() { + let size = CGSize(width: 200, height: 100) + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 10, y: 50), in: size) + #expect(zone == .left) + } + + @Test func tallRectangleTopEdge() { + let size = CGSize(width: 100, height: 200) + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 10), in: size) + #expect(zone == .top) + } + + // MARK: - Out-of-Bounds Points + + @Test func pointLeftOfViewSelectsLeft() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: -10, y: 50), in: standardSize) + #expect(zone == .left) + } + + @Test func pointAboveViewSelectsTop() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: -10), in: standardSize) + #expect(zone == .top) + } + + @Test func pointRightOfViewSelectsRight() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 110, y: 50), in: standardSize) + #expect(zone == .right) + } + + @Test func pointBelowViewSelectsBottom() { + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 50, y: 110), in: standardSize) + #expect(zone == .bottom) + } + + // MARK: - Diagonal Regions (Triangular Zones) + + @Test func upperLeftTriangleSelectsLeft() { + // Point in the upper-left triangle, closer to left than top + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 30), in: standardSize) + #expect(zone == .left) + } + + @Test func upperRightTriangleSelectsRight() { + // Point in the upper-right triangle, closer to right than top + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 30), in: standardSize) + #expect(zone == .right) + } + + @Test func lowerLeftTriangleSelectsLeft() { + // Point in the lower-left triangle, closer to left than bottom + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 20, y: 70), in: standardSize) + #expect(zone == .left) + } + + @Test func lowerRightTriangleSelectsRight() { + // Point in the lower-right triangle, closer to right than bottom + let zone = TerminalSplitDropZone.calculate(at: CGPoint(x: 80, y: 70), in: standardSize) + #expect(zone == .right) + } +} From cfa3de5d9b34e9ee9edb9adb91b6cf7616ce3c7a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 06:33:29 -0800 Subject: [PATCH 304/605] macos: change style --- macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index 0b0b394ce..d8ff49163 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -19,9 +19,9 @@ extension Ghostty { .frame(height: handleHeight) .overlay(alignment: .center) { if isHovering || isDragging { - Capsule() - .fill(Color.white.opacity(0.4)) - .frame(width: 40, height: 4) + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white.opacity(0.5)) } } .contentShape(Rectangle()) From c164e3bc02ed459d0b29c5b81406cfd97b0f7399 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 07:13:23 -0800 Subject: [PATCH 305/605] macos: fix messy rebase --- macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 37d868a9f..f9a5480ec 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1654,6 +1654,7 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1662,6 +1663,7 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 + self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1671,6 +1673,7 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From 83314473983d41b99547144dde83624041b8e58f Mon Sep 17 00:00:00 2001 From: cyppe Date: Mon, 29 Dec 2025 16:15:02 +0100 Subject: [PATCH 306/605] Improve type detection --- src/apprt/gtk/class/surface.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cbd444936..22e08c598 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3642,11 +3642,9 @@ const Clipboard = struct { // pass through when the clipboard contains non-text content (e.g., images). if (state == .paste) { const formats = clipboard.getFormats(); - if (formats.containMimeType("text/plain") == 0 and - formats.containMimeType("UTF8_STRING") == 0 and - formats.containMimeType("TEXT") == 0 and - formats.containMimeType("STRING") == 0) - { + // G_TYPE_STRING = G_TYPE_MAKE_FUNDAMENTAL(16) = (16 << 2) = 64 + const G_TYPE_STRING: usize = 64; + if (formats.containGtype(G_TYPE_STRING) == 0) { log.debug("clipboard has no text format, not starting paste request", .{}); return false; } From 86d5048dad55f6931b8e35b736b335911f1d1f55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 08:50:23 -0800 Subject: [PATCH 307/605] terminal: PageList needs to fix up viewport pin after row change From #10074 The test comments explain in detail. --- src/terminal/PageList.zig | 94 +++++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 07b264ef5..910083b7b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -394,6 +394,7 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { const IntegrityError = error{ TotalRowsMismatch, ViewportPinOffsetMismatch, + ViewportPinInsufficientRows, PageSerialInvalid, }; @@ -435,9 +436,8 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { return IntegrityError.TotalRowsMismatch; } - // Verify that our viewport pin row offset is correct. - if (self.viewport == .pin) pin: { - const cached_offset = self.viewport_pin_row_offset orelse break :pin; + if (self.viewport == .pin) { + // Verify that our viewport pin row offset is correct. const actual_offset: usize = offset: { var offset: usize = 0; var node = self.pages.last; @@ -456,12 +456,24 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { return error.ViewportPinOffsetMismatch; }; - if (cached_offset != actual_offset) { + if (self.viewport_pin_row_offset) |cached_offset| { + if (cached_offset != actual_offset) { + log.warn( + "PageList integrity violation: viewport pin offset mismatch cached={} actual={}", + .{ cached_offset, actual_offset }, + ); + return error.ViewportPinOffsetMismatch; + } + } + + // Ensure our viewport has enough rows. + const rows = self.total_rows - actual_offset; + if (rows < self.rows) { log.warn( - "PageList integrity violation: viewport pin offset mismatch cached={} actual={}", - .{ cached_offset, actual_offset }, + "PageList integrity violation: viewport pin rows too small rows={} needed={}", + .{ rows, self.rows }, ); - return error.ViewportPinOffsetMismatch; + return error.ViewportPinInsufficientRows; } } } @@ -856,6 +868,16 @@ pub fn resize(self: *PageList, opts: Resize) !void { try self.resizeCols(cols, opts.cursor); }, } + + // Various resize operations can change our total row count such + // that our viewport pin is now in the active area and has insufficient + // space. We need to check for this case and fix it up. + switch (self.viewport) { + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .active; + }, + .active, .top => {}, + } } /// Resize the pagelist with reflow by adding or removing columns. @@ -1717,7 +1739,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // area, since that will lead to all sorts of problems. switch (self.viewport) { .pin => if (self.pinIsActive(self.viewport_pin.*)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; }, .active, .top => {}, } @@ -8284,7 +8306,7 @@ test "PageList resize reflow invalidates viewport offset cache" { // Verify scrollbar cache was invalidated during reflow try testing.expectEqual(Scrollbar{ .total = s.total_rows, - .offset = 8, + .offset = 5, .len = s.rows, }, s.scrollbar()); } @@ -10850,3 +10872,57 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Verify the resize succeeded try testing.expectEqual(@as(usize, 2), s.cols); } + +test "PageList resize grow cols with unwrap fixes viewport pin" { + // Regression test: after resize/reflow, the viewport pin can end up at a + // position where pin.y + rows > total_rows, causing getBottomRight to panic. + + // The plan is to pin viewport in history, then grow columns to unwrap rows. + // The unwrap reduces total_rows, but the tracked pin moves to a position + // that no longer has enough rows below it for the viewport height. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 10, null); + defer s.deinit(); + + // Make sure we have some history, in this case we have 30 rows of history + try s.growRows(30); + try testing.expectEqual(@as(usize, 40), s.totalRows()); + + // Fill all rows with wrapped content (pairs that unwrap when cols increase) + var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.node.data; + for (chunk.start..chunk.end) |y| { + const rac = page.getRowAndCell(0, y); + if (y % 2 == 0) { + rac.row.wrap = true; + } else { + rac.row.wrap_continuation = true; + } + for (0..s.cols) |x| { + page.getRowAndCell(x, y).cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + } + + // Pin viewport at row 28 (in history, 2 rows before active area at row 30). + // After unwrap: row 28 -> row 14, total_rows 40 -> 20, active starts at 10. + // Pin at 14 needs rows 14-23, but only 0-19 exist -> overflow. + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = 28 } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expect(s.getBottomRight(.viewport) != null); + + // Resize with reflow: unwraps rows, reducing total_rows + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expect(s.totalRows() < 40); + + // Used to panic here, so test that we can get the bottom right. + const br_after = s.getBottomRight(.viewport); + try testing.expect(br_after != null); +} From 972b65eb1b12d7872790639066c20c0bf46f783b Mon Sep 17 00:00:00 2001 From: cyppe Date: Mon, 29 Dec 2025 18:32:25 +0100 Subject: [PATCH 308/605] review --- src/apprt/gtk/class/surface.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 22e08c598..cb5122314 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3642,9 +3642,7 @@ const Clipboard = struct { // pass through when the clipboard contains non-text content (e.g., images). if (state == .paste) { const formats = clipboard.getFormats(); - // G_TYPE_STRING = G_TYPE_MAKE_FUNDAMENTAL(16) = (16 << 2) = 64 - const G_TYPE_STRING: usize = 64; - if (formats.containGtype(G_TYPE_STRING) == 0) { + if (formats.containGtype(gobject.ext.types.string) == 0) { log.debug("clipboard has no text format, not starting paste request", .{}); return false; } From 25c413005b2ffa3a414fbd1a77ff4e416559c92a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 09:49:57 -0800 Subject: [PATCH 309/605] macos: emit a notification when the surface drag ends outside area --- .../Surface View/SurfaceDragSource.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index ce243b7b3..34184e46e 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -99,6 +99,18 @@ extension Ghostty { /// and either mouseUp or drag initiation). Used to determine cursor state. private var isTracking: Bool = false + /// Local event monitor to detect escape key presses during drag. + private var escapeMonitor: Any? + + /// Whether the current drag was cancelled by pressing escape. + private var dragCancelledByEscape: Bool = false + + deinit { + if let escapeMonitor { + NSEvent.removeMonitor(escapeMonitor) + } + } + override func updateTrackingAreas() { super.updateTrackingAreas() @@ -182,6 +194,15 @@ extension Ghostty { willBeginAt screenPoint: NSPoint ) { isTracking = true + + // Reset our escape tracking + dragCancelledByEscape = false + escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == 53 { // Escape key + self?.dragCancelledByEscape = true + } + return event + } } func draggingSession( @@ -196,8 +217,32 @@ extension Ghostty { endedAt screenPoint: NSPoint, operation: NSDragOperation ) { + if let escapeMonitor { + NSEvent.removeMonitor(escapeMonitor) + self.escapeMonitor = nil + } + + if operation == [] && !dragCancelledByEscape { + let endsInWindow = NSApplication.shared.windows.contains { window in + window.isVisible && window.frame.contains(screenPoint) + } + if !endsInWindow { + NotificationCenter.default.post( + name: .ghosttySurfaceDragEndedNoTarget, + object: surfaceView + ) + } + } + isTracking = false onDragStateChanged?(false) } } } + +extension Notification.Name { + /// Posted when a surface drag session ends with no operation (the drag was + /// released outside a valid drop target) and was not cancelled by the user + /// pressing escape. The notification's object is the SurfaceView that was dragged. + static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget") +} From 89c515cab50b2d3d78b33065f8567bffb92ca706 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 10:01:29 -0800 Subject: [PATCH 310/605] macos: new window from tree in TerminalController --- .../Terminal/TerminalController.swift | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index ae0b44e4a..bd2c99a22 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -275,6 +275,56 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return c } + /// Create a new window with an existing split tree. + /// The window will be sized to match the tree's current view bounds if available. + static func newWindow( + _ ghostty: Ghostty.App, + tree: SplitTree + ) -> TerminalController { + let c = TerminalController.init(ghostty, withSurfaceTree: tree) + + // Calculate the target frame based on the tree's view bounds + let treeSize: CGSize? = tree.root?.viewBounds() + + DispatchQueue.main.async { + if let window = c.window { + // If we have a tree size, resize the window's content to match + if let treeSize, treeSize.width > 0, treeSize.height > 0 { + window.setContentSize(treeSize) + window.constrainToScreen() + } + + if !window.styleMask.contains(.fullScreen) { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + } + + c.showWindow(self) + } + + // Setup our undo + if let undoManager = c.undoManager { + undoManager.setActionName("New Window") + undoManager.registerUndo( + withTarget: c, + expiresAfter: c.undoExpiration + ) { target in + undoManager.disableUndoRegistration { + target.closeWindow(nil) + } + + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newWindow(ghostty, tree: tree) + } + } + } + + return c + } + static func newTab( _ ghostty: Ghostty.App, from parent: NSWindow? = nil, @@ -397,7 +447,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return controller } - + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { From 5ecd26727e738ca9a26a02f92fc9711dd593b542 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 10:11:58 -0800 Subject: [PATCH 311/605] macos: allow pulling split out into its own window --- .../Terminal/BaseTerminalController.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e278f653b..ce2d5a5f2 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -200,6 +200,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidPresentTerminal(_:)), name: Ghostty.Notification.ghosttyPresentTerminal, object: nil) + center.addObserver( + self, + selector: #selector(ghosttySurfaceDragEndedNoTarget(_:)), + name: .ghosttySurfaceDragEndedNoTarget, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -721,6 +726,31 @@ class BaseTerminalController: NSWindowController, target.highlight() } + @objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard let targetNode = surfaceTree.root?.node(view: target) else { return } + + // If our tree isn't split, then we never create a new window, because + // it is already a single split. + guard surfaceTree.isSplit else { return } + + // Remove the surface from our tree + let removedTree = surfaceTree.remove(targetNode) + + // Create a new tree with the dragged surface and open a new window + let newTree = SplitTree(view: target) + + // Treat our undo below as a full group. + undoManager?.beginUndoGrouping() + undoManager?.setActionName("Move Split") + defer { + undoManager?.endUndoGrouping() + } + + replaceSurfaceTree(removedTree, moveFocusFrom: focusedSurface) + _ = TerminalController.newWindow(ghostty, tree: newTree) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { From 29edbbbc8620e66c50fa3163522220da2a48b3af Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 10:19:51 -0800 Subject: [PATCH 312/605] macos: open dragged windows where they are dropped --- .../Terminal/BaseTerminalController.swift | 5 ++++- .../Features/Terminal/TerminalController.swift | 15 +++++++++++++-- .../Ghostty/Surface View/SurfaceDragSource.swift | 12 ++++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ce2d5a5f2..529b1d18a 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -734,6 +734,9 @@ class BaseTerminalController: NSWindowController, // it is already a single split. guard surfaceTree.isSplit else { return } + // Extract the drop position from the notification + let dropPoint = notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint + // Remove the surface from our tree let removedTree = surfaceTree.remove(targetNode) @@ -748,7 +751,7 @@ class BaseTerminalController: NSWindowController, } replaceSurfaceTree(removedTree, moveFocusFrom: focusedSurface) - _ = TerminalController.newWindow(ghostty, tree: newTree) + _ = TerminalController.newWindow(ghostty, tree: newTree, position: dropPoint) } // MARK: Local Events diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bd2c99a22..021b9f394 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -277,9 +277,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// Create a new window with an existing split tree. /// The window will be sized to match the tree's current view bounds if available. + /// - Parameters: + /// - ghostty: The Ghostty app instance. + /// - tree: The split tree to use for the new window. + /// - position: Optional screen position (top-left corner) for the new window. + /// If nil, the window will cascade from the last cascade point. static func newWindow( _ ghostty: Ghostty.App, - tree: SplitTree + tree: SplitTree, + position: NSPoint? = nil ) -> TerminalController { let c = TerminalController.init(ghostty, withSurfaceTree: tree) @@ -295,7 +301,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } if !window.styleMask.contains(.fullScreen) { - Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + if let position { + window.setFrameTopLeftPoint(position) + window.constrainToScreen() + } else { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 34184e46e..21416ac75 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -177,7 +177,11 @@ extension Ghostty { } onDragStateChanged?(true) - beginDraggingSession(with: [item], event: event, source: self) + let session = beginDraggingSession(with: [item], event: event, source: self) + + // We need to disable this so that endedAt happens immediately for our + // drags outside of any targets. + session.animatesToStartingPositionsOnCancelOrFail = false } // MARK: NSDraggingSource @@ -229,7 +233,8 @@ extension Ghostty { if !endsInWindow { NotificationCenter.default.post( name: .ghosttySurfaceDragEndedNoTarget, - object: surfaceView + object: surfaceView, + userInfo: [Foundation.Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey: screenPoint] ) } } @@ -245,4 +250,7 @@ extension Notification.Name { /// released outside a valid drop target) and was not cancelled by the user /// pressing escape. The notification's object is the SurfaceView that was dragged. static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget") + + /// Key for the screen point where the drag ended in the userInfo dictionary. + static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint" } From 19f7b57cd1eddff5b02ca2eb227ffb13d2dee4dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 10:36:17 -0800 Subject: [PATCH 313/605] macos: fixup focus issues when closing the new window --- .../Terminal/BaseTerminalController.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 529b1d18a..c34119561 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -733,9 +733,11 @@ class BaseTerminalController: NSWindowController, // If our tree isn't split, then we never create a new window, because // it is already a single split. guard surfaceTree.isSplit else { return } - - // Extract the drop position from the notification - let dropPoint = notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint + + // If we are removing our focused surface then we move it. + if focusedSurface == target { + focusedSurface = findNextFocusTargetAfterClosing(node: targetNode) + } // Remove the surface from our tree let removedTree = surfaceTree.remove(targetNode) @@ -751,7 +753,10 @@ class BaseTerminalController: NSWindowController, } replaceSurfaceTree(removedTree, moveFocusFrom: focusedSurface) - _ = TerminalController.newWindow(ghostty, tree: newTree, position: dropPoint) + _ = TerminalController.newWindow( + ghostty, + tree: newTree, + position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint) } // MARK: Local Events @@ -1205,6 +1210,15 @@ class BaseTerminalController: NSWindowController, } func windowDidBecomeKey(_ notification: Notification) { + // If when we become key our first responder is the window itself, then we + // want to move focus to our focused terminal surface. This works around + // various weirdness with moving surfaces around. + if let window, window.firstResponder == window, let focusedSurface { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusedSurface) + } + } + // Becoming/losing key means we have to notify our surface(s) that we have focus // so things like cursors blink, pty events are sent, etc. self.syncFocusToSurfaceTree() From 7512f6158bbfddd88411a17735deba77f08ecf0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 10:46:58 -0800 Subject: [PATCH 314/605] macos: fix bugs --- .../Features/Terminal/BaseTerminalController.swift | 9 ++++++--- macos/Sources/Features/Terminal/TerminalController.swift | 9 +++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index c34119561..e7caf70a9 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -734,7 +734,9 @@ class BaseTerminalController: NSWindowController, // it is already a single split. guard surfaceTree.isSplit else { return } - // If we are removing our focused surface then we move it. + // If we are removing our focused surface then we move it. We need to + // keep track of our old one so undo sends focus back to the right place. + let oldFocusedSurface = focusedSurface if focusedSurface == target { focusedSurface = findNextFocusTargetAfterClosing(node: targetNode) } @@ -752,11 +754,12 @@ class BaseTerminalController: NSWindowController, undoManager?.endUndoGrouping() } - replaceSurfaceTree(removedTree, moveFocusFrom: focusedSurface) + replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface) _ = TerminalController.newWindow( ghostty, tree: newTree, - position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint) + position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint, + confirmUndo: false) } // MARK: Local Events diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 021b9f394..fbd215e2b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -285,7 +285,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr static func newWindow( _ ghostty: Ghostty.App, tree: SplitTree, - position: NSPoint? = nil + position: NSPoint? = nil, + confirmUndo: Bool = true, ) -> TerminalController { let c = TerminalController.init(ghostty, withSurfaceTree: tree) @@ -321,7 +322,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr expiresAfter: c.undoExpiration ) { target in undoManager.disableUndoRegistration { - target.closeWindow(nil) + if confirmUndo { + target.closeWindow(nil) + } else { + target.closeWindowImmediately() + } } undoManager.registerUndo( From a826892ef7502333dc81b74625dd82253f4e4323 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 11:07:52 -0800 Subject: [PATCH 315/605] macos: make undo/redo work for final split dragged out Fixes #10093 --- .../Terminal/BaseTerminalController.swift | 50 +++++++++++-------- .../Terminal/TerminalController.swift | 9 +++- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e7caf70a9..e4f700170 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -893,18 +893,19 @@ class BaseTerminalController: NSWindowController, if let sourceNode = surfaceTree.root?.node(view: source) { // Source is in our tree - same window move let treeWithoutSource = surfaceTree.remove(sourceNode) - + let newTree: SplitTree do { - let newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) - replaceSurfaceTree( - newTree, - moveFocusTo: source, - moveFocusFrom: focusedSurface, - undoAction: "Move Split") + newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) } catch { Ghostty.logger.warning("failed to insert surface during drop: \(error)") + return } + replaceSurfaceTree( + newTree, + moveFocusTo: source, + moveFocusFrom: focusedSurface, + undoAction: "Move Split") return } @@ -938,17 +939,6 @@ class BaseTerminalController: NSWindowController, return } - // If our old sourceTree became empty, disable undo, because this will - // close the window and we don't have a way to restore that currently. - if sourceTreeWithoutNode.isEmpty { - undoManager?.disableUndoRegistration() - } - defer { - if sourceTreeWithoutNode.isEmpty { - undoManager?.enableUndoRegistration() - } - } - // Treat our undo below as a full group. undoManager?.beginUndoGrouping() undoManager?.setActionName("Move Split") @@ -956,8 +946,28 @@ class BaseTerminalController: NSWindowController, undoManager?.endUndoGrouping() } - sourceController.replaceSurfaceTree( - sourceTreeWithoutNode) + if sourceTreeWithoutNode.isEmpty { + // If our source tree is becoming empty, then we're closing this terminal. + // We need to handle this carefully to get undo to work properly. If the + // controller is a TerminalController this is easy because it has a way + // to do this. + if let c = sourceController as? TerminalController { + c.closeWindowImmediately() + } else { + // Not a TerminalController so we always undo into a new window. + _ = TerminalController.newWindow( + sourceController.ghostty, + tree: sourceController.surfaceTree, + confirmUndo: false) + } + } else { + // The source isn't empty so we can do a simple replace which will handle + // the undo properly. + sourceController.replaceSurfaceTree( + sourceTreeWithoutNode) + } + + // Add in the surface to our tree replaceSurfaceTree( newTree, moveFocusTo: source, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index fbd215e2b..26ca8f70e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -945,13 +945,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // 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.id == focusedUUID }) { DispatchQueue.main.async { Ghostty.moveFocus(to: focusTarget, from: nil) } + } else if let focusedSurface = surfaceTree.first { + // No prior focused surface or we can't find it, let's focus + // the first. + self.focusedSurface = focusedSurface + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusedSurface, from: nil) + } } } } From 61df50d70b7d80096e1a3ca758309308c35303c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Dec 2025 12:06:36 -0800 Subject: [PATCH 316/605] input: add `end_key_sequence` binding action End the currently active key sequence, if any, and flush the keys up to this point to the terminal, excluding the key that triggered this action. For example: `ctrl+w>escape=end_key_sequence` would encode `ctrl+w` to the terminal and exit the key sequence. Normally, an invalid sequence will reset the key sequence and flush all data including the invalid key. This action allows you to flush only the prior keys, which is useful when you want to bind something like a control key (`ctrl+w`) but not send additional inputs. --- src/Surface.zig | 8 ++++++++ src/input/Binding.zig | 15 +++++++++++++++ src/input/command.zig | 1 + 3 files changed, 24 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 614f40475..43ee440c2 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5794,6 +5794,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool return try self.deactivateAllKeyTables(); }, + .end_key_sequence => { + // End the key sequence and flush queued keys to the terminal, + // but don't encode the key that triggered this action. This + // will do that because leaf keys (keys with bindings) aren't + // in the queued encoding list. + self.endKeySequence(.flush, .retain); + }, + .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e7507b112..d5b24c61b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -827,6 +827,20 @@ pub const Action = union(enum) { /// be undone or redone. redo, + /// End the currently active key sequence, if any, and flush the + /// keys up to this point to the terminal, excluding the key that + /// triggered this action. + /// + /// For example: `ctrl+w>escape=end_key_sequence` would encode + /// `ctrl+w` to the terminal and exit the key sequence. + /// + /// Normally, an invalid sequence will reset the key sequence and + /// flush all data including the invalid key. This action allows + /// you to flush only the prior keys, which is useful when you want + /// to bind something like a control key (`ctrl+w`) but not send + /// additional inputs. + end_key_sequence, + /// Activate a named key table (see `keybind` configuration documentation). /// The named key table will remain active until `deactivate_key_table` /// is called. If you want a one-shot key table activation, use the @@ -1316,6 +1330,7 @@ pub const Action = union(enum) { .activate_key_table_once, .deactivate_key_table, .deactivate_all_key_tables, + .end_key_sequence, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 936f2211c..f089112db 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -696,6 +696,7 @@ fn actionCommands(action: Action.Key) []const Command { .activate_key_table_once, .deactivate_key_table, .deactivate_all_key_tables, + .end_key_sequence, .crash, => comptime &.{}, From d09bac64aec6e8ef0ce84d3383ae19b94cbf1848 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Sun, 28 Dec 2025 15:39:29 +0100 Subject: [PATCH 317/605] Remove systemd integration from nautilus extension As far as I understand ghostty integrates with systemd already nowadays, and manages its own scopes/cgroups for tabs. As such, explicitly moving launching ghostty into a separate systemd scope from nautilus no longer seems necessary. --- dist/linux/ghostty_nautilus.py | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index 42c397642..4f13dd617 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -19,39 +19,13 @@ from os.path import isdir from gi import require_version -from gi.repository import Nautilus, GObject, Gio, GLib +from gi.repository import Nautilus, GObject, Gio class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): - def __init__(self): - super().__init__() - session = Gio.bus_get_sync(Gio.BusType.SESSION, None) - self._systemd = None - # Check if the this system runs under systemd, per sd_booted(3) - if isdir('/run/systemd/system/'): - self._systemd = Gio.DBusProxy.new_sync(session, - Gio.DBusProxyFlags.NONE, - None, - "org.freedesktop.systemd1", - "/org/freedesktop/systemd1", - "org.freedesktop.systemd1.Manager", None) - def _open_terminal(self, path): cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] - child = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) - if self._systemd: - # Move new terminal into a dedicated systemd scope to make systemd - # track the terminal separately; in particular this makes systemd - # keep a separate CPU and memory account for the terminal which in turn - # ensures that oomd doesn't take nautilus down if a process in - # ghostty consumes a lot of memory. - pid = int(child.get_identifier()) - props = [("PIDs", GLib.Variant('au', [pid])), - ('CollectMode', GLib.Variant('s', 'inactive-or-failed'))] - name = 'app-nautilus-com.mitchellh.ghostty-{}.scope'.format(pid) - args = GLib.Variant('(ssa(sv)a(sa(sv)))', (name, 'fail', props, [])) - self._systemd.call_sync('StartTransientUnit', args, - Gio.DBusCallFlags.NO_AUTO_START, 500, None) + Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) def _menu_item_activated(self, _menu, paths): for path in paths: From 622d49206a27659381f8a994b792215525a80c2f Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Dec 2025 17:42:19 +0100 Subject: [PATCH 318/605] Remove unused imports --- dist/linux/ghostty_nautilus.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index 4f13dd617..7e6c6e302 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -17,8 +17,6 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -from os.path import isdir -from gi import require_version from gi.repository import Nautilus, GObject, Gio From 5c4af69765b9668ca1a6f7623e42498f26040973 Mon Sep 17 00:00:00 2001 From: qingyunha <845767657@qq.com> Date: Tue, 30 Dec 2025 08:47:18 +0800 Subject: [PATCH 319/605] Update Vim filetype detection patterns Fixes #10094 --- src/extra/vim.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extra/vim.zig b/src/extra/vim.zig index 9140b83f8..062ccd2b6 100644 --- a/src/extra/vim.zig +++ b/src/extra/vim.zig @@ -10,7 +10,7 @@ pub const ftdetect = \\" \\" THIS FILE IS AUTO-GENERATED \\ - \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/*,*.ghostty setf ghostty + \\au BufRead,BufNewFile */ghostty/config,*/*.ghostty/config,*/ghostty/themes/*,*.ghostty setf ghostty \\ ; pub const ftplugin = From 53c510ac4055166038da29f4d05dbb7fa958e4e7 Mon Sep 17 00:00:00 2001 From: John Xu Date: Tue, 30 Dec 2025 16:32:12 +0800 Subject: [PATCH 320/605] macos: keep glass titlebar inset in sync on layout --- .../Terminal/TerminalViewContainer.swift | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index 1765edec3..c65dca1d2 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -8,6 +8,7 @@ class TerminalViewContainer: NSView { /// Glass effect view for liquid glass background when transparency is enabled private var glassEffectView: NSView? + private var glassTopConstraint: NSLayoutConstraint? private var derivedConfig: DerivedConfig init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { @@ -54,6 +55,12 @@ class TerminalViewContainer: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() updateGlassEffectIfNeeded() + updateGlassEffectTopInsetIfNeeded() + } + + override func layout() { + super.layout() + updateGlassEffectTopInsetIfNeeded() } @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -74,6 +81,7 @@ private extension TerminalViewContainer { @available(macOS 26.0, *) func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { if let existed = glassEffectView as? NSGlassEffectView { + updateGlassEffectTopInsetIfNeeded() return existed } guard let themeFrameView = window?.contentView?.superview else { @@ -82,12 +90,18 @@ private extension TerminalViewContainer { let effectView = NSGlassEffectView() addSubview(effectView, positioned: .below, relativeTo: terminalView) effectView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) + glassTopConstraint = effectView.topAnchor.constraint( + equalTo: topAnchor, + constant: -themeFrameView.safeAreaInsets.top + ) + if let glassTopConstraint { + NSLayoutConstraint.activate([ + glassTopConstraint, + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } glassEffectView = effectView return effectView } @@ -98,6 +112,7 @@ private extension TerminalViewContainer { guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { glassEffectView?.removeFromSuperview() glassEffectView = nil + glassTopConstraint = nil return } guard let effectView = addGlassEffectViewIfNeeded() else { @@ -120,6 +135,17 @@ private extension TerminalViewContainer { #endif // compiler(>=6.2) } + func updateGlassEffectTopInsetIfNeeded() { +#if compiler(>=6.2) + guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + return + } + guard glassEffectView != nil else { return } + guard let themeFrameView = window?.contentView?.superview else { return } + glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top +#endif // compiler(>=6.2) + } + struct DerivedConfig: Equatable { var backgroundOpacity: Double = 0 var backgroundBlur: Ghostty.Config.BackgroundBlur From f1bed9dd6a4a44aedbf634a6e7627cdb6d1b2366 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Dec 2025 22:24:37 +0100 Subject: [PATCH 321/605] Inline trivial method --- dist/linux/ghostty_nautilus.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index 7e6c6e302..965e8107e 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -21,13 +21,10 @@ from gi.repository import Nautilus, GObject, Gio class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): - def _open_terminal(self, path): - cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] - Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) - def _menu_item_activated(self, _menu, paths): for path in paths: - self._open_terminal(path) + cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] + Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) def _make_item(self, name, paths): item = Nautilus.MenuItem(name=name, label='Open in Ghostty', From 09d6a1ee2ee8c79f0690d271935d201cf4694ac9 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Dec 2025 22:25:33 +0100 Subject: [PATCH 322/605] Lift functions out of class Neither of these used self. --- dist/linux/ghostty_nautilus.py | 48 ++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index 965e8107e..e993d0aaa 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -20,37 +20,39 @@ from gi.repository import Nautilus, GObject, Gio -class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): - def _menu_item_activated(self, _menu, paths): - for path in paths: - cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] - Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) +def open_in_ghostty_activated(_menu, paths): + for path in paths: + cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] + Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) + +def get_paths_to_open(files): + paths = [] + for file in files: + location = file.get_location() if file.is_directory() else file.get_parent_location() + path = location.get_path() + if path and path not in paths: + paths.append(path) + if 10 < len(paths): + # Let's not open anything if the user selected a lot of directories, + # to avoid accidentally spamming their desktop with dozends of + # new windows or tabs. Ten is a totally arbitrary limit :) + return [] + else: + return paths + + +class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): def _make_item(self, name, paths): item = Nautilus.MenuItem(name=name, label='Open in Ghostty', icon='com.mitchellh.ghostty') - item.connect('activate', self._menu_item_activated, paths) + item.connect('activate', open_in_ghostty_activated, paths) return item - def _paths_to_open(self, files): - paths = [] - for file in files: - location = file.get_location() if file.is_directory() else file.get_parent_location() - path = location.get_path() - if path and path not in paths: - paths.append(path) - if 10 < len(paths): - # Let's not open anything if the user selected a lot of directories, - # to avoid accidentally spamming their desktop with dozends of - # new windows or tabs. Ten is a totally arbitrary limit :) - return [] - else: - return paths - def get_file_items(self, *args): # Nautilus 3.0 API passes args (window, files), 4.0 API just passes files files = args[0] if len(args) == 1 else args[1] - paths = self._paths_to_open(files) + paths = get_paths_to_open(files) if paths: return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)] else: @@ -59,7 +61,7 @@ class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): def get_background_items(self, *args): # Nautilus 3.0 API passes args (window, file), 4.0 API just passes file file = args[0] if len(args) == 1 else args[1] - paths = self._paths_to_open([file]) + paths = get_paths_to_open([file]) if paths: return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)] else: From dee093db579c1e28c4695351c0e16eda968291a2 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Dec 2025 22:33:57 +0100 Subject: [PATCH 323/605] Extract duplicated code into single helper Extract duplicated code from the two different menu item hooks into a single helper function, and then inline _make_item, as it's only used once now. --- dist/linux/ghostty_nautilus.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index e993d0aaa..80ec84ac3 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -42,27 +42,24 @@ def get_paths_to_open(files): return paths -class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): - def _make_item(self, name, paths): +def get_items_for_files(name, files): + paths = get_paths_to_open(files) + if paths: item = Nautilus.MenuItem(name=name, label='Open in Ghostty', icon='com.mitchellh.ghostty') item.connect('activate', open_in_ghostty_activated, paths) - return item + return [item] + else: + return [] + +class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): def get_file_items(self, *args): # Nautilus 3.0 API passes args (window, files), 4.0 API just passes files files = args[0] if len(args) == 1 else args[1] - paths = get_paths_to_open(files) - if paths: - return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)] - else: - return [] + return get_items_for_files('GhosttyNautilus::open_in_ghostty', files) def get_background_items(self, *args): # Nautilus 3.0 API passes args (window, file), 4.0 API just passes file file = args[0] if len(args) == 1 else args[1] - paths = get_paths_to_open([file]) - if paths: - return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)] - else: - return [] + return get_items_for_files('GhosttyNautilus::open_folder_in_ghostty', [file]) From 5c35a4710d91ec5d1eadb84c13f395dbd2ed9387 Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Dec 2025 22:51:07 +0100 Subject: [PATCH 324/605] Rename class It's a menu provider not a single action. --- dist/linux/ghostty_nautilus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index 80ec84ac3..cd5011b9f 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -53,7 +53,7 @@ def get_items_for_files(name, files): return [] -class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): +class GhosttyMenuProvider(GObject.GObject, Nautilus.MenuProvider): def get_file_items(self, *args): # Nautilus 3.0 API passes args (window, files), 4.0 API just passes files files = args[0] if len(args) == 1 else args[1] From ab4b54e2a45e949a857468a2d218f6ec10194d4a Mon Sep 17 00:00:00 2001 From: Sebastian Wiesner Date: Mon, 29 Dec 2025 23:07:01 +0100 Subject: [PATCH 325/605] Drop GNOME 42 compatibility GNOME 43 is from 2022-09, i.e. almost as old as Ghostty, so not longer worth supporting here. --- dist/linux/ghostty_nautilus.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py index cd5011b9f..01202031c 100644 --- a/dist/linux/ghostty_nautilus.py +++ b/dist/linux/ghostty_nautilus.py @@ -54,12 +54,8 @@ def get_items_for_files(name, files): class GhosttyMenuProvider(GObject.GObject, Nautilus.MenuProvider): - def get_file_items(self, *args): - # Nautilus 3.0 API passes args (window, files), 4.0 API just passes files - files = args[0] if len(args) == 1 else args[1] + def get_file_items(self, files): return get_items_for_files('GhosttyNautilus::open_in_ghostty', files) - def get_background_items(self, *args): - # Nautilus 3.0 API passes args (window, file), 4.0 API just passes file - file = args[0] if len(args) == 1 else args[1] + def get_background_items(self, file): return get_items_for_files('GhosttyNautilus::open_folder_in_ghostty', [file]) From 43c7277a6067c19bef16617e0217446b213d4297 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Dec 2025 13:06:41 -0800 Subject: [PATCH 326/605] macos: make surface grab handle visible in light mode --- macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index d8ff49163..f3ee80874 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -15,13 +15,13 @@ extension Ghostty { var body: some View { VStack(spacing: 0) { Rectangle() - .fill(Color.white.opacity(isHovering || isDragging ? 0.15 : 0)) + .fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0)) .frame(height: handleHeight) .overlay(alignment: .center) { if isHovering || isDragging { Image(systemName: "ellipsis") .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white.opacity(0.5)) + .foregroundColor(.primary.opacity(0.5)) } } .contentShape(Rectangle()) From c34bb5976a9d471bbe0a635b67dfc844c08ae650 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Dec 2025 13:09:19 -0800 Subject: [PATCH 327/605] macos: Ghostty.Command must copy string values We were previously storing the C struct which contained pointers into ephemeral memory that could cause segfaults later on. --- macos/Sources/Ghostty/Ghostty.Command.swift | 23 +++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Command.swift b/macos/Sources/Ghostty/Ghostty.Command.swift index 1479ae92d..797d469c5 100644 --- a/macos/Sources/Ghostty/Ghostty.Command.swift +++ b/macos/Sources/Ghostty/Ghostty.Command.swift @@ -3,28 +3,18 @@ import GhosttyKit extension Ghostty { /// `ghostty_command_s` struct Command: Sendable { - private let cValue: ghostty_command_s - /// The title of the command. - var title: String { - String(cString: cValue.title) - } + let title: String /// Human-friendly description of what this command will do. - var description: String { - String(cString: cValue.description) - } + let description: String /// The full action that must be performed to invoke this command. - var action: String { - String(cString: cValue.action) - } + let action: String /// Only the key portion of the action so you can compare action types, e.g. `goto_split` /// instead of `goto_split:left`. - var actionKey: String { - String(cString: cValue.action_key) - } + let actionKey: String /// True if this can be performed on this target. var isSupported: Bool { @@ -40,7 +30,10 @@ extension Ghostty { ] init(cValue: ghostty_command_s) { - self.cValue = cValue + self.title = String(cString: cValue.title) + self.description = String(cString: cValue.description) + self.action = String(cString: cValue.action) + self.actionKey = String(cString: cValue.action_key) } } } From e1ad74c0b738e5f6ab4981b39b2c88d01e1d374f Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 30 Dec 2025 17:14:04 -0500 Subject: [PATCH 328/605] debug codepoints --- src/font/shaper/harfbuzz.zig | 47 +++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index fa4e79ccd..02d28347e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -32,6 +32,14 @@ pub const Shaper = struct { /// The features to use for shaping. hb_feats: []harfbuzz.Feature, + // For debugging positions, turn this on: + debug_codepoints: std.ArrayListUnmanaged(DebugCodepoint) = .{}, + + const DebugCodepoint = struct { + cluster: u32, + codepoint: u32, + }; + const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); /// The cell_buf argument is the buffer to use for storing shaped results. @@ -74,6 +82,9 @@ pub const Shaper = struct { self.hb_buf.destroy(); self.cell_buf.deinit(self.alloc); self.alloc.free(self.hb_feats); + + // For debugging positions, turn this on: + self.debug_codepoints.deinit(self.alloc); } pub fn endFrame(self: *const Shaper) void { @@ -157,7 +168,7 @@ pub const Shaper = struct { const y_offset = cell_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); // For debugging positions, turn this on: - //debugPositions(cell_offset, pos_v); + try self.debugPositions(cell_offset, pos_v); try self.cell_buf.append(self.alloc, .{ .x = @intCast(info_v.cluster), @@ -198,6 +209,12 @@ pub const Shaper = struct { pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void { // log.warn("cluster={} cp={x}", .{ cluster, cp }); self.shaper.hb_buf.add(cp, cluster); + + // For debugging positions, turn this on: + try self.shaper.debug_codepoints.append(self.shaper.alloc, .{ + .cluster = cluster, + .codepoint = cp, + }); } pub fn finalize(self: RunIteratorHook) !void { @@ -206,9 +223,10 @@ pub const Shaper = struct { }; fn debugPositions( + self: *Shaper, cell_offset: anytype, pos_v: harfbuzz.GlyphPosition, - ) void { + ) !void { const x_offset = cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); const y_offset = cell_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); const advance_x_offset = cell_offset.x; @@ -216,14 +234,24 @@ pub const Shaper = struct { const x_offset_diff = x_offset - advance_x_offset; const y_offset_diff = y_offset - advance_y_offset; - // It'd be nice if we could log the original codepoints that went in to - // shaping this glyph, but at this point HarfBuzz has replaced - // `info_v.codepoint` with the glyph index (and that's only one of the - // codepoints anyway). We could have some way to map the cluster back - // to the original codepoints, but since that would only be used for - // debugging, we don't do that. if (@abs(x_offset_diff) > 0 or @abs(y_offset_diff) > 0) { - log.warn("position differs from advance: cluster={d} pos=({d},{d}) adv=({d},{d}) diff=({d},{d})", .{ + var allocating = std.Io.Writer.Allocating.init(self.alloc); + defer allocating.deinit(); + const writer = &allocating.writer; + const codepoints = self.debug_codepoints.items; + for (codepoints) |cp| { + if (cp.cluster == cell_offset.cluster) { + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.cluster == cell_offset.cluster) { + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + } + const formatted_cps = try allocating.toOwnedSlice(); + log.warn("position differs from advance: cluster={d} pos=({d},{d}) adv=({d},{d}) diff=({d},{d}) cps={s}", .{ cell_offset.cluster, x_offset, y_offset, @@ -231,6 +259,7 @@ pub const Shaper = struct { advance_y_offset, x_offset_diff, y_offset_diff, + formatted_cps, }); } } From 0a648cddf83b59352b409db7504a484f83a3d7c0 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 30 Dec 2025 17:36:45 -0500 Subject: [PATCH 329/605] shaping: Use position.y directly for CoreText --- src/font/shaper/coretext.zig | 71 ++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 17d7801ff..09f123b4b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -103,15 +103,9 @@ pub const Shaper = struct { } }; - const RunOffset = struct { - x: f64 = 0, - y: f64 = 0, - }; - const CellOffset = struct { cluster: u32 = 0, x: f64 = 0, - y: f64 = 0, }; /// Create a CoreFoundation Dictionary suitable for @@ -388,15 +382,15 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current offsets within a run. - var run_offset: RunOffset = .{}; + // This keeps track of the current x offset within a run. + var run_offset_x: f64 = 0.0; - // This keeps track of the current offsets within a cell. + // This keeps track of the current x offset and cluster for a cell. var cell_offset: CellOffset = .{}; // For debugging positions, turn this on: - //var start_index: usize = 0; - //var end_index: usize = 0; + //var run_offset_y: f64 = 0.0; + //var cell_offset_y: f64 = 0.0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -450,39 +444,31 @@ pub const Shaper = struct { cell_offset = .{ .cluster = cluster, - .x = run_offset.x, - .y = run_offset.y, + .x = run_offset_x, }; // For debugging positions, turn this on: - // start_index = index; - // end_index = index; - //} else { - // if (index < start_index) { - // start_index = index; - // } - // if (index > end_index) { - // end_index = index; - // } + //cell_offset_y = run_offset_y; } // For debugging positions, turn this on: - //try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index); + //try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); const x_offset = position.x - cell_offset.x; - const y_offset = position.y - cell_offset.y; self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), .x_offset = @intFromFloat(@round(x_offset)), - .y_offset = @intFromFloat(@round(y_offset)), + .y_offset = @intFromFloat(@round(position.y)), .glyph_index = glyph, }); // Add our advances to keep track of our run offsets. // Advances apply to the NEXT cell. - run_offset.x += advance.width; - run_offset.y += advance.height; + run_offset_x += advance.width; + + // For debugging positions, turn this on: + //run_offset_y += advance.height; } } @@ -655,33 +641,38 @@ pub const Shaper = struct { fn debugPositions( self: *Shaper, alloc: Allocator, - run_offset: RunOffset, + run_offset_x: f64, + run_offset_y: f64, cell_offset: CellOffset, + cell_offset_y: f64, position: macos.graphics.Point, - start_index: usize, - end_index: usize, index: usize, ) !void { const state = &self.run_state; const x_offset = position.x - cell_offset.x; - const y_offset = position.y - cell_offset.y; - const advance_x_offset = run_offset.x - cell_offset.x; - const advance_y_offset = run_offset.y - cell_offset.y; + const advance_x_offset = run_offset_x - cell_offset.x; + const advance_y_offset = run_offset_y - cell_offset_y; const x_offset_diff = x_offset - advance_x_offset; - const y_offset_diff = y_offset - advance_y_offset; + const y_offset_diff = position.y - advance_y_offset; if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { var allocating = std.Io.Writer.Allocating.init(alloc); const writer = &allocating.writer; - const codepoints = state.codepoints.items[start_index .. end_index + 1]; + const codepoints = state.codepoints.items; for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("\\u{{{x}}}", .{cp.codepoint}); + if (cp.cluster == cell_offset.cluster and + cp.codepoint != 0 // Skip surrogate pair padding + ) { + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } } try writer.writeAll(" → "); for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + if (cp.cluster == cell_offset.cluster and + cp.codepoint != 0 // Skip surrogate pair padding + ) { + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } } const formatted_cps = try allocating.toOwnedSlice(); @@ -698,7 +689,7 @@ pub const Shaper = struct { log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ cell_offset.cluster, x_offset, - y_offset, + position.y, advance_x_offset, advance_y_offset, x_offset_diff, From 3e399a3d3506392f3559f406c1241bc4bc7882fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Dec 2025 14:12:15 -0800 Subject: [PATCH 330/605] macos: detect surface tab bar hovers and focus them --- .../Window Styles/TerminalWindow.swift | 27 +------ .../TitlebarTabsTahoeTerminalWindow.swift | 14 ++-- .../Surface View/SurfaceDragSource.swift | 78 +++++++++++++++++++ .../Extensions/NSWindow+Extension.swift | 56 +++++++++++-- 4 files changed, 136 insertions(+), 39 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9debd2cb3..501ac0e67 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -194,7 +194,7 @@ class TerminalWindow: NSWindow { // Its possible we miss the accessory titlebar call so we check again // whenever the window becomes main. Both of these are idempotent. - if hasTabBar { + if tabBarView != nil { tabBarDidAppear() } else { tabBarDidDisappear() @@ -243,31 +243,6 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") - func findTitlebarView() -> NSView? { - // Find our tab bar. If it doesn't exist we don't do anything. - // - // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. - // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; - // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. - // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 - guard let themeFrameView = contentView?.rootView else { return nil } - let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) { - themeFrameView.value(forKey: "titlebarView") as? NSView - } else { - NSView?.none - } - return titlebarView - } - - func findTabBar() -> NSView? { - findTitlebarView()?.firstDescendant(withClassName: "NSTabBar") - } - - /// Returns true if there is a tab bar visible on this window. - var hasTabBar: Bool { - findTabBar() != nil - } - var hasMoreThanOneTabs: Bool { /// accessing ``tabGroup?.windows`` here /// will cause other edge cases, be careful diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index b18fff291..918191522 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -85,7 +85,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool return } - guard let tabBarView = findTabBar() else { + guard let tabBarView else { super.sendEvent(event) return } @@ -176,8 +176,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool guard tabBarObserver == nil else { return } guard - let titlebarView = findTitlebarView(), - let tabBar = findTabBar() + let titlebarView, + let tabBarView = self.tabBarView else { return } // View model updates must happen on their own ticks. @@ -186,13 +186,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool } // Find our clip view - guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } + guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } // Make sure tabBar's height won't be stretched guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return } - tabBar.frame.size.height = newTabButton.frame.width + tabBarView.frame.size.height = newTabButton.frame.width // The container is the view that we'll constrain our tab bar within. let container = toolbarView @@ -228,10 +228,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // other events occur, the tab bar can resize and clear our constraints. 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 + tabBarView.postsFrameChangedNotifications = true tabBarObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, - object: tabBar, + object: tabBarView, queue: .main ) { [weak self] _ in guard let self else { return } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 21416ac75..77127583c 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -214,6 +214,9 @@ extension Ghostty { movedTo screenPoint: NSPoint ) { NSCursor.closedHand.set() + + // Handle hovering over a tab bar. + detectTabBarHover(at: screenPoint) } func draggingSession( @@ -226,6 +229,8 @@ extension Ghostty { self.escapeMonitor = nil } + hoveredTabState = nil + if operation == [] && !dragCancelledByEscape { let endsInWindow = NSApplication.shared.windows.contains { window in window.isVisible && window.frame.contains(screenPoint) @@ -242,6 +247,79 @@ extension Ghostty { isTracking = false onDragStateChanged?(false) } + + // MARK: Hovered Native Tabs + + /// The currently hovered tab, tracked to detect when hover changes. + private var hoveredTabState: HoveredTabState? + + /// This detects if the drag hover is over a native tab bar in our app. If it is then + /// we start a timer to focus that tab if the drag remains over it. + private func detectTabBarHover(at screenPoint: NSPoint) { + for window in NSApplication.shared.windows { + if let index = window.tabIndex(atScreenPoint: screenPoint) { + let state: HoveredTabState = .init(window: window, index: index) + guard hoveredTabState != state else { + // We already are tracking this. + return + } + + // Stop our prior state since we've changed tabs. + hoveredTabState = nil + + // Grab our window and ensure that it isn't the key window. If + // it is already key then the tab is already focused so we don't + // need to do it again. + guard let targetWindow = window.tabbedWindows?[safe: index] else { return } + guard !targetWindow.isKeyWindow else { return } + + // Start our timer to focus it and store our state. + state.startTimer() + hoveredTabState = state + return + } + } + + hoveredTabState = nil + } + + fileprivate class HoveredTabState: Equatable { + let window: NSWindow + let index: Int + var focusTimer: Timer? + + /// Duration to hover over a tab before it becomes focused. + private static let hoverDelay: TimeInterval = 0.5 + + init(window: NSWindow, index: Int) { + self.window = window + self.index = index + } + + deinit { + focusTimer?.invalidate() + } + + func startTimer() { + focusTimer?.invalidate() + focusTimer = Timer.scheduledTimer( + withTimeInterval: Self.hoverDelay, + repeats: false) { [weak self] _ in + self?.focus() + } + } + + private func focus() { + guard let targetWindow = window.tabbedWindows?[safe: index] else { return } + targetWindow.makeKeyAndOrderFront(nil) + } + + static func == (lhs: HoveredTabState, rhs: HoveredTabState) -> Bool { + return + lhs.window == rhs.window && + lhs.index == rhs.index + } + } } } diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index f8df803db..5d1831f26 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -10,12 +10,6 @@ extension NSWindow { return CGWindowID(windowNumber) } - /// True if this is the first window in the tab group. - var isFirstWindowInTabGroup: Bool { - guard let firstWindow = tabGroup?.windows.first else { return true } - return firstWindow === self - } - /// Adjusts the window frame if necessary to ensure the window remains visible on screen. /// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen). func constrainToScreen() { @@ -36,3 +30,53 @@ extension NSWindow { } } } + +// MARK: Native Tabbing + +extension NSWindow { + /// True if this is the first window in the tab group. + var isFirstWindowInTabGroup: Bool { + guard let firstWindow = tabGroup?.windows.first else { return true } + return firstWindow === self + } +} + +/// Native tabbing private API usage. :( +extension NSWindow { + var titlebarView: NSView? { + // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. + // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; + // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. + // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 + guard let themeFrameView = contentView?.rootView else { return nil } + guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil } + return themeFrameView.value(forKey: "titlebarView") as? NSView + } + + /// Returns the [private] NSTabBar view, if it exists. + var tabBarView: NSView? { + titlebarView?.firstDescendant(withClassName: "NSTabBar") + } + + /// Returns the index of the tab button at the given screen point, if any. + func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + guard let tabBarView else { return nil } + let locationInWindow = convertPoint(fromScreen: screenPoint) + let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) + guard tabBarView.bounds.contains(locationInTabBar) else { return nil } + + // Find all tab buttons and sort by x position to get visual order. + // The view hierarchy order doesn't match the visual tab order. + let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton") + .sorted { $0.frame.origin.x < $1.frame.origin.x } + + for (index, tabItemView) in tabItemViews.enumerated() { + let locationInTab = tabItemView.convert(locationInWindow, from: nil) + if tabItemView.bounds.contains(locationInTab) { + return index + } + } + + return nil + } +} From d4ba0fa27ee2aedb9081c3dafd1424ff339af6be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Dec 2025 15:00:32 -0800 Subject: [PATCH 331/605] macos: last surface should close tab immediately not window --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 2 +- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e4f700170..fb86ce8f7 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -952,7 +952,7 @@ class BaseTerminalController: NSWindowController, // controller is a TerminalController this is easy because it has a way // to do this. if let c = sourceController as? TerminalController { - c.closeWindowImmediately() + c.closeTabImmediately() } else { // Not a TerminalController so we always undo into a new window. _ = TerminalController.newWindow( diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 26ca8f70e..abaedbe41 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -614,7 +614,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindow(nil) } - private func closeTabImmediately(registerRedo: Bool = true) { + func closeTabImmediately(registerRedo: Bool = true) { guard let window = window else { return } guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { From 18abcaa7974db77c108dfbbfcd5dda785831a129 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 06:15:01 -0800 Subject: [PATCH 332/605] macos: remove tab hover event, seems native handles it --- .../Surface View/SurfaceDragSource.swift | 78 ------------------- 1 file changed, 78 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 77127583c..21416ac75 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -214,9 +214,6 @@ extension Ghostty { movedTo screenPoint: NSPoint ) { NSCursor.closedHand.set() - - // Handle hovering over a tab bar. - detectTabBarHover(at: screenPoint) } func draggingSession( @@ -229,8 +226,6 @@ extension Ghostty { self.escapeMonitor = nil } - hoveredTabState = nil - if operation == [] && !dragCancelledByEscape { let endsInWindow = NSApplication.shared.windows.contains { window in window.isVisible && window.frame.contains(screenPoint) @@ -247,79 +242,6 @@ extension Ghostty { isTracking = false onDragStateChanged?(false) } - - // MARK: Hovered Native Tabs - - /// The currently hovered tab, tracked to detect when hover changes. - private var hoveredTabState: HoveredTabState? - - /// This detects if the drag hover is over a native tab bar in our app. If it is then - /// we start a timer to focus that tab if the drag remains over it. - private func detectTabBarHover(at screenPoint: NSPoint) { - for window in NSApplication.shared.windows { - if let index = window.tabIndex(atScreenPoint: screenPoint) { - let state: HoveredTabState = .init(window: window, index: index) - guard hoveredTabState != state else { - // We already are tracking this. - return - } - - // Stop our prior state since we've changed tabs. - hoveredTabState = nil - - // Grab our window and ensure that it isn't the key window. If - // it is already key then the tab is already focused so we don't - // need to do it again. - guard let targetWindow = window.tabbedWindows?[safe: index] else { return } - guard !targetWindow.isKeyWindow else { return } - - // Start our timer to focus it and store our state. - state.startTimer() - hoveredTabState = state - return - } - } - - hoveredTabState = nil - } - - fileprivate class HoveredTabState: Equatable { - let window: NSWindow - let index: Int - var focusTimer: Timer? - - /// Duration to hover over a tab before it becomes focused. - private static let hoverDelay: TimeInterval = 0.5 - - init(window: NSWindow, index: Int) { - self.window = window - self.index = index - } - - deinit { - focusTimer?.invalidate() - } - - func startTimer() { - focusTimer?.invalidate() - focusTimer = Timer.scheduledTimer( - withTimeInterval: Self.hoverDelay, - repeats: false) { [weak self] _ in - self?.focus() - } - } - - private func focus() { - guard let targetWindow = window.tabbedWindows?[safe: index] else { return } - targetWindow.makeKeyAndOrderFront(nil) - } - - static func == (lhs: HoveredTabState, rhs: HoveredTabState) -> Bool { - return - lhs.window == rhs.window && - lhs.index == rhs.index - } - } } } From 3bd898603a4487c56d17a11c1835a3ef7151785f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 09:34:57 -0800 Subject: [PATCH 333/605] pkg/dcimgui: DearBindings-based Imgui support --- pkg/dcimgui/build.zig | 157 ++++++++++++++++++++++++++++++++++++++ pkg/dcimgui/build.zig.zon | 26 +++++++ pkg/dcimgui/main.zig | 26 +++++++ 3 files changed, 209 insertions(+) create mode 100644 pkg/dcimgui/build.zig create mode 100644 pkg/dcimgui/build.zig.zon create mode 100644 pkg/dcimgui/main.zig diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig new file mode 100644 index 000000000..2d1594cba --- /dev/null +++ b/pkg/dcimgui/build.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const NativeTargetInfo = std.zig.system.NativeTargetInfo; + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const backend_opengl3 = b.option(bool, "backend-opengl3", "OpenGL3 backend") orelse false; + const backend_metal = b.option(bool, "backend-metal", "Metal backend") orelse false; + const backend_osx = b.option(bool, "backend-osx", "OSX backend") orelse false; + + // Build options + const options = b.addOptions(); + options.addOption(bool, "backend_opengl3", backend_opengl3); + options.addOption(bool, "backend_metal", backend_metal); + options.addOption(bool, "backend_osx", backend_osx); + + // Main static lib + const lib = b.addLibrary(.{ + .name = "dcimgui", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + }), + .linkage = .static, + }); + lib.linkLibC(); + lib.linkLibCpp(); + b.installArtifact(lib); + + // Zig module + const mod = b.addModule("dcimgui", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + mod.addOptions("build_options", options); + mod.linkLibrary(lib); + + // We need to add proper Apple SDKs to find stdlib headers + if (target.result.os.tag.isDarwin()) { + if (!target.query.isNative()) { + try @import("apple_sdk").addPaths(b, lib); + } + } + + // Flags for C compilation, common to all. + var flags: std.ArrayList([]const u8) = .empty; + defer flags.deinit(b.allocator); + try flags.appendSlice(b.allocator, &.{ + "-DIMGUI_USE_WCHAR32=1", + "-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1", + }); + if (target.result.os.tag == .windows) { + try flags.appendSlice(b.allocator, &.{ + "-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)", + }); + } else { + try flags.appendSlice(b.allocator, &.{ + "-DIMGUI_IMPL_API=extern\t\"C\"", + }); + } + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + + // Add the core Dear Imgui source files + if (b.lazyDependency("imgui", .{})) |upstream| { + lib.addIncludePath(upstream.path("")); + lib.addCSourceFiles(.{ + .root = upstream.path(""), + .files = &.{ + "imgui_demo.cpp", + "imgui_draw.cpp", + "imgui_tables.cpp", + "imgui_widgets.cpp", + "imgui.cpp", + }, + .flags = flags.items, + }); + + lib.installHeadersDirectory( + upstream.path(""), + "", + .{ .include_extensions = &.{".h"} }, + ); + + if (backend_metal) { + lib.addCSourceFiles(.{ + .root = upstream.path("backends"), + .files = &.{"imgui_impl_metal.mm"}, + .flags = flags.items, + }); + lib.installHeadersDirectory( + upstream.path("backends"), + "", + .{ .include_extensions = &.{"imgui_impl_metal.h"} }, + ); + } + if (backend_osx) { + lib.addCSourceFiles(.{ + .root = upstream.path("backends"), + .files = &.{"imgui_impl_osx.mm"}, + .flags = flags.items, + }); + lib.installHeadersDirectory( + upstream.path("backends"), + "", + .{ .include_extensions = &.{"imgui_impl_osx.h"} }, + ); + } + if (backend_opengl3) { + lib.addCSourceFiles(.{ + .root = upstream.path("backends"), + .files = &.{"imgui_impl_opengl3.cpp"}, + .flags = flags.items, + }); + lib.installHeadersDirectory( + upstream.path("backends"), + "", + .{ .include_extensions = &.{"imgui_impl_opengl3.h"} }, + ); + } + } + + // Add the C bindings + if (b.lazyDependency("bindings", .{})) |upstream| { + lib.addIncludePath(upstream.path("")); + lib.addCSourceFiles(.{ + .root = upstream.path(""), + .files = &.{ + "dcimgui.cpp", + "dcimgui_internal.cpp", + }, + .flags = flags.items, + }); + + lib.installHeadersDirectory( + upstream.path(""), + "", + .{ .include_extensions = &.{".h"} }, + ); + } + + const test_exe = b.addTest(.{ + .name = "test", + .root_module = b.createModule(.{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }), + }); + test_exe.root_module.addOptions("build_options", options); + test_exe.linkLibrary(lib); + const tests_run = b.addRunArtifact(test_exe); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); +} diff --git a/pkg/dcimgui/build.zig.zon b/pkg/dcimgui/build.zig.zon new file mode 100644 index 000000000..95d0120e1 --- /dev/null +++ b/pkg/dcimgui/build.zig.zon @@ -0,0 +1,26 @@ +.{ + .name = .dcimgui, + .version = "1.92.5", // -docking branch + .fingerprint = 0x1a25797442c6324f, + .paths = .{""}, + .dependencies = .{ + // The bindings and imgui versions below must match exactly. + + .bindings = .{ + // https://github.com/dearimgui/dear_bindings + .url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz", + .hash = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr", + .lazy = true, + }, + + .imgui = .{ + // https://github.com/ocornut/imgui + .url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", + .hash = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI", + .lazy = true, + }, + + .apple_sdk = .{ .path = "../apple-sdk" }, + .freetype = .{ .path = "../freetype" }, + }, +} diff --git a/pkg/dcimgui/main.zig b/pkg/dcimgui/main.zig new file mode 100644 index 000000000..c73a74515 --- /dev/null +++ b/pkg/dcimgui/main.zig @@ -0,0 +1,26 @@ +pub const build_options = @import("build_options"); + +pub const c = @cImport({ + @cInclude("dcimgui.h"); +}); + +// OpenGL3 backend +pub extern fn ImGui_ImplOpenGL3_Init(glsl_version: ?[*:0]const u8) callconv(.c) bool; +pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void; +pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void; +pub extern fn ImGui_ImplOpenGL3_RenderDrawData(draw_data: *c.ImDrawData) callconv(.c) void; + +// Metal backend +pub extern fn ImGui_ImplMetal_Init(device: *anyopaque) callconv(.c) bool; +pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void; +pub extern fn ImGui_ImplMetal_NewFrame(render_pass_descriptor: *anyopaque) callconv(.c) void; +pub extern fn ImGui_ImplMetal_RenderDrawData(draw_data: *c.ImDrawData, command_buffer: *anyopaque, command_encoder: *anyopaque) callconv(.c) void; + +// OSX +pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool; +pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void; +pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void; + +test { + _ = c; +} From 978400b0b0fa0d63b010f35c5cb76002e37d41e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 10:10:03 -0800 Subject: [PATCH 334/605] replace cimgui with dcimgui --- build.zig.zon | 1 + pkg/dcimgui/main.zig | 7 + src/apprt/embedded.zig | 63 ++- src/apprt/gtk/class/imgui_widget.zig | 41 +- src/build/SharedDeps.zig | 12 +- src/input/key.zig | 4 +- src/inspector/Inspector.zig | 555 +++++++++++++-------------- src/inspector/cell.zig | 90 +++-- src/inspector/cursor.zig | 74 ++-- src/inspector/key.zig | 104 +++-- src/inspector/page.zig | 132 +++---- src/inspector/termio.zig | 11 +- 12 files changed, 524 insertions(+), 570 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 191c1bec9..eff6dc12e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -64,6 +64,7 @@ // C libs .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, + .dcimgui = .{ .path = "./pkg/dcimgui", .lazy = true }, .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, .freetype = .{ .path = "./pkg/freetype", .lazy = true }, .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, diff --git a/pkg/dcimgui/main.zig b/pkg/dcimgui/main.zig index c73a74515..7ea135cdb 100644 --- a/pkg/dcimgui/main.zig +++ b/pkg/dcimgui/main.zig @@ -21,6 +21,13 @@ pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool; pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void; pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void; +// Internal API functions from dcimgui_internal.h +// We declare these manually because the internal header contains bitfields +// that Zig's cImport cannot translate. +pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void; + test { _ = c; } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a1b6a6e9b..c6c4d2ab8 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -947,7 +947,7 @@ pub const Surface = struct { /// Inspector is the state required for the terminal inspector. A terminal /// inspector is 1:1 with a Surface. pub const Inspector = struct { - const cimgui = @import("cimgui"); + const cimgui = @import("dcimgui"); surface: *Surface, ig_ctx: *cimgui.c.ImGuiContext, @@ -968,10 +968,10 @@ pub const Inspector = struct { }; pub fn init(surface: *Surface) !Inspector { - const ig_ctx = cimgui.c.igCreateContext(null) orelse return error.OutOfMemory; - errdefer cimgui.c.igDestroyContext(ig_ctx); - cimgui.c.igSetCurrentContext(ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const ig_ctx = cimgui.c.ImGui_CreateContext(null) orelse return error.OutOfMemory; + errdefer cimgui.c.ImGui_DestroyContext(ig_ctx); + cimgui.c.ImGui_SetCurrentContext(ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); io.BackendPlatformName = "ghostty_embedded"; // Setup our core inspector @@ -988,9 +988,9 @@ pub const Inspector = struct { pub fn deinit(self: *Inspector) void { self.surface.core_surface.deactivateInspector(); - cimgui.c.igSetCurrentContext(self.ig_ctx); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); if (self.backend) |v| v.deinit(); - cimgui.c.igDestroyContext(self.ig_ctx); + cimgui.c.ImGui_DestroyContext(self.ig_ctx); } /// Queue a render for the next frame. @@ -1001,7 +1001,7 @@ pub const Inspector = struct { /// Initialize the inspector for a metal backend. pub fn initMetal(self: *Inspector, device: objc.Object) bool { defer device.msgSend(void, objc.sel("release"), .{}); - cimgui.c.igSetCurrentContext(self.ig_ctx); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); if (self.backend) |v| { v.deinit(); @@ -1036,7 +1036,7 @@ pub const Inspector = struct { for (0..2) |_| { cimgui.ImGui_ImplMetal_NewFrame(desc.value); try self.newFrame(); - cimgui.c.igNewFrame(); + cimgui.c.ImGui_NewFrame(); // Build our UI render: { @@ -1046,7 +1046,7 @@ pub const Inspector = struct { } // Render - cimgui.c.igRender(); + cimgui.c.ImGui_Render(); } // MTLRenderCommandEncoder @@ -1057,7 +1057,7 @@ pub const Inspector = struct { ); defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); cimgui.ImGui_ImplMetal_RenderDrawData( - cimgui.c.igGetDrawData(), + cimgui.c.ImGui_GetDrawData(), command_buffer.value, encoder.value, ); @@ -1065,22 +1065,21 @@ pub const Inspector = struct { pub fn updateContentScale(self: *Inspector, x: f64, y: f64) void { _ = y; - cimgui.c.igSetCurrentContext(self.ig_ctx); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); // Cache our scale because we use it for cursor position calculations. self.content_scale = x; // Setup a new style and scale it appropriately. - const style = cimgui.c.ImGuiStyle_ImGuiStyle(); - defer cimgui.c.ImGuiStyle_destroy(style); - cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatCast(x)); - const active_style = cimgui.c.igGetStyle(); - active_style.* = style.*; + var style: cimgui.c.ImGuiStyle = .{}; + cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatCast(x)); + const active_style = cimgui.c.ImGui_GetStyle(); + active_style.* = style; } pub fn updateSize(self: *Inspector, width: u32, height: u32) void { - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; } @@ -1093,8 +1092,8 @@ pub const Inspector = struct { _ = mods; self.queueRender(); - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); const imgui_button = switch (button) { .left => cimgui.c.ImGuiMouseButton_Left, @@ -1115,8 +1114,8 @@ pub const Inspector = struct { _ = mods; self.queueRender(); - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddMouseWheelEvent( io, @floatCast(xoff), @@ -1126,8 +1125,8 @@ pub const Inspector = struct { pub fn cursorPosCallback(self: *Inspector, x: f64, y: f64) void { self.queueRender(); - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddMousePosEvent( io, @floatCast(x * self.content_scale), @@ -1137,15 +1136,15 @@ pub const Inspector = struct { pub fn focusCallback(self: *Inspector, focused: bool) void { self.queueRender(); - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddFocusEvent(io, focused); } pub fn textCallback(self: *Inspector, text: [:0]const u8) void { self.queueRender(); - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, text.ptr); } @@ -1156,8 +1155,8 @@ pub const Inspector = struct { mods: input.Mods, ) !void { self.queueRender(); - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGui_SetCurrentContext(self.ig_ctx); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); // Update all our modifiers cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); @@ -1176,7 +1175,7 @@ pub const Inspector = struct { } fn newFrame(self: *Inspector) !void { - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); // Determine our delta time const now = try std.time.Instant.now(); diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index ef1ca05c9..474efaa91 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -126,7 +126,7 @@ pub const ImguiWidget = extern struct { log.warn("Dear ImGui context not initialized", .{}); return error.ContextNotInitialized; }; - cimgui.c.igSetCurrentContext(ig_context); + cimgui.c.ImGui_SetCurrentContext(ig_context); } /// Initialize the frame. Expects that the context is already current. @@ -137,7 +137,7 @@ pub const ImguiWidget = extern struct { const priv = self.private(); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); // Determine our delta time const now = std.time.Instant.now() catch unreachable; @@ -163,7 +163,7 @@ pub const ImguiWidget = extern struct { self.setCurrentContext() catch return false; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); const mods = key.translateMods(gtk_mods); cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); @@ -219,14 +219,14 @@ pub const ImguiWidget = extern struct { return; } - priv.ig_context = cimgui.c.igCreateContext(null) orelse { + priv.ig_context = cimgui.c.ImGui_CreateContext(null) orelse { log.warn("unable to initialize Dear ImGui context", .{}); return; }; self.setCurrentContext() catch return; // Setup some basic config - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); io.BackendPlatformName = "ghostty_gtk"; // Realize means that our OpenGL context is ready, so we can now @@ -247,7 +247,7 @@ pub const ImguiWidget = extern struct { /// Handle a request to resize the GLArea fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void { self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); const scale_factor = area.as(gtk.Widget).getScaleFactor(); // Our display size is always unscaled. We'll do the scaling in the @@ -256,11 +256,10 @@ pub const ImguiWidget = extern struct { io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; // Setup a new style and scale it appropriately. - const style = cimgui.c.ImGuiStyle_ImGuiStyle(); - defer cimgui.c.ImGuiStyle_destroy(style); - cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor)); - const active_style = cimgui.c.igGetStyle(); - active_style.* = style.*; + var style: cimgui.c.ImGuiStyle = .{}; + cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatFromInt(scale_factor)); + const active_style = cimgui.c.ImGui_GetStyle(); + active_style.* = style; } /// Handle a request to render the contents of our GLArea @@ -273,33 +272,33 @@ pub const ImguiWidget = extern struct { for (0..2) |_| { cimgui.ImGui_ImplOpenGL3_NewFrame(); self.newFrame(); - cimgui.c.igNewFrame(); + cimgui.c.ImGui_NewFrame(); // Call the virtual method to draw the UI. self.render(); // Render - cimgui.c.igRender(); + cimgui.c.ImGui_Render(); } // OpenGL final render gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0); gl.clear(gl.c.GL_COLOR_BUFFER_BIT); - cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData()); + cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.ImGui_GetDrawData()); return @intFromBool(true); } fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddFocusEvent(io, true); self.queueRender(); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddFocusEvent(io, false); self.queueRender(); } @@ -345,7 +344,7 @@ pub const ImguiWidget = extern struct { ) callconv(.c) void { self.queueRender(); self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); if (translateMouseButton(gdk_button)) |button| { cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true); @@ -361,7 +360,7 @@ pub const ImguiWidget = extern struct { ) callconv(.c) void { self.queueRender(); self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); if (translateMouseButton(gdk_button)) |button| { cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false); @@ -376,7 +375,7 @@ pub const ImguiWidget = extern struct { ) callconv(.c) void { self.queueRender(); self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); const scale_factor = self.getScaleFactor(); cimgui.c.ImGuiIO_AddMousePosEvent( io, @@ -393,7 +392,7 @@ pub const ImguiWidget = extern struct { ) callconv(.c) c_int { self.queueRender(); self.setCurrentContext() catch return @intFromBool(false); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddMouseWheelEvent( io, @floatCast(x), @@ -409,7 +408,7 @@ pub const ImguiWidget = extern struct { ) callconv(.c) void { self.queueRender(); self.setCurrentContext() catch return; - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 5e2cd40b9..f96a269bd 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -479,15 +479,17 @@ pub fn add( } // cimgui - if (b.lazyDependency("cimgui", .{ + if (b.lazyDependency("dcimgui", .{ .target = target, .optimize = optimize, - })) |cimgui_dep| { - step.root_module.addImport("cimgui", cimgui_dep.module("cimgui")); - step.linkLibrary(cimgui_dep.artifact("cimgui")); + .@"backend-metal" = target.result.os.tag.isDarwin(), + .@"backend-osx" = target.result.os.tag == .macos, + })) |dep| { + step.root_module.addImport("dcimgui", dep.module("dcimgui")); + step.linkLibrary(dep.artifact("dcimgui")); try static_libs.append( b.allocator, - cimgui_dep.artifact("cimgui").getEmittedBin(), + dep.artifact("dcimgui").getEmittedBin(), ); } diff --git a/src/input/key.zig b/src/input/key.zig index 54c7491ae..6445871eb 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const OptionAsAlt = @import("config.zig").OptionAsAlt; /// A generic key input event. This is the information that is necessary @@ -696,7 +696,7 @@ pub const Key = enum(c_int) { } /// Returns the cimgui key constant for this key. - pub fn imguiKey(self: Key) ?c_uint { + pub fn imguiKey(self: Key) ?c_int { return switch (self) { .key_a => cimgui.c.ImGuiKey_A, .key_b => cimgui.c.ImGuiKey_B, diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index dc498b58d..b1ac473b8 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -7,7 +7,7 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const Surface = @import("../Surface.zig"); const font = @import("../font/main.zig"); const input = @import("../input.zig"); @@ -126,7 +126,7 @@ const CellInspect = union(enum) { /// Setup the ImGui state. This requires an ImGui context to be set. pub fn setup() void { - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); // Enable docking, which we use heavily for the UI. io.ConfigFlags |= cimgui.c.ImGuiConfigFlags_DockingEnable; @@ -144,15 +144,14 @@ pub fn setup() void { // This is currently hardcoded to a 2x content scale. const font_size = 16 * 2; - const font_config: *cimgui.c.ImFontConfig = cimgui.c.ImFontConfig_ImFontConfig(); - defer cimgui.c.ImFontConfig_destroy(font_config); + var font_config: cimgui.c.ImFontConfig = .{}; font_config.FontDataOwnedByAtlas = false; _ = cimgui.c.ImFontAtlas_AddFontFromMemoryTTF( io.Fonts, @ptrCast(@constCast(font.embedded.regular)), font.embedded.regular.len, font_size, - font_config, + &font_config, null, ); } @@ -221,11 +220,7 @@ pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { /// Render the frame. pub fn render(self: *Inspector) void { - const dock_id = cimgui.c.igDockSpaceOverViewport( - cimgui.c.igGetMainViewport(), - cimgui.c.ImGuiDockNodeFlags_None, - null, - ); + const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); // Render all of our data. We hold the mutex for this duration. This is // expensive but this is an initial implementation until it doesn't work @@ -245,7 +240,7 @@ pub fn render(self: *Inspector) void { // widgets and such. if (builtin.mode == .Debug) { var show: bool = true; - cimgui.c.igShowDemoWindow(&show); + cimgui.c.ImGui_ShowDemoWindow(&show); } // On first render we set up the layout. We can actually do this at @@ -261,7 +256,7 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { _ = self; // Our initial focus - cimgui.c.igSetWindowFocus_Str(window_screen); + cimgui.c.ImGui_SetWindowFocusStr(window_screen); // Setup our initial layout. const dock_id: struct { @@ -270,7 +265,7 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { } = dock_id: { var dock_id_left: cimgui.c.ImGuiID = undefined; var dock_id_right: cimgui.c.ImGuiID = undefined; - _ = cimgui.c.igDockBuilderSplitNode( + _ = cimgui.ImGui_DockBuilderSplitNode( dock_id_main, cimgui.c.ImGuiDir_Left, 0.7, @@ -284,20 +279,20 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { }; }; - cimgui.c.igDockBuilderDockWindow(window_cell, dock_id.left); - cimgui.c.igDockBuilderDockWindow(window_modes, dock_id.left); - cimgui.c.igDockBuilderDockWindow(window_keyboard, dock_id.left); - cimgui.c.igDockBuilderDockWindow(window_termio, dock_id.left); - cimgui.c.igDockBuilderDockWindow(window_screen, dock_id.left); - cimgui.c.igDockBuilderDockWindow(window_imgui_demo, dock_id.left); - cimgui.c.igDockBuilderDockWindow(window_size, dock_id.right); - cimgui.c.igDockBuilderFinish(dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id.left); + cimgui.ImGui_DockBuilderDockWindow(window_modes, dock_id.left); + cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id.left); + cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id.left); + cimgui.ImGui_DockBuilderDockWindow(window_screen, dock_id.left); + cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id.left); + cimgui.ImGui_DockBuilderDockWindow(window_size, dock_id.right); + cimgui.ImGui_DockBuilderFinish(dock_id_main); } fn renderScreenWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. - defer cimgui.c.igEnd(); - if (!cimgui.c.igBegin( + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( window_screen, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, @@ -307,76 +302,70 @@ fn renderScreenWindow(self: *Inspector) void { const screen: *terminal.Screen = t.screens.active; { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_screen", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Active Screen"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Active Screen"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(t.screens.active_key).ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.screens.active_key).ptr); } } } - if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + if (cimgui.c.ImGui_CollapsingHeader( "Cursor", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_cursor", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); inspector.cursor.renderInTable( self.surface.renderer_state.terminal, &screen.cursor, ); } // table - cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); } // cursor - if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + if (cimgui.c.ImGui_CollapsingHeader( "Keyboard", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_keyboard", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); const kitty_flags = screen.kitty_keyboard.current(); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Mode"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Mode"); } { - _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); const mode = if (kitty_flags.int() != 0) "kitty" else "legacy"; - cimgui.c.igText("%s", mode.ptr); + cimgui.c.ImGui_Text("%s", mode.ptr); } } @@ -386,15 +375,15 @@ fn renderScreenWindow(self: *Inspector) void { { const value = @field(kitty_flags, field.name); - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); const name = std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.igText("%s", name.ptr); + cimgui.c.ImGui_Text("%s", name.ptr); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%s", if (value) "true".ptr else "false".ptr, ); @@ -403,14 +392,14 @@ fn renderScreenWindow(self: *Inspector) void { } } else { { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Xterm modify keys"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Xterm modify keys"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%s", if (t.flags.modify_other_keys_2) "true".ptr else "false".ptr, ); @@ -420,143 +409,139 @@ fn renderScreenWindow(self: *Inspector) void { } // table } // keyboard - if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + if (cimgui.c.ImGui_CollapsingHeader( "Kitty Graphics", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) kitty_gfx: { if (!screen.kitty_images.enabled()) { - cimgui.c.igTextDisabled("(Kitty graphics are disabled)"); + cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)"); break :kitty_gfx; } { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "##kitty_graphics", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); const kitty_images = &screen.kitty_images; { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Memory Usage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Usage"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Memory Limit"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Limit"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Image Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Image Count"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", kitty_images.images.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", kitty_images.images.count()); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Placement Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Placement Count"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", kitty_images.placements.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", kitty_images.placements.count()); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Image Loading"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Image Loading"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr); } } } // table } // kitty graphics - if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + if (cimgui.c.ImGui_CollapsingHeader( "Internal Terminal State", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { const pages = &screen.pages; { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "##terminal_state", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Memory Usage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Usage"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Memory Limit"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Limit"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Viewport Location"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Viewport Location"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(pages.viewport).ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); } } } // table // - if (cimgui.c.igCollapsingHeader_TreeNodeFlags( + if (cimgui.c.ImGui_CollapsingHeader( "Active Page", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { @@ -569,28 +554,26 @@ fn renderScreenWindow(self: *Inspector) void { /// users to toggle them on and off. fn renderModesWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. - defer cimgui.c.igEnd(); - if (!cimgui.c.igBegin( + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( window_modes, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, )) return; - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_modes", 3, cimgui.c.ImGuiTableFlags_SizingFixedFit | cimgui.c.ImGuiTableFlags_RowBg, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); { - _ = cimgui.c.igTableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize, 0, 0); - _ = cimgui.c.igTableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending, 0, 0); - _ = cimgui.c.igTableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0); - cimgui.c.igTableHeadersRow(); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize); + cimgui.c.ImGui_TableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending); + cimgui.c.ImGui_TableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); } const t = self.surface.renderer_state.terminal; @@ -598,59 +581,57 @@ fn renderModesWindow(self: *Inspector) void { @setEvalBranchQuota(6000); const tag: terminal.modes.ModeTag = @bitCast(@as(terminal.modes.ModeTag.Backing, field.value)); - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); var value: bool = t.modes.get(@field(terminal.Mode, field.name)); - _ = cimgui.c.igCheckbox("", &value); + _ = cimgui.c.ImGui_Checkbox("", &value); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%s%d", if (tag.ansi) "" else "?", @as(u32, @intCast(tag.value)), ); } { - _ = cimgui.c.igTableSetColumnIndex(2); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); const name = std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.igText("%s", name.ptr); + cimgui.c.ImGui_Text("%s", name.ptr); } } } fn renderSizeWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. - defer cimgui.c.igEnd(); - if (!cimgui.c.igBegin( + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( window_size, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, )) return; - cimgui.c.igSeparatorText("Dimensions"); + cimgui.c.ImGui_SeparatorText("Dimensions"); { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_size", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); // Screen Size { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Screen Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Screen Size"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%dpx x %dpx", self.surface.size.screen.width, self.surface.size.screen.height, @@ -660,15 +641,15 @@ fn renderSizeWindow(self: *Inspector) void { // Grid Size { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Grid Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid Size"); } { - _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); const grid_size = self.surface.size.grid(); - cimgui.c.igText( + cimgui.c.ImGui_Text( "%dc x %dr", grid_size.columns, grid_size.rows, @@ -678,14 +659,14 @@ fn renderSizeWindow(self: *Inspector) void { // Cell Size { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Cell Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Cell Size"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%dpx x %dpx", self.surface.size.cell.width, self.surface.size.cell.height, @@ -695,14 +676,14 @@ fn renderSizeWindow(self: *Inspector) void { // Padding { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Window Padding"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Window Padding"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "T=%d B=%d L=%d R=%d px", self.surface.size.padding.top, self.surface.size.padding.bottom, @@ -713,27 +694,25 @@ fn renderSizeWindow(self: *Inspector) void { } } - cimgui.c.igSeparatorText("Font"); + cimgui.c.ImGui_SeparatorText("Font"); { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_font", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Size (Points)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size (Points)"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%.2f pt", self.surface.font_size.points, ); @@ -741,14 +720,14 @@ fn renderSizeWindow(self: *Inspector) void { } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Size (Pixels)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size (Pixels)"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "%.2f px", self.surface.font_size.pixels(), ); @@ -756,17 +735,15 @@ fn renderSizeWindow(self: *Inspector) void { } } - cimgui.c.igSeparatorText("Mouse"); + cimgui.c.ImGui_SeparatorText("Mouse"); { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_mouse", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); const mouse = &self.surface.mouse; const t = self.surface.renderer_state.terminal; @@ -781,14 +758,14 @@ fn renderSizeWindow(self: *Inspector) void { break :pt pt.coord(); }; - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Hover Grid"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hover Grid"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "row=%d, col=%d", hover_point.y, hover_point.x, @@ -804,14 +781,14 @@ fn renderSizeWindow(self: *Inspector) void { }, }).convert(.terminal, self.surface.size).terminal; - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Hover Point"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hover Point"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "(%dpx, %dpx)", @as(i64, @intFromFloat(coord.x)), @as(i64, @intFromFloat(coord.y)), @@ -824,23 +801,23 @@ fn renderSizeWindow(self: *Inspector) void { } else false; click: { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Click State"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click State"); } { - _ = cimgui.c.igTableSetColumnIndex(1); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); if (!any_click) { - cimgui.c.igText("none"); + cimgui.c.ImGui_Text("none"); break :click; } for (mouse.click_state, 0..) |state, i| { if (state != .press) continue; const button: input.MouseButton = @enumFromInt(i); - cimgui.c.igSameLine(0, 0); - cimgui.c.igText("%s", (switch (button) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%s", (switch (button) { .unknown => "?", .left => "L", .middle => "M", @@ -868,14 +845,14 @@ fn renderSizeWindow(self: *Inspector) void { break :pt pt.coord(); }; - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Click Grid"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click Grid"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "row=%d, col=%d", left_click_point.y, left_click_point.x, @@ -884,14 +861,14 @@ fn renderSizeWindow(self: *Inspector) void { } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Click Point"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click Point"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "(%dpx, %dpx)", @as(u32, @intFromFloat(mouse.left_click_xpos)), @as(u32, @intFromFloat(mouse.left_click_ypos)), @@ -903,8 +880,8 @@ fn renderSizeWindow(self: *Inspector) void { fn renderCellWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. - defer cimgui.c.igEnd(); - if (!cimgui.c.igBegin( + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( window_cell, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, @@ -913,45 +890,45 @@ fn renderCellWindow(self: *Inspector) void { // Our popup for the picker const popup_picker = "Cell Picker"; - if (cimgui.c.igButton("Picker", .{ .x = 0, .y = 0 })) { + if (cimgui.c.ImGui_Button("Picker")) { // Request a cell self.cell.request(); - cimgui.c.igOpenPopup_Str( + cimgui.c.ImGui_OpenPopup( popup_picker, cimgui.c.ImGuiPopupFlags_None, ); } - if (cimgui.c.igBeginPopupModal( + if (cimgui.c.ImGui_BeginPopupModal( popup_picker, null, cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, )) popup: { - defer cimgui.c.igEndPopup(); + defer cimgui.c.ImGui_EndPopup(); // Once we select a cell, close this popup. if (self.cell == .selected) { - cimgui.c.igCloseCurrentPopup(); + cimgui.c.ImGui_CloseCurrentPopup(); break :popup; } - cimgui.c.igText( + cimgui.c.ImGui_Text( "Click on a cell in the terminal to inspect it.\n" ++ "The click will be intercepted by the picker, \n" ++ "so it won't be sent to the terminal.", ); - cimgui.c.igSeparator(); + cimgui.c.ImGui_Separator(); - if (cimgui.c.igButton("Cancel", .{ .x = 0, .y = 0 })) { - cimgui.c.igCloseCurrentPopup(); + if (cimgui.c.ImGui_Button("Cancel")) { + cimgui.c.ImGui_CloseCurrentPopup(); } } // cell pick popup - cimgui.c.igSeparator(); + cimgui.c.ImGui_Separator(); if (self.cell != .selected) { - cimgui.c.igText("No cell selected."); + cimgui.c.ImGui_Text("No cell selected."); return; } @@ -965,8 +942,8 @@ fn renderCellWindow(self: *Inspector) void { fn renderKeyboardWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. - defer cimgui.c.igEnd(); - if (!cimgui.c.igBegin( + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( window_keyboard, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, @@ -974,47 +951,44 @@ fn renderKeyboardWindow(self: *Inspector) void { list: { if (self.key_events.empty()) { - cimgui.c.igText("No recorded key events. Press a key with the " ++ + cimgui.c.ImGui_Text("No recorded key events. Press a key with the " ++ "terminal focused to record it."); break :list; } - if (cimgui.c.igButton("Clear", .{ .x = 0, .y = 0 })) { + if (cimgui.c.ImGui_Button("Clear")) { var it = self.key_events.iterator(.forward); while (it.next()) |v| v.deinit(self.surface.alloc); self.key_events.clear(); self.vt_stream.handler.current_seq = 1; } - cimgui.c.igSeparator(); + cimgui.c.ImGui_Separator(); - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_key_events", 1, //cimgui.c.ImGuiTableFlags_ScrollY | cimgui.c.ImGuiTableFlags_RowBg | cimgui.c.ImGuiTableFlags_Borders, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); var it = self.key_events.iterator(.reverse); while (it.next()) |ev| { // Need to push an ID so that our selectable is unique. - cimgui.c.igPushID_Ptr(ev); - defer cimgui.c.igPopID(); + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); var buf: [1024]u8 = undefined; const label = ev.label(&buf) catch "Key Event"; - _ = cimgui.c.igSelectable_BoolPtr( + _ = cimgui.c.ImGui_SelectableBoolPtr( label.ptr, &ev.imgui_state.selected, cimgui.c.ImGuiSelectableFlags_None, - .{ .x = 0, .y = 0 }, ); if (!ev.imgui_state.selected) continue; @@ -1034,7 +1008,7 @@ fn getKeyAction(self: *Inspector) KeyAction { }; inline for (keys) |k| { - if (cimgui.c.igIsKeyPressed_Bool(k.key, false)) { + if (cimgui.c.ImGui_IsKeyPressed(k.key)) { return k.action; } } @@ -1043,8 +1017,8 @@ fn getKeyAction(self: *Inspector) KeyAction { fn renderTermioWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. - defer cimgui.c.igEnd(); - if (!cimgui.c.igBegin( + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( window_termio, null, cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, @@ -1057,21 +1031,21 @@ fn renderTermioWindow(self: *Inspector) void { "Pause##pause_play" else "Resume##pause_play"; - if (cimgui.c.igButton(pause_play.ptr, .{ .x = 0, .y = 0 })) { + if (cimgui.c.ImGui_Button(pause_play.ptr)) { self.vt_stream.handler.active = !self.vt_stream.handler.active; } - cimgui.c.igSameLine(0, cimgui.c.igGetStyle().*.ItemInnerSpacing.x); - if (cimgui.c.igButton("Filter", .{ .x = 0, .y = 0 })) { - cimgui.c.igOpenPopup_Str( + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Filter")) { + cimgui.c.ImGui_OpenPopup( popup_filter, cimgui.c.ImGuiPopupFlags_None, ); } if (!self.vt_events.empty()) { - cimgui.c.igSameLine(0, cimgui.c.igGetStyle().*.ItemInnerSpacing.x); - if (cimgui.c.igButton("Clear", .{ .x = 0, .y = 0 })) { + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Clear")) { var it = self.vt_events.iterator(.forward); while (it.next()) |v| v.deinit(self.surface.alloc); self.vt_events.clear(); @@ -1081,44 +1055,36 @@ fn renderTermioWindow(self: *Inspector) void { } } - cimgui.c.igSeparator(); + cimgui.c.ImGui_Separator(); if (self.vt_events.empty()) { - cimgui.c.igText("Waiting for events..."); + cimgui.c.ImGui_Text("Waiting for events..."); break :list; } - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_vt_events", 3, cimgui.c.ImGuiTableFlags_RowBg | cimgui.c.ImGuiTableFlags_Borders, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); - cimgui.c.igTableSetupColumn( + cimgui.c.ImGui_TableSetupColumn( "Seq", cimgui.c.ImGuiTableColumnFlags_WidthFixed, - 0, - 0, ); - cimgui.c.igTableSetupColumn( + cimgui.c.ImGui_TableSetupColumn( "Kind", cimgui.c.ImGuiTableColumnFlags_WidthFixed, - 0, - 0, ); - cimgui.c.igTableSetupColumn( + cimgui.c.ImGui_TableSetupColumn( "Description", cimgui.c.ImGuiTableColumnFlags_WidthStretch, - 0, - 0, ); // Handle keyboard navigation when window is focused - if (cimgui.c.igIsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) { + if (cimgui.c.ImGui_IsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) { const key_pressed = self.getKeyAction(); switch (key_pressed) { @@ -1174,11 +1140,11 @@ fn renderTermioWindow(self: *Inspector) void { var it = self.vt_events.iterator(.reverse); while (it.next()) |ev| { // Need to push an ID so that our selectable is unique. - cimgui.c.igPushID_Ptr(ev); - defer cimgui.c.igPopID(); + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableNextColumn(); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); // Store the previous selection state to detect changes const was_selected = ev.imgui_selected; @@ -1189,11 +1155,10 @@ fn renderTermioWindow(self: *Inspector) void { } // Handle selectable widget - if (cimgui.c.igSelectable_BoolPtr( + if (cimgui.c.ImGui_SelectableBoolPtr( "##select", &ev.imgui_selected, cimgui.c.ImGuiSelectableFlags_SpanAllColumns, - .{ .x = 0, .y = 0 }, )) { // If selection state changed, update keyboard navigation state if (ev.imgui_selected != was_selected) { @@ -1205,40 +1170,38 @@ fn renderTermioWindow(self: *Inspector) void { } } - cimgui.c.igSameLine(0, 0); - cimgui.c.igText("%d", ev.seq); - _ = cimgui.c.igTableNextColumn(); - cimgui.c.igText("%s", @tagName(ev.kind).ptr); - _ = cimgui.c.igTableNextColumn(); - cimgui.c.igText("%s", ev.str.ptr); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%d", ev.seq); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", ev.str.ptr); // If the event is selected, we render info about it. For now // we put this in the last column because that's the widest and // imgui has no way to make a column span. if (ev.imgui_selected) { { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "details", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); inspector.cursor.renderInTable( self.surface.renderer_state.terminal, &ev.cursor, ); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Scroll Region"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Scroll Region"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( "T=%d B=%d L=%d R=%d", ev.scrolling_region.top, ev.scrolling_region.bottom, @@ -1253,51 +1216,49 @@ fn renderTermioWindow(self: *Inspector) void { var buf: [256]u8 = undefined; const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch ""; - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableNextColumn(); - cimgui.c.igText("%s", key.ptr); - _ = cimgui.c.igTableNextColumn(); - cimgui.c.igText("%s", entry.value_ptr.ptr); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", key.ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", entry.value_ptr.ptr); } } // If this is the selected event and scrolling is needed, scroll to it if (self.need_scroll_to_selected and self.is_keyboard_selection) { - cimgui.c.igSetScrollHereY(0.5); + cimgui.c.ImGui_SetScrollHereY(0.5); self.need_scroll_to_selected = false; } } } } // table - if (cimgui.c.igBeginPopupModal( + if (cimgui.c.ImGui_BeginPopupModal( popup_filter, null, cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, )) { - defer cimgui.c.igEndPopup(); + defer cimgui.c.ImGui_EndPopup(); - cimgui.c.igText("Changed filter settings will only affect future events."); + cimgui.c.ImGui_Text("Changed filter settings will only affect future events."); - cimgui.c.igSeparator(); + cimgui.c.ImGui_Separator(); { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_filter_kind", 3, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); inline for (@typeInfo(terminal.Parser.Action.Tag).@"enum".fields) |field| { const tag = @field(terminal.Parser.Action.Tag, field.name); if (tag == .apc_put or tag == .dcs_put) continue; - _ = cimgui.c.igTableNextColumn(); + _ = cimgui.c.ImGui_TableNextColumn(); var value = !self.vt_stream.handler.filter_exclude.contains(tag); - if (cimgui.c.igCheckbox(@tagName(tag).ptr, &value)) { + if (cimgui.c.ImGui_Checkbox(@tagName(tag).ptr, &value)) { if (value) { self.vt_stream.handler.filter_exclude.remove(tag); } else { @@ -1307,22 +1268,22 @@ fn renderTermioWindow(self: *Inspector) void { } } // Filter kind table - cimgui.c.igSeparator(); + cimgui.c.ImGui_Separator(); - cimgui.c.igText( + cimgui.c.ImGui_Text( "Filter by string. Empty displays all, \"abc\" finds lines\n" ++ "containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n" ++ "or \"xyz\", \"-abc\" excludes lines containing \"abc\".", ); _ = cimgui.c.ImGuiTextFilter_Draw( - self.vt_stream.handler.filter_text, + &self.vt_stream.handler.filter_text, "##filter_text", 0, ); - cimgui.c.igSeparator(); - if (cimgui.c.igButton("Close", .{ .x = 0, .y = 0 })) { - cimgui.c.igCloseCurrentPopup(); + cimgui.c.ImGui_Separator(); + if (cimgui.c.ImGui_Button("Close")) { + cimgui.c.ImGui_CloseCurrentPopup(); } } // filter popup } diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig index 2f72556bd..540e044fd 100644 --- a/src/inspector/cell.zig +++ b/src/inspector/cell.zig @@ -1,7 +1,7 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); /// A cell being inspected. This duplicates much of the data in @@ -55,24 +55,22 @@ pub const Cell = struct { y: usize, ) void { // We have a selected cell, show information about it. - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "table_cursor", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Grid Position"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid Position"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("row=%d col=%d", y, x); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("row=%d col=%d", y, x); } } @@ -82,18 +80,18 @@ pub const Cell = struct { // the single glyph in an image view so it looks _identical_ to the // terminal. codepoint: { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Codepoints"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Codepoints"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - if (cimgui.c.igBeginListBox("##codepoints", .{ .x = 0, .y = 0 })) { - defer cimgui.c.igEndListBox(); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cimgui.c.ImGui_BeginListBox("##codepoints", .{ .x = 0, .y = 0 })) { + defer cimgui.c.ImGui_EndListBox(); if (self.codepoint == 0) { - _ = cimgui.c.igSelectable_Bool("(empty)", false, 0, .{}); + _ = cimgui.c.ImGui_SelectableEx("(empty)", false, 0, .{}); break :codepoint; } @@ -102,42 +100,42 @@ pub const Cell = struct { { const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{self.codepoint}) catch ""; - _ = cimgui.c.igSelectable_Bool(key.ptr, false, 0, .{}); + _ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{}); } // All extras for (self.cps) |cp| { const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch ""; - _ = cimgui.c.igSelectable_Bool(key.ptr, false, 0, .{}); + _ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{}); } } } } // Character width property - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Width Property"); - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText(@tagName(self.wide)); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Width Property"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text(@tagName(self.wide)); // If we have a color then we show the color - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Foreground Color"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground Color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (self.style.fg_color) { - .none => cimgui.c.igText("default"), + .none => cimgui.c.ImGui_Text("default"), .palette => |idx| { const rgb = t.colors.palette.current[idx]; - cimgui.c.igValue_Int("Palette", idx); + cimgui.c.ImGui_Text("Palette %d", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_fg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -152,7 +150,7 @@ pub const Cell = struct { @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_fg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -162,21 +160,21 @@ pub const Cell = struct { }, } - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Background Color"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background Color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (self.style.bg_color) { - .none => cimgui.c.igText("default"), + .none => cimgui.c.ImGui_Text("default"), .palette => |idx| { const rgb = t.colors.palette.current[idx]; - cimgui.c.igValue_Int("Palette", idx); + cimgui.c.ImGui_Text("Palette %d", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_bg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -191,7 +189,7 @@ pub const Cell = struct { @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_bg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -209,17 +207,17 @@ pub const Cell = struct { inline for (styles) |style| style: { if (!@field(self.style.flags, style)) break :style; - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText(style.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text(style.ptr); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("true"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); } } - cimgui.c.igTextDisabled("(Any styles not shown are not currently set)"); + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); } }; diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index 756898252..4f8bfb2e0 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -1,4 +1,4 @@ -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); /// Render cursor information with a table already open. @@ -7,57 +7,57 @@ pub fn renderInTable( cursor: *const terminal.Screen.Cursor, ) void { { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Position (x, y)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Position (x, y)"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("(%d, %d)", cursor.x, cursor.y); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Style"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Style"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(cursor.cursor_style).ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(cursor.cursor_style).ptr); } } if (cursor.pending_wrap) { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Pending Wrap"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pending Wrap"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr); } } // If we have a color then we show the color - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Foreground Color"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground Color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (cursor.style.fg_color) { - .none => cimgui.c.igText("default"), + .none => cimgui.c.ImGui_Text("default"), .palette => |idx| { const rgb = t.colors.palette.current[idx]; - cimgui.c.igValue_Int("Palette", idx); + cimgui.c.ImGui_Text("Palette %d", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_fg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -72,7 +72,7 @@ pub fn renderInTable( @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_fg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -82,21 +82,21 @@ pub fn renderInTable( }, } - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Background Color"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background Color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (cursor.style.bg_color) { - .none => cimgui.c.igText("default"), + .none => cimgui.c.ImGui_Text("default"), .palette => |idx| { const rgb = t.colors.palette.current[idx]; - cimgui.c.igValue_Int("Palette", idx); + cimgui.c.ImGui_Text("Palette %d", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_bg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -111,7 +111,7 @@ pub fn renderInTable( @as(f32, @floatFromInt(rgb.g)) / 255, @as(f32, @floatFromInt(rgb.b)) / 255, }; - _ = cimgui.c.igColorEdit3( + _ = cimgui.c.ImGui_ColorEdit3( "color_bg", &color, cimgui.c.ImGuiColorEditFlags_DisplayHex | @@ -129,14 +129,14 @@ pub fn renderInTable( inline for (styles) |style| style: { if (!@field(cursor.style.flags, style)) break :style; - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText(style.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text(style.ptr); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("true"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); } } } diff --git a/src/inspector/key.zig b/src/inspector/key.zig index e42e4f23c..12d91a107 100644 --- a/src/inspector/key.zig +++ b/src/inspector/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const input = @import("../input.zig"); const CircBuf = @import("../datastruct/main.zig").CircBuf; -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); /// Circular buffer of key events. pub const EventRing = CircBuf(Event, undefined); @@ -72,30 +72,28 @@ pub const Event = struct { /// Render this event in the inspector GUI. pub fn render(self: *const Event) void { - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "##event", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); if (self.binding.len > 0) { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Triggered Binding"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Triggered Binding"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); const height: f32 = height: { const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); - const padding = cimgui.c.igGetStyle().*.FramePadding.y * 2; - break :height cimgui.c.igGetTextLineHeightWithSpacing() * item_count + padding; + const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2; + break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding; }; - if (cimgui.c.igBeginListBox("##bindings", .{ .x = 0, .y = height })) { - defer cimgui.c.igEndListBox(); + if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) { + defer cimgui.c.ImGui_EndListBox(); for (self.binding) |action| { - _ = cimgui.c.igSelectable_Bool( + _ = cimgui.c.ImGui_SelectableEx( @tagName(action).ptr, false, cimgui.c.ImGuiSelectableFlags_None, @@ -106,64 +104,64 @@ pub const Event = struct { } pty: { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Encoding to Pty"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Encoding to Pty"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); if (self.pty.len == 0) { - cimgui.c.igTextDisabled("(no data)"); + cimgui.c.ImGui_TextDisabled("(no data)"); break :pty; } self.renderPty() catch { - cimgui.c.igTextDisabled("(error rendering pty data)"); + cimgui.c.ImGui_TextDisabled("(error rendering pty data)"); break :pty; }; } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Action"); - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(self.event.action).ptr); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Action"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr); } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Key"); - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(self.event.key).ptr); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Key"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr); } if (!self.event.mods.empty()) { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Mods"); - _ = cimgui.c.igTableSetColumnIndex(1); - if (self.event.mods.shift) cimgui.c.igText("shift "); - if (self.event.mods.ctrl) cimgui.c.igText("ctrl "); - if (self.event.mods.alt) cimgui.c.igText("alt "); - if (self.event.mods.super) cimgui.c.igText("super "); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Mods"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.event.mods.shift) cimgui.c.ImGui_Text("shift "); + if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl "); + if (self.event.mods.alt) cimgui.c.ImGui_Text("alt "); + if (self.event.mods.super) cimgui.c.ImGui_Text("super "); } if (self.event.composing) { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Composing"); - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("true"); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Composing"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); } utf8: { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("UTF-8"); - _ = cimgui.c.igTableSetColumnIndex(1); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("UTF-8"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); if (self.event.utf8.len == 0) { - cimgui.c.igTextDisabled("(empty)"); + cimgui.c.ImGui_TextDisabled("(empty)"); break :utf8; } self.renderUtf8(self.event.utf8) catch { - cimgui.c.igTextDisabled("(error rendering utf-8)"); + cimgui.c.ImGui_TextDisabled("(error rendering utf-8)"); break :utf8; }; } @@ -187,13 +185,11 @@ pub const Event = struct { try writer.writeByte(0); // Render as a textbox - _ = cimgui.c.igInputText( + _ = cimgui.c.ImGui_InputText( "##utf8", &buf, buf_stream.getWritten().len - 1, cimgui.c.ImGuiInputTextFlags_ReadOnly, - null, - null, ); } @@ -223,13 +219,11 @@ pub const Event = struct { try writer.writeByte(0); // Render as a textbox - _ = cimgui.c.igInputText( + _ = cimgui.c.ImGui_InputText( "##pty", &buf, buf_stream.getWritten().len - 1, cimgui.c.ImGuiInputTextFlags_ReadOnly, - null, - null, ); } }; diff --git a/src/inspector/page.zig b/src/inspector/page.zig index 7da469e21..fd9d3bfb4 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -1,167 +1,161 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); const units = @import("units.zig"); pub fn render(page: *const terminal.Page) void { - cimgui.c.igPushID_Ptr(page); - defer cimgui.c.igPopID(); + cimgui.c.ImGui_PushIDPtr(page); + defer cimgui.c.ImGui_PopID(); - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_BeginTable( "##page_state", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Memory Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Size"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len)); - cimgui.c.igText("%d VM pages", page.memory.len / std.heap.page_size_min); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len)); + cimgui.c.ImGui_Text("%d VM pages", page.memory.len / std.heap.page_size_min); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Unique Styles"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Unique Styles"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", page.styles.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", page.styles.count()); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Grapheme Entries"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme Entries"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", page.graphemeCount()); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", page.graphemeCount()); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Capacity"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Capacity"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = cimgui.c.ImGui_BeginTable( "##capacity", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); const cap = page.capacity; { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Columns"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Columns"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", @as(u32, @intCast(cap.cols))); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.cols))); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Rows"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", @as(u32, @intCast(cap.rows))); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.rows))); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Unique Styles"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Unique Styles"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", @as(u32, @intCast(cap.styles))); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.styles))); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Grapheme Bytes"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme Bytes"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", cap.grapheme_bytes); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", cap.grapheme_bytes); } } } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - _ = cimgui.c.igBeginTable( + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = cimgui.c.ImGui_BeginTable( "##size", 2, cimgui.c.ImGuiTableFlags_None, - .{ .x = 0, .y = 0 }, - 0, ); - defer cimgui.c.igEndTable(); + defer cimgui.c.ImGui_EndTable(); const size = page.size; { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Columns"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Columns"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", @as(u32, @intCast(size.cols))); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.cols))); } } { - cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); + cimgui.c.ImGui_TableNextRow(); { - _ = cimgui.c.igTableSetColumnIndex(0); - cimgui.c.igText("Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Rows"); } { - _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d", @as(u32, @intCast(size.rows))); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.rows))); } } } diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 7e2b51ee1..9f55e6019 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const terminal = @import("../terminal/main.zig"); const CircBuf = @import("../datastruct/main.zig").CircBuf; const Surface = @import("../Surface.zig"); @@ -83,7 +83,7 @@ pub const VTEvent = struct { /// Returns true if the event passes the given filter. pub fn passFilter( self: *const VTEvent, - filter: *cimgui.c.ImGuiTextFilter, + filter: *const cimgui.c.ImGuiTextFilter, ) bool { // Check our main string if (cimgui.c.ImGuiTextFilter_PassFilter( @@ -318,19 +318,18 @@ pub const VTHandler = struct { /// Exclude certain actions by tag. filter_exclude: ActionTagSet = .initMany(&.{.print}), - filter_text: *cimgui.c.ImGuiTextFilter, + filter_text: cimgui.c.ImGuiTextFilter = .{}, const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); pub fn init(surface: *Surface) VTHandler { return .{ .surface = surface, - .filter_text = cimgui.c.ImGuiTextFilter_ImGuiTextFilter(""), }; } pub fn deinit(self: *VTHandler) void { - cimgui.c.ImGuiTextFilter_destroy(self.filter_text); + _ = self; } pub fn vt( @@ -371,7 +370,7 @@ pub const VTHandler = struct { errdefer ev.deinit(alloc); // Check if the event passes the filter - if (!ev.passFilter(self.filter_text)) { + if (!ev.passFilter(&self.filter_text)) { ev.deinit(alloc); return true; } From 896361f426d29efc4a28f69261095dfeeef3733a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 11:00:21 -0800 Subject: [PATCH 335/605] Fix up API calls for initialization --- pkg/dcimgui/build.zig | 5 ++++ pkg/dcimgui/ext.cpp | 30 ++++++++++++++++++++++++ pkg/dcimgui/main.zig | 6 +++++ src/apprt/embedded.zig | 7 ++++-- src/apprt/gtk/class/imgui_widget.zig | 7 ++++-- src/inspector/Inspector.zig | 35 ++++++++++++++-------------- 6 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 pkg/dcimgui/ext.cpp diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 2d1594cba..4e5184920 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -133,6 +133,11 @@ pub fn build(b: *std.Build) !void { }, .flags = flags.items, }); + lib.addCSourceFiles(.{ + .root = b.path(""), + .files = &.{"ext.cpp"}, + .flags = flags.items, + }); lib.installHeadersDirectory( upstream.path(""), diff --git a/pkg/dcimgui/ext.cpp b/pkg/dcimgui/ext.cpp new file mode 100644 index 000000000..d4732e0fa --- /dev/null +++ b/pkg/dcimgui/ext.cpp @@ -0,0 +1,30 @@ +#include "imgui.h" + +// This file contains custom extensions for functionality that isn't +// properly supported by Dear Bindings yet. Namely: +// https://github.com/dearimgui/dear_bindings/issues/55 + +// Wrap this in a namespace to keep it separate from the C++ API +namespace cimgui +{ +#include "dcimgui.h" +} + +extern "C" +{ +CIMGUI_API void ImFontConfig_ImFontConfig(cimgui::ImFontConfig* self) +{ + static_assert(sizeof(cimgui::ImFontConfig) == sizeof(::ImFontConfig), "ImFontConfig size mismatch"); + static_assert(alignof(cimgui::ImFontConfig) == alignof(::ImFontConfig), "ImFontConfig alignment mismatch"); + ::ImFontConfig defaults; + *reinterpret_cast<::ImFontConfig*>(self) = defaults; +} + +CIMGUI_API void ImGuiStyle_ImGuiStyle(cimgui::ImGuiStyle* self) +{ + static_assert(sizeof(cimgui::ImGuiStyle) == sizeof(::ImGuiStyle), "ImGuiStyle size mismatch"); + static_assert(alignof(cimgui::ImGuiStyle) == alignof(::ImGuiStyle), "ImGuiStyle alignment mismatch"); + ::ImGuiStyle defaults; + *reinterpret_cast<::ImGuiStyle*>(self) = defaults; +} +} diff --git a/pkg/dcimgui/main.zig b/pkg/dcimgui/main.zig index 7ea135cdb..00a3c739d 100644 --- a/pkg/dcimgui/main.zig +++ b/pkg/dcimgui/main.zig @@ -28,6 +28,12 @@ pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID; pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void; +// Extension functions from ext.cpp +pub const ext = struct { + pub extern fn ImFontConfig_ImFontConfig(self: *c.ImFontConfig) callconv(.c) void; + pub extern fn ImGuiStyle_ImGuiStyle(self: *c.ImGuiStyle) callconv(.c) void; +}; + test { _ = c; } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c6c4d2ab8..27f07604a 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1070,8 +1070,11 @@ pub const Inspector = struct { // Cache our scale because we use it for cursor position calculations. self.content_scale = x; - // Setup a new style and scale it appropriately. - var style: cimgui.c.ImGuiStyle = .{}; + // Setup a new style and scale it appropriately. We must use the + // ImGuiStyle constructor to get proper default values (e.g., + // CurveTessellationTol) rather than zero-initialized values. + var style: cimgui.c.ImGuiStyle = undefined; + cimgui.ext.ImGuiStyle_ImGuiStyle(&style); cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatCast(x)); const active_style = cimgui.c.ImGui_GetStyle(); active_style.* = style; diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 474efaa91..39a8cef63 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -255,8 +255,11 @@ pub const ImguiWidget = extern struct { io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; - // Setup a new style and scale it appropriately. - var style: cimgui.c.ImGuiStyle = .{}; + // Setup a new style and scale it appropriately. We must use the + // ImGuiStyle constructor to get proper default values (e.g., + // CurveTessellationTol) rather than zero-initialized values. + var style: cimgui.c.ImGuiStyle = undefined; + cimgui.ext.ImGuiStyle_ImGuiStyle(&style); cimgui.c.ImGuiStyle_ScaleAllSizes(&style, @floatFromInt(scale_factor)); const active_style = cimgui.c.ImGui_GetStyle(); active_style.* = style; diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index b1ac473b8..7029b7d3a 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -138,23 +138,24 @@ pub fn setup() void { io.IniFilename = null; io.LogFilename = null; - // Use our own embedded font - { - // TODO: This will have to be recalculated for different screen DPIs. - // This is currently hardcoded to a 2x content scale. - const font_size = 16 * 2; - - var font_config: cimgui.c.ImFontConfig = .{}; - font_config.FontDataOwnedByAtlas = false; - _ = cimgui.c.ImFontAtlas_AddFontFromMemoryTTF( - io.Fonts, - @ptrCast(@constCast(font.embedded.regular)), - font.embedded.regular.len, - font_size, - &font_config, - null, - ); - } + // // Use our own embedded font + // { + // // TODO: This will have to be recalculated for different screen DPIs. + // // This is currently hardcoded to a 2x content scale. + // const font_size = 16 * 2; + // + // var font_config: cimgui.c.ImFontConfig = .{}; + // cimgui.ext.ImFontConfig_ImFontConfig(&font_config); + // font_config.FontDataOwnedByAtlas = false; + // _ = cimgui.c.ImFontAtlas_AddFontFromMemoryTTF( + // io.Fonts, + // @ptrCast(@constCast(font.embedded.regular.ptr)), + // @intCast(font.embedded.regular.len), + // font_size, + // &font_config, + // null, + // ); + // } } pub fn init(surface: *Surface) !Inspector { From f1ba5297b8b35b6a952ff0312b3e0fd4564d0908 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 13:20:01 -0800 Subject: [PATCH 336/605] build: fix imgui on GTK --- src/apprt/gtk/class/imgui_widget.zig | 2 +- src/build/SharedDeps.zig | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 39a8cef63..79e85fad2 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -1,7 +1,7 @@ const std = @import("std"); const assert = @import("../../../quirks.zig").inlineAssert; -const cimgui = @import("cimgui"); +const cimgui = @import("dcimgui"); const gl = @import("opengl"); const adw = @import("adw"); const gdk = @import("gdk"); diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index f96a269bd..ce0d5d479 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -484,6 +484,7 @@ pub fn add( .optimize = optimize, .@"backend-metal" = target.result.os.tag.isDarwin(), .@"backend-osx" = target.result.os.tag == .macos, + .@"backend-opengl3" = target.result.os.tag != .macos, })) |dep| { step.root_module.addImport("dcimgui", dep.module("dcimgui")); step.linkLibrary(dep.artifact("dcimgui")); From 965ffb1750b2373c4e25cdf3a03b160635ad1dc4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 13:12:55 -0800 Subject: [PATCH 337/605] pkg/dcimgui: add freetype --- pkg/dcimgui/build.zig | 37 +++++++++++++++++++++++++++++++++++ src/build/SharedDeps.zig | 42 ++++++++++++++++++++-------------------- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 4e5184920..683f0be92 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -4,12 +4,14 @@ const NativeTargetInfo = std.zig.system.NativeTargetInfo; pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const freetype = b.option(bool, "freetype", "Use Freetype") orelse false; const backend_opengl3 = b.option(bool, "backend-opengl3", "OpenGL3 backend") orelse false; const backend_metal = b.option(bool, "backend-metal", "Metal backend") orelse false; const backend_osx = b.option(bool, "backend-osx", "OSX backend") orelse false; // Build options const options = b.addOptions(); + options.addOption(bool, "freetype", freetype); options.addOption(bool, "backend_opengl3", backend_opengl3); options.addOption(bool, "backend_metal", backend_metal); options.addOption(bool, "backend_osx", backend_osx); @@ -50,6 +52,9 @@ pub fn build(b: *std.Build) !void { "-DIMGUI_USE_WCHAR32=1", "-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1", }); + if (freetype) try flags.appendSlice(b.allocator, &.{ + "-DIMGUI_ENABLE_FREETYPE=1", + }); if (target.result.os.tag == .windows) { try flags.appendSlice(b.allocator, &.{ "-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)", @@ -84,6 +89,30 @@ pub fn build(b: *std.Build) !void { .{ .include_extensions = &.{".h"} }, ); + if (freetype) { + lib.addCSourceFile(.{ + .file = upstream.path("misc/freetype/imgui_freetype.cpp"), + .flags = flags.items, + }); + + if (b.systemIntegrationOption("freetype", .{})) { + lib.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + const freetype_dep = b.dependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + }); + lib.linkLibrary(freetype_dep.artifact("freetype")); + if (freetype_dep.builder.lazyDependency( + "freetype", + .{}, + )) |freetype_upstream| { + mod.addIncludePath(freetype_upstream.path("include")); + } + } + } + if (backend_metal) { lib.addCSourceFiles(.{ .root = upstream.path("backends"), @@ -160,3 +189,11 @@ pub fn build(b: *std.Build) !void { const test_step = b.step("test", "Run tests"); test_step.dependOn(&tests_run.step); } + +// For dynamic linking, we prefer dynamic linking and to search by +// mode first. Mode first will search all paths for a dynamic library +// before falling back to static. +const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, +}; diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index ce0d5d479..b1c084002 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -135,29 +135,28 @@ pub fn add( // Every exe needs the terminal options self.config.terminalOptions().add(b, step.root_module); - // Freetype + // Freetype. We always include this even if our font backend doesn't + // use it because Dear Imgui uses Freetype. _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help - if (self.config.font_backend.hasFreetype()) { - if (b.lazyDependency("freetype", .{ - .target = target, - .optimize = optimize, - .@"enable-libpng" = true, - })) |freetype_dep| { - step.root_module.addImport( - "freetype", - freetype_dep.module("freetype"), - ); + if (b.lazyDependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + })) |freetype_dep| { + step.root_module.addImport( + "freetype", + freetype_dep.module("freetype"), + ); - if (b.systemIntegrationOption("freetype", .{})) { - step.linkSystemLibrary2("bzip2", dynamic_link_opts); - step.linkSystemLibrary2("freetype2", dynamic_link_opts); - } else { - step.linkLibrary(freetype_dep.artifact("freetype")); - try static_libs.append( - b.allocator, - freetype_dep.artifact("freetype").getEmittedBin(), - ); - } + if (b.systemIntegrationOption("freetype", .{})) { + step.linkSystemLibrary2("bzip2", dynamic_link_opts); + step.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + step.linkLibrary(freetype_dep.artifact("freetype")); + try static_libs.append( + b.allocator, + freetype_dep.artifact("freetype").getEmittedBin(), + ); } } @@ -482,6 +481,7 @@ pub fn add( if (b.lazyDependency("dcimgui", .{ .target = target, .optimize = optimize, + .freetype = true, .@"backend-metal" = target.result.os.tag.isDarwin(), .@"backend-osx" = target.result.os.tag == .macos, .@"backend-opengl3" = target.result.os.tag != .macos, From f2bc722a582862f94a7ad4d3a4e658ba33b1de5b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 13:35:37 -0800 Subject: [PATCH 338/605] pkg/dcimgui: fix wchar size mismatch --- pkg/dcimgui/main.zig | 4 ++++ src/inspector/Inspector.zig | 36 ++++++++++++++++++------------------ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/pkg/dcimgui/main.zig b/pkg/dcimgui/main.zig index 00a3c739d..e709158f5 100644 --- a/pkg/dcimgui/main.zig +++ b/pkg/dcimgui/main.zig @@ -1,6 +1,10 @@ pub const build_options = @import("build_options"); pub const c = @cImport({ + // This is set during the build so it also has to be set + // during import time to get the right types. Without this + // you get stack size mismatches on some structs. + @cDefine("IMGUI_USE_WCHAR32", "1"); @cInclude("dcimgui.h"); }); diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 7029b7d3a..156e2cb18 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -138,24 +138,24 @@ pub fn setup() void { io.IniFilename = null; io.LogFilename = null; - // // Use our own embedded font - // { - // // TODO: This will have to be recalculated for different screen DPIs. - // // This is currently hardcoded to a 2x content scale. - // const font_size = 16 * 2; - // - // var font_config: cimgui.c.ImFontConfig = .{}; - // cimgui.ext.ImFontConfig_ImFontConfig(&font_config); - // font_config.FontDataOwnedByAtlas = false; - // _ = cimgui.c.ImFontAtlas_AddFontFromMemoryTTF( - // io.Fonts, - // @ptrCast(@constCast(font.embedded.regular.ptr)), - // @intCast(font.embedded.regular.len), - // font_size, - // &font_config, - // null, - // ); - // } + // Use our own embedded font + { + // TODO: This will have to be recalculated for different screen DPIs. + // This is currently hardcoded to a 2x content scale. + const font_size = 16 * 2; + + var font_config: cimgui.c.ImFontConfig = undefined; + cimgui.ext.ImFontConfig_ImFontConfig(&font_config); + font_config.FontDataOwnedByAtlas = false; + _ = cimgui.c.ImFontAtlas_AddFontFromMemoryTTF( + io.Fonts, + @ptrCast(@constCast(font.embedded.regular.ptr)), + @intCast(font.embedded.regular.len), + font_size, + &font_config, + null, + ); + } } pub fn init(surface: *Surface) !Inspector { From 82e585ad9a8e6c1fcec5689ec2e603d1d5fa3be8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 13:38:39 -0800 Subject: [PATCH 339/605] remove pkg/cimgui --- build.zig.zon | 1 - pkg/cimgui/build.zig | 129 - pkg/cimgui/build.zig.zon | 19 - pkg/cimgui/c.zig | 4 - pkg/cimgui/main.zig | 20 - pkg/cimgui/vendor/cimgui.cpp | 5943 ------------------------------ pkg/cimgui/vendor/cimgui.h | 6554 ---------------------------------- 7 files changed, 12670 deletions(-) delete mode 100644 pkg/cimgui/build.zig delete mode 100644 pkg/cimgui/build.zig.zon delete mode 100644 pkg/cimgui/c.zig delete mode 100644 pkg/cimgui/main.zig delete mode 100644 pkg/cimgui/vendor/cimgui.cpp delete mode 100644 pkg/cimgui/vendor/cimgui.h diff --git a/build.zig.zon b/build.zig.zon index eff6dc12e..c9c3093b6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -63,7 +63,6 @@ }, // C libs - .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, .dcimgui = .{ .path = "./pkg/dcimgui", .lazy = true }, .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, .freetype = .{ .path = "./pkg/freetype", .lazy = true }, diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig deleted file mode 100644 index 890873ef9..000000000 --- a/pkg/cimgui/build.zig +++ /dev/null @@ -1,129 +0,0 @@ -const std = @import("std"); -const NativeTargetInfo = std.zig.system.NativeTargetInfo; - -pub fn build(b: *std.Build) !void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const module = b.addModule("cimgui", .{ - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - - const imgui_ = b.lazyDependency("imgui", .{}); - const lib = b.addLibrary(.{ - .name = "cimgui", - .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - }), - .linkage = .static, - }); - lib.linkLibC(); - lib.linkLibCpp(); - if (target.result.os.tag == .windows) { - lib.linkSystemLibrary("imm32"); - } - - // For dynamic linking, we prefer dynamic linking and to search by - // mode first. Mode first will search all paths for a dynamic library - // before falling back to static. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; - - if (b.systemIntegrationOption("freetype", .{})) { - lib.linkSystemLibrary2("freetype2", dynamic_link_opts); - } else { - const freetype = b.dependency("freetype", .{ - .target = target, - .optimize = optimize, - .@"enable-libpng" = true, - }); - lib.linkLibrary(freetype.artifact("freetype")); - - if (freetype.builder.lazyDependency( - "freetype", - .{}, - )) |freetype_dep| { - module.addIncludePath(freetype_dep.path("include")); - } - } - - if (imgui_) |imgui| lib.addIncludePath(imgui.path("")); - module.addIncludePath(b.path("vendor")); - - var flags: std.ArrayList([]const u8) = .empty; - defer flags.deinit(b.allocator); - try flags.appendSlice(b.allocator, &.{ - "-DCIMGUI_FREETYPE=1", - "-DIMGUI_USE_WCHAR32=1", - "-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1", - }); - if (target.result.os.tag == .windows) { - try flags.appendSlice(b.allocator, &.{ - "-DIMGUI_IMPL_API=extern\t\"C\"\t__declspec(dllexport)", - }); - } else { - try flags.appendSlice(b.allocator, &.{ - "-DIMGUI_IMPL_API=extern\t\"C\"", - }); - } - - if (target.result.os.tag == .freebsd) { - try flags.append(b.allocator, "-fPIC"); - } - - if (imgui_) |imgui| { - lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ .file = imgui.path("imgui_draw.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ .file = imgui.path("imgui_demo.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ .file = imgui.path("imgui_widgets.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ .file = imgui.path("imgui_tables.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ .file = imgui.path("misc/freetype/imgui_freetype.cpp"), .flags = flags.items }); - lib.addCSourceFile(.{ - .file = imgui.path("backends/imgui_impl_opengl3.cpp"), - .flags = flags.items, - }); - - if (target.result.os.tag.isDarwin()) { - if (!target.query.isNative()) { - try @import("apple_sdk").addPaths(b, lib); - } - lib.addCSourceFile(.{ - .file = imgui.path("backends/imgui_impl_metal.mm"), - .flags = flags.items, - }); - if (target.result.os.tag == .macos) { - lib.addCSourceFile(.{ - .file = imgui.path("backends/imgui_impl_osx.mm"), - .flags = flags.items, - }); - } - } - } - - lib.installHeadersDirectory( - b.path("vendor"), - "", - .{ .include_extensions = &.{".h"} }, - ); - - b.installArtifact(lib); - - const test_exe = b.addTest(.{ - .name = "test", - .root_module = b.createModule(.{ - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }), - }); - test_exe.linkLibrary(lib); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); -} diff --git a/pkg/cimgui/build.zig.zon b/pkg/cimgui/build.zig.zon deleted file mode 100644 index f539a8fd6..000000000 --- a/pkg/cimgui/build.zig.zon +++ /dev/null @@ -1,19 +0,0 @@ -.{ - .name = .cimgui, - .version = "1.90.6", // -docking branch - .fingerprint = 0x49726f5f8acbc90d, - .paths = .{""}, - .dependencies = .{ - // This should be kept in sync with the submodule in the cimgui source - // code in ./vendor/ to be safe that they're compatible. - .imgui = .{ - // ocornut/imgui - .url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", - .hash = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3", - .lazy = true, - }, - - .apple_sdk = .{ .path = "../apple-sdk" }, - .freetype = .{ .path = "../freetype" }, - }, -} diff --git a/pkg/cimgui/c.zig b/pkg/cimgui/c.zig deleted file mode 100644 index f9b8ff920..000000000 --- a/pkg/cimgui/c.zig +++ /dev/null @@ -1,4 +0,0 @@ -pub const c = @cImport({ - @cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "1"); - @cInclude("cimgui.h"); -}); diff --git a/pkg/cimgui/main.zig b/pkg/cimgui/main.zig deleted file mode 100644 index b890a49ee..000000000 --- a/pkg/cimgui/main.zig +++ /dev/null @@ -1,20 +0,0 @@ -pub const c = @import("c.zig").c; - -// OpenGL -pub extern fn ImGui_ImplOpenGL3_Init(?[*:0]const u8) callconv(.c) bool; -pub extern fn ImGui_ImplOpenGL3_Shutdown() callconv(.c) void; -pub extern fn ImGui_ImplOpenGL3_NewFrame() callconv(.c) void; -pub extern fn ImGui_ImplOpenGL3_RenderDrawData(*c.ImDrawData) callconv(.c) void; - -// Metal -pub extern fn ImGui_ImplMetal_Init(*anyopaque) callconv(.c) bool; -pub extern fn ImGui_ImplMetal_Shutdown() callconv(.c) void; -pub extern fn ImGui_ImplMetal_NewFrame(*anyopaque) callconv(.c) void; -pub extern fn ImGui_ImplMetal_RenderDrawData(*c.ImDrawData, *anyopaque, *anyopaque) callconv(.c) void; - -// OSX -pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool; -pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void; -pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void; - -test {} diff --git a/pkg/cimgui/vendor/cimgui.cpp b/pkg/cimgui/vendor/cimgui.cpp deleted file mode 100644 index 3b36df7d9..000000000 --- a/pkg/cimgui/vendor/cimgui.cpp +++ /dev/null @@ -1,5943 +0,0 @@ -// This file is automatically generated by generator.lua from -// https://github.com/cimgui/cimgui based on imgui.h file version "1.90.6" 19060 -// from Dear ImGui https://github.com/ocornut/imgui with imgui_internal.h api -// docking branch -#define IMGUI_ENABLE_FREETYPE -#ifdef IMGUI_ENABLE_FREETYPE -#ifndef CIMGUI_FREETYPE -#error "IMGUI_FREETYPE should be defined for Freetype linking" -#endif -#else -#ifdef CIMGUI_FREETYPE -#error "IMGUI_FREETYPE should not be defined without freetype generated cimgui" -#endif -#endif -#include "imgui.h" -#ifdef IMGUI_ENABLE_FREETYPE -#include "misc/freetype/imgui_freetype.h" -#endif -#include "imgui_internal.h" - -#include "cimgui.h" - -CIMGUI_API ImVec2* ImVec2_ImVec2_Nil(void) { - return IM_NEW(ImVec2)(); -} -CIMGUI_API void ImVec2_destroy(ImVec2* self) { - IM_DELETE(self); -} -CIMGUI_API ImVec2* ImVec2_ImVec2_Float(float _x, float _y) { - return IM_NEW(ImVec2)(_x, _y); -} -CIMGUI_API ImVec4* ImVec4_ImVec4_Nil(void) { - return IM_NEW(ImVec4)(); -} -CIMGUI_API void ImVec4_destroy(ImVec4* self) { - IM_DELETE(self); -} -CIMGUI_API ImVec4* ImVec4_ImVec4_Float(float _x, float _y, float _z, float _w) { - return IM_NEW(ImVec4)(_x, _y, _z, _w); -} -CIMGUI_API ImGuiContext* igCreateContext(ImFontAtlas* shared_font_atlas) { - return ImGui::CreateContext(shared_font_atlas); -} -CIMGUI_API void igDestroyContext(ImGuiContext* ctx) { - return ImGui::DestroyContext(ctx); -} -CIMGUI_API ImGuiContext* igGetCurrentContext() { - return ImGui::GetCurrentContext(); -} -CIMGUI_API void igSetCurrentContext(ImGuiContext* ctx) { - return ImGui::SetCurrentContext(ctx); -} -CIMGUI_API ImGuiIO* igGetIO() { - return &ImGui::GetIO(); -} -CIMGUI_API ImGuiStyle* igGetStyle() { - return &ImGui::GetStyle(); -} -CIMGUI_API void igNewFrame() { - return ImGui::NewFrame(); -} -CIMGUI_API void igEndFrame() { - return ImGui::EndFrame(); -} -CIMGUI_API void igRender() { - return ImGui::Render(); -} -CIMGUI_API ImDrawData* igGetDrawData() { - return ImGui::GetDrawData(); -} -CIMGUI_API void igShowDemoWindow(bool* p_open) { - return ImGui::ShowDemoWindow(p_open); -} -CIMGUI_API void igShowMetricsWindow(bool* p_open) { - return ImGui::ShowMetricsWindow(p_open); -} -CIMGUI_API void igShowDebugLogWindow(bool* p_open) { - return ImGui::ShowDebugLogWindow(p_open); -} -CIMGUI_API void igShowIDStackToolWindow(bool* p_open) { - return ImGui::ShowIDStackToolWindow(p_open); -} -CIMGUI_API void igShowAboutWindow(bool* p_open) { - return ImGui::ShowAboutWindow(p_open); -} -CIMGUI_API void igShowStyleEditor(ImGuiStyle* ref) { - return ImGui::ShowStyleEditor(ref); -} -CIMGUI_API bool igShowStyleSelector(const char* label) { - return ImGui::ShowStyleSelector(label); -} -CIMGUI_API void igShowFontSelector(const char* label) { - return ImGui::ShowFontSelector(label); -} -CIMGUI_API void igShowUserGuide() { - return ImGui::ShowUserGuide(); -} -CIMGUI_API const char* igGetVersion() { - return ImGui::GetVersion(); -} -CIMGUI_API void igStyleColorsDark(ImGuiStyle* dst) { - return ImGui::StyleColorsDark(dst); -} -CIMGUI_API void igStyleColorsLight(ImGuiStyle* dst) { - return ImGui::StyleColorsLight(dst); -} -CIMGUI_API void igStyleColorsClassic(ImGuiStyle* dst) { - return ImGui::StyleColorsClassic(dst); -} -CIMGUI_API bool igBegin(const char* name, - bool* p_open, - ImGuiWindowFlags flags) { - return ImGui::Begin(name, p_open, flags); -} -CIMGUI_API void igEnd() { - return ImGui::End(); -} -CIMGUI_API bool igBeginChild_Str(const char* str_id, - const ImVec2 size, - ImGuiChildFlags child_flags, - ImGuiWindowFlags window_flags) { - return ImGui::BeginChild(str_id, size, child_flags, window_flags); -} -CIMGUI_API bool igBeginChild_ID(ImGuiID id, - const ImVec2 size, - ImGuiChildFlags child_flags, - ImGuiWindowFlags window_flags) { - return ImGui::BeginChild(id, size, child_flags, window_flags); -} -CIMGUI_API void igEndChild() { - return ImGui::EndChild(); -} -CIMGUI_API bool igIsWindowAppearing() { - return ImGui::IsWindowAppearing(); -} -CIMGUI_API bool igIsWindowCollapsed() { - return ImGui::IsWindowCollapsed(); -} -CIMGUI_API bool igIsWindowFocused(ImGuiFocusedFlags flags) { - return ImGui::IsWindowFocused(flags); -} -CIMGUI_API bool igIsWindowHovered(ImGuiHoveredFlags flags) { - return ImGui::IsWindowHovered(flags); -} -CIMGUI_API ImDrawList* igGetWindowDrawList() { - return ImGui::GetWindowDrawList(); -} -CIMGUI_API float igGetWindowDpiScale() { - return ImGui::GetWindowDpiScale(); -} -CIMGUI_API void igGetWindowPos(ImVec2* pOut) { - *pOut = ImGui::GetWindowPos(); -} -CIMGUI_API void igGetWindowSize(ImVec2* pOut) { - *pOut = ImGui::GetWindowSize(); -} -CIMGUI_API float igGetWindowWidth() { - return ImGui::GetWindowWidth(); -} -CIMGUI_API float igGetWindowHeight() { - return ImGui::GetWindowHeight(); -} -CIMGUI_API ImGuiViewport* igGetWindowViewport() { - return ImGui::GetWindowViewport(); -} -CIMGUI_API void igSetNextWindowPos(const ImVec2 pos, - ImGuiCond cond, - const ImVec2 pivot) { - return ImGui::SetNextWindowPos(pos, cond, pivot); -} -CIMGUI_API void igSetNextWindowSize(const ImVec2 size, ImGuiCond cond) { - return ImGui::SetNextWindowSize(size, cond); -} -CIMGUI_API void igSetNextWindowSizeConstraints( - const ImVec2 size_min, - const ImVec2 size_max, - ImGuiSizeCallback custom_callback, - void* custom_callback_data) { - return ImGui::SetNextWindowSizeConstraints( - size_min, size_max, custom_callback, custom_callback_data); -} -CIMGUI_API void igSetNextWindowContentSize(const ImVec2 size) { - return ImGui::SetNextWindowContentSize(size); -} -CIMGUI_API void igSetNextWindowCollapsed(bool collapsed, ImGuiCond cond) { - return ImGui::SetNextWindowCollapsed(collapsed, cond); -} -CIMGUI_API void igSetNextWindowFocus() { - return ImGui::SetNextWindowFocus(); -} -CIMGUI_API void igSetNextWindowScroll(const ImVec2 scroll) { - return ImGui::SetNextWindowScroll(scroll); -} -CIMGUI_API void igSetNextWindowBgAlpha(float alpha) { - return ImGui::SetNextWindowBgAlpha(alpha); -} -CIMGUI_API void igSetNextWindowViewport(ImGuiID viewport_id) { - return ImGui::SetNextWindowViewport(viewport_id); -} -CIMGUI_API void igSetWindowPos_Vec2(const ImVec2 pos, ImGuiCond cond) { - return ImGui::SetWindowPos(pos, cond); -} -CIMGUI_API void igSetWindowSize_Vec2(const ImVec2 size, ImGuiCond cond) { - return ImGui::SetWindowSize(size, cond); -} -CIMGUI_API void igSetWindowCollapsed_Bool(bool collapsed, ImGuiCond cond) { - return ImGui::SetWindowCollapsed(collapsed, cond); -} -CIMGUI_API void igSetWindowFocus_Nil() { - return ImGui::SetWindowFocus(); -} -CIMGUI_API void igSetWindowFontScale(float scale) { - return ImGui::SetWindowFontScale(scale); -} -CIMGUI_API void igSetWindowPos_Str(const char* name, - const ImVec2 pos, - ImGuiCond cond) { - return ImGui::SetWindowPos(name, pos, cond); -} -CIMGUI_API void igSetWindowSize_Str(const char* name, - const ImVec2 size, - ImGuiCond cond) { - return ImGui::SetWindowSize(name, size, cond); -} -CIMGUI_API void igSetWindowCollapsed_Str(const char* name, - bool collapsed, - ImGuiCond cond) { - return ImGui::SetWindowCollapsed(name, collapsed, cond); -} -CIMGUI_API void igSetWindowFocus_Str(const char* name) { - return ImGui::SetWindowFocus(name); -} -CIMGUI_API void igGetContentRegionAvail(ImVec2* pOut) { - *pOut = ImGui::GetContentRegionAvail(); -} -CIMGUI_API void igGetContentRegionMax(ImVec2* pOut) { - *pOut = ImGui::GetContentRegionMax(); -} -CIMGUI_API void igGetWindowContentRegionMin(ImVec2* pOut) { - *pOut = ImGui::GetWindowContentRegionMin(); -} -CIMGUI_API void igGetWindowContentRegionMax(ImVec2* pOut) { - *pOut = ImGui::GetWindowContentRegionMax(); -} -CIMGUI_API float igGetScrollX() { - return ImGui::GetScrollX(); -} -CIMGUI_API float igGetScrollY() { - return ImGui::GetScrollY(); -} -CIMGUI_API void igSetScrollX_Float(float scroll_x) { - return ImGui::SetScrollX(scroll_x); -} -CIMGUI_API void igSetScrollY_Float(float scroll_y) { - return ImGui::SetScrollY(scroll_y); -} -CIMGUI_API float igGetScrollMaxX() { - return ImGui::GetScrollMaxX(); -} -CIMGUI_API float igGetScrollMaxY() { - return ImGui::GetScrollMaxY(); -} -CIMGUI_API void igSetScrollHereX(float center_x_ratio) { - return ImGui::SetScrollHereX(center_x_ratio); -} -CIMGUI_API void igSetScrollHereY(float center_y_ratio) { - return ImGui::SetScrollHereY(center_y_ratio); -} -CIMGUI_API void igSetScrollFromPosX_Float(float local_x, float center_x_ratio) { - return ImGui::SetScrollFromPosX(local_x, center_x_ratio); -} -CIMGUI_API void igSetScrollFromPosY_Float(float local_y, float center_y_ratio) { - return ImGui::SetScrollFromPosY(local_y, center_y_ratio); -} -CIMGUI_API void igPushFont(ImFont* font) { - return ImGui::PushFont(font); -} -CIMGUI_API void igPopFont() { - return ImGui::PopFont(); -} -CIMGUI_API void igPushStyleColor_U32(ImGuiCol idx, ImU32 col) { - return ImGui::PushStyleColor(idx, col); -} -CIMGUI_API void igPushStyleColor_Vec4(ImGuiCol idx, const ImVec4 col) { - return ImGui::PushStyleColor(idx, col); -} -CIMGUI_API void igPopStyleColor(int count) { - return ImGui::PopStyleColor(count); -} -CIMGUI_API void igPushStyleVar_Float(ImGuiStyleVar idx, float val) { - return ImGui::PushStyleVar(idx, val); -} -CIMGUI_API void igPushStyleVar_Vec2(ImGuiStyleVar idx, const ImVec2 val) { - return ImGui::PushStyleVar(idx, val); -} -CIMGUI_API void igPopStyleVar(int count) { - return ImGui::PopStyleVar(count); -} -CIMGUI_API void igPushTabStop(bool tab_stop) { - return ImGui::PushTabStop(tab_stop); -} -CIMGUI_API void igPopTabStop() { - return ImGui::PopTabStop(); -} -CIMGUI_API void igPushButtonRepeat(bool repeat) { - return ImGui::PushButtonRepeat(repeat); -} -CIMGUI_API void igPopButtonRepeat() { - return ImGui::PopButtonRepeat(); -} -CIMGUI_API void igPushItemWidth(float item_width) { - return ImGui::PushItemWidth(item_width); -} -CIMGUI_API void igPopItemWidth() { - return ImGui::PopItemWidth(); -} -CIMGUI_API void igSetNextItemWidth(float item_width) { - return ImGui::SetNextItemWidth(item_width); -} -CIMGUI_API float igCalcItemWidth() { - return ImGui::CalcItemWidth(); -} -CIMGUI_API void igPushTextWrapPos(float wrap_local_pos_x) { - return ImGui::PushTextWrapPos(wrap_local_pos_x); -} -CIMGUI_API void igPopTextWrapPos() { - return ImGui::PopTextWrapPos(); -} -CIMGUI_API ImFont* igGetFont() { - return ImGui::GetFont(); -} -CIMGUI_API float igGetFontSize() { - return ImGui::GetFontSize(); -} -CIMGUI_API void igGetFontTexUvWhitePixel(ImVec2* pOut) { - *pOut = ImGui::GetFontTexUvWhitePixel(); -} -CIMGUI_API ImU32 igGetColorU32_Col(ImGuiCol idx, float alpha_mul) { - return ImGui::GetColorU32(idx, alpha_mul); -} -CIMGUI_API ImU32 igGetColorU32_Vec4(const ImVec4 col) { - return ImGui::GetColorU32(col); -} -CIMGUI_API ImU32 igGetColorU32_U32(ImU32 col, float alpha_mul) { - return ImGui::GetColorU32(col, alpha_mul); -} -CIMGUI_API const ImVec4* igGetStyleColorVec4(ImGuiCol idx) { - return &ImGui::GetStyleColorVec4(idx); -} -CIMGUI_API void igGetCursorScreenPos(ImVec2* pOut) { - *pOut = ImGui::GetCursorScreenPos(); -} -CIMGUI_API void igSetCursorScreenPos(const ImVec2 pos) { - return ImGui::SetCursorScreenPos(pos); -} -CIMGUI_API void igGetCursorPos(ImVec2* pOut) { - *pOut = ImGui::GetCursorPos(); -} -CIMGUI_API float igGetCursorPosX() { - return ImGui::GetCursorPosX(); -} -CIMGUI_API float igGetCursorPosY() { - return ImGui::GetCursorPosY(); -} -CIMGUI_API void igSetCursorPos(const ImVec2 local_pos) { - return ImGui::SetCursorPos(local_pos); -} -CIMGUI_API void igSetCursorPosX(float local_x) { - return ImGui::SetCursorPosX(local_x); -} -CIMGUI_API void igSetCursorPosY(float local_y) { - return ImGui::SetCursorPosY(local_y); -} -CIMGUI_API void igGetCursorStartPos(ImVec2* pOut) { - *pOut = ImGui::GetCursorStartPos(); -} -CIMGUI_API void igSeparator() { - return ImGui::Separator(); -} -CIMGUI_API void igSameLine(float offset_from_start_x, float spacing) { - return ImGui::SameLine(offset_from_start_x, spacing); -} -CIMGUI_API void igNewLine() { - return ImGui::NewLine(); -} -CIMGUI_API void igSpacing() { - return ImGui::Spacing(); -} -CIMGUI_API void igDummy(const ImVec2 size) { - return ImGui::Dummy(size); -} -CIMGUI_API void igIndent(float indent_w) { - return ImGui::Indent(indent_w); -} -CIMGUI_API void igUnindent(float indent_w) { - return ImGui::Unindent(indent_w); -} -CIMGUI_API void igBeginGroup() { - return ImGui::BeginGroup(); -} -CIMGUI_API void igEndGroup() { - return ImGui::EndGroup(); -} -CIMGUI_API void igAlignTextToFramePadding() { - return ImGui::AlignTextToFramePadding(); -} -CIMGUI_API float igGetTextLineHeight() { - return ImGui::GetTextLineHeight(); -} -CIMGUI_API float igGetTextLineHeightWithSpacing() { - return ImGui::GetTextLineHeightWithSpacing(); -} -CIMGUI_API float igGetFrameHeight() { - return ImGui::GetFrameHeight(); -} -CIMGUI_API float igGetFrameHeightWithSpacing() { - return ImGui::GetFrameHeightWithSpacing(); -} -CIMGUI_API void igPushID_Str(const char* str_id) { - return ImGui::PushID(str_id); -} -CIMGUI_API void igPushID_StrStr(const char* str_id_begin, - const char* str_id_end) { - return ImGui::PushID(str_id_begin, str_id_end); -} -CIMGUI_API void igPushID_Ptr(const void* ptr_id) { - return ImGui::PushID(ptr_id); -} -CIMGUI_API void igPushID_Int(int int_id) { - return ImGui::PushID(int_id); -} -CIMGUI_API void igPopID() { - return ImGui::PopID(); -} -CIMGUI_API ImGuiID igGetID_Str(const char* str_id) { - return ImGui::GetID(str_id); -} -CIMGUI_API ImGuiID igGetID_StrStr(const char* str_id_begin, - const char* str_id_end) { - return ImGui::GetID(str_id_begin, str_id_end); -} -CIMGUI_API ImGuiID igGetID_Ptr(const void* ptr_id) { - return ImGui::GetID(ptr_id); -} -CIMGUI_API void igTextUnformatted(const char* text, const char* text_end) { - return ImGui::TextUnformatted(text, text_end); -} -CIMGUI_API void igText(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::TextV(fmt, args); - va_end(args); -} -CIMGUI_API void igTextV(const char* fmt, va_list args) { - return ImGui::TextV(fmt, args); -} -CIMGUI_API void igTextColored(const ImVec4 col, const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::TextColoredV(col, fmt, args); - va_end(args); -} -CIMGUI_API void igTextColoredV(const ImVec4 col, - const char* fmt, - va_list args) { - return ImGui::TextColoredV(col, fmt, args); -} -CIMGUI_API void igTextDisabled(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::TextDisabledV(fmt, args); - va_end(args); -} -CIMGUI_API void igTextDisabledV(const char* fmt, va_list args) { - return ImGui::TextDisabledV(fmt, args); -} -CIMGUI_API void igTextWrapped(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::TextWrappedV(fmt, args); - va_end(args); -} -CIMGUI_API void igTextWrappedV(const char* fmt, va_list args) { - return ImGui::TextWrappedV(fmt, args); -} -CIMGUI_API void igLabelText(const char* label, const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::LabelTextV(label, fmt, args); - va_end(args); -} -CIMGUI_API void igLabelTextV(const char* label, const char* fmt, va_list args) { - return ImGui::LabelTextV(label, fmt, args); -} -CIMGUI_API void igBulletText(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::BulletTextV(fmt, args); - va_end(args); -} -CIMGUI_API void igBulletTextV(const char* fmt, va_list args) { - return ImGui::BulletTextV(fmt, args); -} -CIMGUI_API void igSeparatorText(const char* label) { - return ImGui::SeparatorText(label); -} -CIMGUI_API bool igButton(const char* label, const ImVec2 size) { - return ImGui::Button(label, size); -} -CIMGUI_API bool igSmallButton(const char* label) { - return ImGui::SmallButton(label); -} -CIMGUI_API bool igInvisibleButton(const char* str_id, - const ImVec2 size, - ImGuiButtonFlags flags) { - return ImGui::InvisibleButton(str_id, size, flags); -} -CIMGUI_API bool igArrowButton(const char* str_id, ImGuiDir dir) { - return ImGui::ArrowButton(str_id, dir); -} -CIMGUI_API bool igCheckbox(const char* label, bool* v) { - return ImGui::Checkbox(label, v); -} -CIMGUI_API bool igCheckboxFlags_IntPtr(const char* label, - int* flags, - int flags_value) { - return ImGui::CheckboxFlags(label, flags, flags_value); -} -CIMGUI_API bool igCheckboxFlags_UintPtr(const char* label, - unsigned int* flags, - unsigned int flags_value) { - return ImGui::CheckboxFlags(label, flags, flags_value); -} -CIMGUI_API bool igRadioButton_Bool(const char* label, bool active) { - return ImGui::RadioButton(label, active); -} -CIMGUI_API bool igRadioButton_IntPtr(const char* label, int* v, int v_button) { - return ImGui::RadioButton(label, v, v_button); -} -CIMGUI_API void igProgressBar(float fraction, - const ImVec2 size_arg, - const char* overlay) { - return ImGui::ProgressBar(fraction, size_arg, overlay); -} -CIMGUI_API void igBullet() { - return ImGui::Bullet(); -} -CIMGUI_API void igImage(ImTextureID user_texture_id, - const ImVec2 image_size, - const ImVec2 uv0, - const ImVec2 uv1, - const ImVec4 tint_col, - const ImVec4 border_col) { - return ImGui::Image(user_texture_id, image_size, uv0, uv1, tint_col, - border_col); -} -CIMGUI_API bool igImageButton(const char* str_id, - ImTextureID user_texture_id, - const ImVec2 image_size, - const ImVec2 uv0, - const ImVec2 uv1, - const ImVec4 bg_col, - const ImVec4 tint_col) { - return ImGui::ImageButton(str_id, user_texture_id, image_size, uv0, uv1, - bg_col, tint_col); -} -CIMGUI_API bool igBeginCombo(const char* label, - const char* preview_value, - ImGuiComboFlags flags) { - return ImGui::BeginCombo(label, preview_value, flags); -} -CIMGUI_API void igEndCombo() { - return ImGui::EndCombo(); -} -CIMGUI_API bool igCombo_Str_arr(const char* label, - int* current_item, - const char* const items[], - int items_count, - int popup_max_height_in_items) { - return ImGui::Combo(label, current_item, items, items_count, - popup_max_height_in_items); -} -CIMGUI_API bool igCombo_Str(const char* label, - int* current_item, - const char* items_separated_by_zeros, - int popup_max_height_in_items) { - return ImGui::Combo(label, current_item, items_separated_by_zeros, - popup_max_height_in_items); -} -CIMGUI_API bool igCombo_FnStrPtr(const char* label, - int* current_item, - const char* (*getter)(void* user_data, - int idx), - void* user_data, - int items_count, - int popup_max_height_in_items) { - return ImGui::Combo(label, current_item, getter, user_data, items_count, - popup_max_height_in_items); -} -CIMGUI_API bool igDragFloat(const char* label, - float* v, - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragFloat(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragFloat2(const char* label, - float v[2], - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragFloat2(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragFloat3(const char* label, - float v[3], - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragFloat3(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragFloat4(const char* label, - float v[4], - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragFloat4(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragFloatRange2(const char* label, - float* v_current_min, - float* v_current_max, - float v_speed, - float v_min, - float v_max, - const char* format, - const char* format_max, - ImGuiSliderFlags flags) { - return ImGui::DragFloatRange2(label, v_current_min, v_current_max, v_speed, - v_min, v_max, format, format_max, flags); -} -CIMGUI_API bool igDragInt(const char* label, - int* v, - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragInt(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragInt2(const char* label, - int v[2], - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragInt2(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragInt3(const char* label, - int v[3], - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragInt3(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragInt4(const char* label, - int v[4], - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragInt4(label, v, v_speed, v_min, v_max, format, flags); -} -CIMGUI_API bool igDragIntRange2(const char* label, - int* v_current_min, - int* v_current_max, - float v_speed, - int v_min, - int v_max, - const char* format, - const char* format_max, - ImGuiSliderFlags flags) { - return ImGui::DragIntRange2(label, v_current_min, v_current_max, v_speed, - v_min, v_max, format, format_max, flags); -} -CIMGUI_API bool igDragScalar(const char* label, - ImGuiDataType data_type, - void* p_data, - float v_speed, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragScalar(label, data_type, p_data, v_speed, p_min, p_max, - format, flags); -} -CIMGUI_API bool igDragScalarN(const char* label, - ImGuiDataType data_type, - void* p_data, - int components, - float v_speed, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragScalarN(label, data_type, p_data, components, v_speed, - p_min, p_max, format, flags); -} -CIMGUI_API bool igSliderFloat(const char* label, - float* v, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderFloat(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderFloat2(const char* label, - float v[2], - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderFloat2(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderFloat3(const char* label, - float v[3], - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderFloat3(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderFloat4(const char* label, - float v[4], - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderFloat4(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderAngle(const char* label, - float* v_rad, - float v_degrees_min, - float v_degrees_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderAngle(label, v_rad, v_degrees_min, v_degrees_max, format, - flags); -} -CIMGUI_API bool igSliderInt(const char* label, - int* v, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderInt(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderInt2(const char* label, - int v[2], - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderInt2(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderInt3(const char* label, - int v[3], - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderInt3(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderInt4(const char* label, - int v[4], - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderInt4(label, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igSliderScalar(const char* label, - ImGuiDataType data_type, - void* p_data, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderScalar(label, data_type, p_data, p_min, p_max, format, - flags); -} -CIMGUI_API bool igSliderScalarN(const char* label, - ImGuiDataType data_type, - void* p_data, - int components, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::SliderScalarN(label, data_type, p_data, components, p_min, - p_max, format, flags); -} -CIMGUI_API bool igVSliderFloat(const char* label, - const ImVec2 size, - float* v, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::VSliderFloat(label, size, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igVSliderInt(const char* label, - const ImVec2 size, - int* v, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::VSliderInt(label, size, v, v_min, v_max, format, flags); -} -CIMGUI_API bool igVSliderScalar(const char* label, - const ImVec2 size, - ImGuiDataType data_type, - void* p_data, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::VSliderScalar(label, size, data_type, p_data, p_min, p_max, - format, flags); -} -CIMGUI_API bool igInputText(const char* label, - char* buf, - size_t buf_size, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data) { - return ImGui::InputText(label, buf, buf_size, flags, callback, user_data); -} -CIMGUI_API bool igInputTextMultiline(const char* label, - char* buf, - size_t buf_size, - const ImVec2 size, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data) { - return ImGui::InputTextMultiline(label, buf, buf_size, size, flags, callback, - user_data); -} -CIMGUI_API bool igInputTextWithHint(const char* label, - const char* hint, - char* buf, - size_t buf_size, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data) { - return ImGui::InputTextWithHint(label, hint, buf, buf_size, flags, callback, - user_data); -} -CIMGUI_API bool igInputFloat(const char* label, - float* v, - float step, - float step_fast, - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputFloat(label, v, step, step_fast, format, flags); -} -CIMGUI_API bool igInputFloat2(const char* label, - float v[2], - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputFloat2(label, v, format, flags); -} -CIMGUI_API bool igInputFloat3(const char* label, - float v[3], - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputFloat3(label, v, format, flags); -} -CIMGUI_API bool igInputFloat4(const char* label, - float v[4], - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputFloat4(label, v, format, flags); -} -CIMGUI_API bool igInputInt(const char* label, - int* v, - int step, - int step_fast, - ImGuiInputTextFlags flags) { - return ImGui::InputInt(label, v, step, step_fast, flags); -} -CIMGUI_API bool igInputInt2(const char* label, - int v[2], - ImGuiInputTextFlags flags) { - return ImGui::InputInt2(label, v, flags); -} -CIMGUI_API bool igInputInt3(const char* label, - int v[3], - ImGuiInputTextFlags flags) { - return ImGui::InputInt3(label, v, flags); -} -CIMGUI_API bool igInputInt4(const char* label, - int v[4], - ImGuiInputTextFlags flags) { - return ImGui::InputInt4(label, v, flags); -} -CIMGUI_API bool igInputDouble(const char* label, - double* v, - double step, - double step_fast, - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputDouble(label, v, step, step_fast, format, flags); -} -CIMGUI_API bool igInputScalar(const char* label, - ImGuiDataType data_type, - void* p_data, - const void* p_step, - const void* p_step_fast, - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputScalar(label, data_type, p_data, p_step, p_step_fast, - format, flags); -} -CIMGUI_API bool igInputScalarN(const char* label, - ImGuiDataType data_type, - void* p_data, - int components, - const void* p_step, - const void* p_step_fast, - const char* format, - ImGuiInputTextFlags flags) { - return ImGui::InputScalarN(label, data_type, p_data, components, p_step, - p_step_fast, format, flags); -} -CIMGUI_API bool igColorEdit3(const char* label, - float col[3], - ImGuiColorEditFlags flags) { - return ImGui::ColorEdit3(label, col, flags); -} -CIMGUI_API bool igColorEdit4(const char* label, - float col[4], - ImGuiColorEditFlags flags) { - return ImGui::ColorEdit4(label, col, flags); -} -CIMGUI_API bool igColorPicker3(const char* label, - float col[3], - ImGuiColorEditFlags flags) { - return ImGui::ColorPicker3(label, col, flags); -} -CIMGUI_API bool igColorPicker4(const char* label, - float col[4], - ImGuiColorEditFlags flags, - const float* ref_col) { - return ImGui::ColorPicker4(label, col, flags, ref_col); -} -CIMGUI_API bool igColorButton(const char* desc_id, - const ImVec4 col, - ImGuiColorEditFlags flags, - const ImVec2 size) { - return ImGui::ColorButton(desc_id, col, flags, size); -} -CIMGUI_API void igSetColorEditOptions(ImGuiColorEditFlags flags) { - return ImGui::SetColorEditOptions(flags); -} -CIMGUI_API bool igTreeNode_Str(const char* label) { - return ImGui::TreeNode(label); -} -CIMGUI_API bool igTreeNode_StrStr(const char* str_id, const char* fmt, ...) { - va_list args; - va_start(args, fmt); - bool ret = ImGui::TreeNodeV(str_id, fmt, args); - va_end(args); - return ret; -} -CIMGUI_API bool igTreeNode_Ptr(const void* ptr_id, const char* fmt, ...) { - va_list args; - va_start(args, fmt); - bool ret = ImGui::TreeNodeV(ptr_id, fmt, args); - va_end(args); - return ret; -} -CIMGUI_API bool igTreeNodeV_Str(const char* str_id, - const char* fmt, - va_list args) { - return ImGui::TreeNodeV(str_id, fmt, args); -} -CIMGUI_API bool igTreeNodeV_Ptr(const void* ptr_id, - const char* fmt, - va_list args) { - return ImGui::TreeNodeV(ptr_id, fmt, args); -} -CIMGUI_API bool igTreeNodeEx_Str(const char* label, ImGuiTreeNodeFlags flags) { - return ImGui::TreeNodeEx(label, flags); -} -CIMGUI_API bool igTreeNodeEx_StrStr(const char* str_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - ...) { - va_list args; - va_start(args, fmt); - bool ret = ImGui::TreeNodeExV(str_id, flags, fmt, args); - va_end(args); - return ret; -} -CIMGUI_API bool igTreeNodeEx_Ptr(const void* ptr_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - ...) { - va_list args; - va_start(args, fmt); - bool ret = ImGui::TreeNodeExV(ptr_id, flags, fmt, args); - va_end(args); - return ret; -} -CIMGUI_API bool igTreeNodeExV_Str(const char* str_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - va_list args) { - return ImGui::TreeNodeExV(str_id, flags, fmt, args); -} -CIMGUI_API bool igTreeNodeExV_Ptr(const void* ptr_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - va_list args) { - return ImGui::TreeNodeExV(ptr_id, flags, fmt, args); -} -CIMGUI_API void igTreePush_Str(const char* str_id) { - return ImGui::TreePush(str_id); -} -CIMGUI_API void igTreePush_Ptr(const void* ptr_id) { - return ImGui::TreePush(ptr_id); -} -CIMGUI_API void igTreePop() { - return ImGui::TreePop(); -} -CIMGUI_API float igGetTreeNodeToLabelSpacing() { - return ImGui::GetTreeNodeToLabelSpacing(); -} -CIMGUI_API bool igCollapsingHeader_TreeNodeFlags(const char* label, - ImGuiTreeNodeFlags flags) { - return ImGui::CollapsingHeader(label, flags); -} -CIMGUI_API bool igCollapsingHeader_BoolPtr(const char* label, - bool* p_visible, - ImGuiTreeNodeFlags flags) { - return ImGui::CollapsingHeader(label, p_visible, flags); -} -CIMGUI_API void igSetNextItemOpen(bool is_open, ImGuiCond cond) { - return ImGui::SetNextItemOpen(is_open, cond); -} -CIMGUI_API bool igSelectable_Bool(const char* label, - bool selected, - ImGuiSelectableFlags flags, - const ImVec2 size) { - return ImGui::Selectable(label, selected, flags, size); -} -CIMGUI_API bool igSelectable_BoolPtr(const char* label, - bool* p_selected, - ImGuiSelectableFlags flags, - const ImVec2 size) { - return ImGui::Selectable(label, p_selected, flags, size); -} -CIMGUI_API bool igBeginListBox(const char* label, const ImVec2 size) { - return ImGui::BeginListBox(label, size); -} -CIMGUI_API void igEndListBox() { - return ImGui::EndListBox(); -} -CIMGUI_API bool igListBox_Str_arr(const char* label, - int* current_item, - const char* const items[], - int items_count, - int height_in_items) { - return ImGui::ListBox(label, current_item, items, items_count, - height_in_items); -} -CIMGUI_API bool igListBox_FnStrPtr(const char* label, - int* current_item, - const char* (*getter)(void* user_data, - int idx), - void* user_data, - int items_count, - int height_in_items) { - return ImGui::ListBox(label, current_item, getter, user_data, items_count, - height_in_items); -} -CIMGUI_API void igPlotLines_FloatPtr(const char* label, - const float* values, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size, - int stride) { - return ImGui::PlotLines(label, values, values_count, values_offset, - overlay_text, scale_min, scale_max, graph_size, - stride); -} -CIMGUI_API void igPlotLines_FnFloatPtr(const char* label, - float (*values_getter)(void* data, - int idx), - void* data, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size) { - return ImGui::PlotLines(label, values_getter, data, values_count, - values_offset, overlay_text, scale_min, scale_max, - graph_size); -} -CIMGUI_API void igPlotHistogram_FloatPtr(const char* label, - const float* values, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size, - int stride) { - return ImGui::PlotHistogram(label, values, values_count, values_offset, - overlay_text, scale_min, scale_max, graph_size, - stride); -} -CIMGUI_API void igPlotHistogram_FnFloatPtr(const char* label, - float (*values_getter)(void* data, - int idx), - void* data, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size) { - return ImGui::PlotHistogram(label, values_getter, data, values_count, - values_offset, overlay_text, scale_min, scale_max, - graph_size); -} -CIMGUI_API void igValue_Bool(const char* prefix, bool b) { - return ImGui::Value(prefix, b); -} -CIMGUI_API void igValue_Int(const char* prefix, int v) { - return ImGui::Value(prefix, v); -} -CIMGUI_API void igValue_Uint(const char* prefix, unsigned int v) { - return ImGui::Value(prefix, v); -} -CIMGUI_API void igValue_Float(const char* prefix, - float v, - const char* float_format) { - return ImGui::Value(prefix, v, float_format); -} -CIMGUI_API bool igBeginMenuBar() { - return ImGui::BeginMenuBar(); -} -CIMGUI_API void igEndMenuBar() { - return ImGui::EndMenuBar(); -} -CIMGUI_API bool igBeginMainMenuBar() { - return ImGui::BeginMainMenuBar(); -} -CIMGUI_API void igEndMainMenuBar() { - return ImGui::EndMainMenuBar(); -} -CIMGUI_API bool igBeginMenu(const char* label, bool enabled) { - return ImGui::BeginMenu(label, enabled); -} -CIMGUI_API void igEndMenu() { - return ImGui::EndMenu(); -} -CIMGUI_API bool igMenuItem_Bool(const char* label, - const char* shortcut, - bool selected, - bool enabled) { - return ImGui::MenuItem(label, shortcut, selected, enabled); -} -CIMGUI_API bool igMenuItem_BoolPtr(const char* label, - const char* shortcut, - bool* p_selected, - bool enabled) { - return ImGui::MenuItem(label, shortcut, p_selected, enabled); -} -CIMGUI_API bool igBeginTooltip() { - return ImGui::BeginTooltip(); -} -CIMGUI_API void igEndTooltip() { - return ImGui::EndTooltip(); -} -CIMGUI_API void igSetTooltip(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::SetTooltipV(fmt, args); - va_end(args); -} -CIMGUI_API void igSetTooltipV(const char* fmt, va_list args) { - return ImGui::SetTooltipV(fmt, args); -} -CIMGUI_API bool igBeginItemTooltip() { - return ImGui::BeginItemTooltip(); -} -CIMGUI_API void igSetItemTooltip(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::SetItemTooltipV(fmt, args); - va_end(args); -} -CIMGUI_API void igSetItemTooltipV(const char* fmt, va_list args) { - return ImGui::SetItemTooltipV(fmt, args); -} -CIMGUI_API bool igBeginPopup(const char* str_id, ImGuiWindowFlags flags) { - return ImGui::BeginPopup(str_id, flags); -} -CIMGUI_API bool igBeginPopupModal(const char* name, - bool* p_open, - ImGuiWindowFlags flags) { - return ImGui::BeginPopupModal(name, p_open, flags); -} -CIMGUI_API void igEndPopup() { - return ImGui::EndPopup(); -} -CIMGUI_API void igOpenPopup_Str(const char* str_id, - ImGuiPopupFlags popup_flags) { - return ImGui::OpenPopup(str_id, popup_flags); -} -CIMGUI_API void igOpenPopup_ID(ImGuiID id, ImGuiPopupFlags popup_flags) { - return ImGui::OpenPopup(id, popup_flags); -} -CIMGUI_API void igOpenPopupOnItemClick(const char* str_id, - ImGuiPopupFlags popup_flags) { - return ImGui::OpenPopupOnItemClick(str_id, popup_flags); -} -CIMGUI_API void igCloseCurrentPopup() { - return ImGui::CloseCurrentPopup(); -} -CIMGUI_API bool igBeginPopupContextItem(const char* str_id, - ImGuiPopupFlags popup_flags) { - return ImGui::BeginPopupContextItem(str_id, popup_flags); -} -CIMGUI_API bool igBeginPopupContextWindow(const char* str_id, - ImGuiPopupFlags popup_flags) { - return ImGui::BeginPopupContextWindow(str_id, popup_flags); -} -CIMGUI_API bool igBeginPopupContextVoid(const char* str_id, - ImGuiPopupFlags popup_flags) { - return ImGui::BeginPopupContextVoid(str_id, popup_flags); -} -CIMGUI_API bool igIsPopupOpen_Str(const char* str_id, ImGuiPopupFlags flags) { - return ImGui::IsPopupOpen(str_id, flags); -} -CIMGUI_API bool igBeginTable(const char* str_id, - int column, - ImGuiTableFlags flags, - const ImVec2 outer_size, - float inner_width) { - return ImGui::BeginTable(str_id, column, flags, outer_size, inner_width); -} -CIMGUI_API void igEndTable() { - return ImGui::EndTable(); -} -CIMGUI_API void igTableNextRow(ImGuiTableRowFlags row_flags, - float min_row_height) { - return ImGui::TableNextRow(row_flags, min_row_height); -} -CIMGUI_API bool igTableNextColumn() { - return ImGui::TableNextColumn(); -} -CIMGUI_API bool igTableSetColumnIndex(int column_n) { - return ImGui::TableSetColumnIndex(column_n); -} -CIMGUI_API void igTableSetupColumn(const char* label, - ImGuiTableColumnFlags flags, - float init_width_or_weight, - ImGuiID user_id) { - return ImGui::TableSetupColumn(label, flags, init_width_or_weight, user_id); -} -CIMGUI_API void igTableSetupScrollFreeze(int cols, int rows) { - return ImGui::TableSetupScrollFreeze(cols, rows); -} -CIMGUI_API void igTableHeader(const char* label) { - return ImGui::TableHeader(label); -} -CIMGUI_API void igTableHeadersRow() { - return ImGui::TableHeadersRow(); -} -CIMGUI_API void igTableAngledHeadersRow() { - return ImGui::TableAngledHeadersRow(); -} -CIMGUI_API ImGuiTableSortSpecs* igTableGetSortSpecs() { - return ImGui::TableGetSortSpecs(); -} -CIMGUI_API int igTableGetColumnCount() { - return ImGui::TableGetColumnCount(); -} -CIMGUI_API int igTableGetColumnIndex() { - return ImGui::TableGetColumnIndex(); -} -CIMGUI_API int igTableGetRowIndex() { - return ImGui::TableGetRowIndex(); -} -CIMGUI_API const char* igTableGetColumnName_Int(int column_n) { - return ImGui::TableGetColumnName(column_n); -} -CIMGUI_API ImGuiTableColumnFlags igTableGetColumnFlags(int column_n) { - return ImGui::TableGetColumnFlags(column_n); -} -CIMGUI_API void igTableSetColumnEnabled(int column_n, bool v) { - return ImGui::TableSetColumnEnabled(column_n, v); -} -CIMGUI_API void igTableSetBgColor(ImGuiTableBgTarget target, - ImU32 color, - int column_n) { - return ImGui::TableSetBgColor(target, color, column_n); -} -CIMGUI_API void igColumns(int count, const char* id, bool border) { - return ImGui::Columns(count, id, border); -} -CIMGUI_API void igNextColumn() { - return ImGui::NextColumn(); -} -CIMGUI_API int igGetColumnIndex() { - return ImGui::GetColumnIndex(); -} -CIMGUI_API float igGetColumnWidth(int column_index) { - return ImGui::GetColumnWidth(column_index); -} -CIMGUI_API void igSetColumnWidth(int column_index, float width) { - return ImGui::SetColumnWidth(column_index, width); -} -CIMGUI_API float igGetColumnOffset(int column_index) { - return ImGui::GetColumnOffset(column_index); -} -CIMGUI_API void igSetColumnOffset(int column_index, float offset_x) { - return ImGui::SetColumnOffset(column_index, offset_x); -} -CIMGUI_API int igGetColumnsCount() { - return ImGui::GetColumnsCount(); -} -CIMGUI_API bool igBeginTabBar(const char* str_id, ImGuiTabBarFlags flags) { - return ImGui::BeginTabBar(str_id, flags); -} -CIMGUI_API void igEndTabBar() { - return ImGui::EndTabBar(); -} -CIMGUI_API bool igBeginTabItem(const char* label, - bool* p_open, - ImGuiTabItemFlags flags) { - return ImGui::BeginTabItem(label, p_open, flags); -} -CIMGUI_API void igEndTabItem() { - return ImGui::EndTabItem(); -} -CIMGUI_API bool igTabItemButton(const char* label, ImGuiTabItemFlags flags) { - return ImGui::TabItemButton(label, flags); -} -CIMGUI_API void igSetTabItemClosed(const char* tab_or_docked_window_label) { - return ImGui::SetTabItemClosed(tab_or_docked_window_label); -} -CIMGUI_API ImGuiID igDockSpace(ImGuiID id, - const ImVec2 size, - ImGuiDockNodeFlags flags, - const ImGuiWindowClass* window_class) { - return ImGui::DockSpace(id, size, flags, window_class); -} -CIMGUI_API ImGuiID -igDockSpaceOverViewport(const ImGuiViewport* viewport, - ImGuiDockNodeFlags flags, - const ImGuiWindowClass* window_class) { - return ImGui::DockSpaceOverViewport(viewport, flags, window_class); -} -CIMGUI_API void igSetNextWindowDockID(ImGuiID dock_id, ImGuiCond cond) { - return ImGui::SetNextWindowDockID(dock_id, cond); -} -CIMGUI_API void igSetNextWindowClass(const ImGuiWindowClass* window_class) { - return ImGui::SetNextWindowClass(window_class); -} -CIMGUI_API ImGuiID igGetWindowDockID() { - return ImGui::GetWindowDockID(); -} -CIMGUI_API bool igIsWindowDocked() { - return ImGui::IsWindowDocked(); -} -CIMGUI_API void igLogToTTY(int auto_open_depth) { - return ImGui::LogToTTY(auto_open_depth); -} -CIMGUI_API void igLogToFile(int auto_open_depth, const char* filename) { - return ImGui::LogToFile(auto_open_depth, filename); -} -CIMGUI_API void igLogToClipboard(int auto_open_depth) { - return ImGui::LogToClipboard(auto_open_depth); -} -CIMGUI_API void igLogFinish() { - return ImGui::LogFinish(); -} -CIMGUI_API void igLogButtons() { - return ImGui::LogButtons(); -} -CIMGUI_API void igLogTextV(const char* fmt, va_list args) { - return ImGui::LogTextV(fmt, args); -} -CIMGUI_API bool igBeginDragDropSource(ImGuiDragDropFlags flags) { - return ImGui::BeginDragDropSource(flags); -} -CIMGUI_API bool igSetDragDropPayload(const char* type, - const void* data, - size_t sz, - ImGuiCond cond) { - return ImGui::SetDragDropPayload(type, data, sz, cond); -} -CIMGUI_API void igEndDragDropSource() { - return ImGui::EndDragDropSource(); -} -CIMGUI_API bool igBeginDragDropTarget() { - return ImGui::BeginDragDropTarget(); -} -CIMGUI_API const ImGuiPayload* igAcceptDragDropPayload( - const char* type, - ImGuiDragDropFlags flags) { - return ImGui::AcceptDragDropPayload(type, flags); -} -CIMGUI_API void igEndDragDropTarget() { - return ImGui::EndDragDropTarget(); -} -CIMGUI_API const ImGuiPayload* igGetDragDropPayload() { - return ImGui::GetDragDropPayload(); -} -CIMGUI_API void igBeginDisabled(bool disabled) { - return ImGui::BeginDisabled(disabled); -} -CIMGUI_API void igEndDisabled() { - return ImGui::EndDisabled(); -} -CIMGUI_API void igPushClipRect(const ImVec2 clip_rect_min, - const ImVec2 clip_rect_max, - bool intersect_with_current_clip_rect) { - return ImGui::PushClipRect(clip_rect_min, clip_rect_max, - intersect_with_current_clip_rect); -} -CIMGUI_API void igPopClipRect() { - return ImGui::PopClipRect(); -} -CIMGUI_API void igSetItemDefaultFocus() { - return ImGui::SetItemDefaultFocus(); -} -CIMGUI_API void igSetKeyboardFocusHere(int offset) { - return ImGui::SetKeyboardFocusHere(offset); -} -CIMGUI_API void igSetNextItemAllowOverlap() { - return ImGui::SetNextItemAllowOverlap(); -} -CIMGUI_API bool igIsItemHovered(ImGuiHoveredFlags flags) { - return ImGui::IsItemHovered(flags); -} -CIMGUI_API bool igIsItemActive() { - return ImGui::IsItemActive(); -} -CIMGUI_API bool igIsItemFocused() { - return ImGui::IsItemFocused(); -} -CIMGUI_API bool igIsItemClicked(ImGuiMouseButton mouse_button) { - return ImGui::IsItemClicked(mouse_button); -} -CIMGUI_API bool igIsItemVisible() { - return ImGui::IsItemVisible(); -} -CIMGUI_API bool igIsItemEdited() { - return ImGui::IsItemEdited(); -} -CIMGUI_API bool igIsItemActivated() { - return ImGui::IsItemActivated(); -} -CIMGUI_API bool igIsItemDeactivated() { - return ImGui::IsItemDeactivated(); -} -CIMGUI_API bool igIsItemDeactivatedAfterEdit() { - return ImGui::IsItemDeactivatedAfterEdit(); -} -CIMGUI_API bool igIsItemToggledOpen() { - return ImGui::IsItemToggledOpen(); -} -CIMGUI_API bool igIsAnyItemHovered() { - return ImGui::IsAnyItemHovered(); -} -CIMGUI_API bool igIsAnyItemActive() { - return ImGui::IsAnyItemActive(); -} -CIMGUI_API bool igIsAnyItemFocused() { - return ImGui::IsAnyItemFocused(); -} -CIMGUI_API ImGuiID igGetItemID() { - return ImGui::GetItemID(); -} -CIMGUI_API void igGetItemRectMin(ImVec2* pOut) { - *pOut = ImGui::GetItemRectMin(); -} -CIMGUI_API void igGetItemRectMax(ImVec2* pOut) { - *pOut = ImGui::GetItemRectMax(); -} -CIMGUI_API void igGetItemRectSize(ImVec2* pOut) { - *pOut = ImGui::GetItemRectSize(); -} -CIMGUI_API ImGuiViewport* igGetMainViewport() { - return ImGui::GetMainViewport(); -} -CIMGUI_API ImDrawList* igGetBackgroundDrawList_Nil() { - return ImGui::GetBackgroundDrawList(); -} -CIMGUI_API ImDrawList* igGetForegroundDrawList_Nil() { - return ImGui::GetForegroundDrawList(); -} -CIMGUI_API ImDrawList* igGetBackgroundDrawList_ViewportPtr( - ImGuiViewport* viewport) { - return ImGui::GetBackgroundDrawList(viewport); -} -CIMGUI_API ImDrawList* igGetForegroundDrawList_ViewportPtr( - ImGuiViewport* viewport) { - return ImGui::GetForegroundDrawList(viewport); -} -CIMGUI_API bool igIsRectVisible_Nil(const ImVec2 size) { - return ImGui::IsRectVisible(size); -} -CIMGUI_API bool igIsRectVisible_Vec2(const ImVec2 rect_min, - const ImVec2 rect_max) { - return ImGui::IsRectVisible(rect_min, rect_max); -} -CIMGUI_API double igGetTime() { - return ImGui::GetTime(); -} -CIMGUI_API int igGetFrameCount() { - return ImGui::GetFrameCount(); -} -CIMGUI_API ImDrawListSharedData* igGetDrawListSharedData() { - return ImGui::GetDrawListSharedData(); -} -CIMGUI_API const char* igGetStyleColorName(ImGuiCol idx) { - return ImGui::GetStyleColorName(idx); -} -CIMGUI_API void igSetStateStorage(ImGuiStorage* storage) { - return ImGui::SetStateStorage(storage); -} -CIMGUI_API ImGuiStorage* igGetStateStorage() { - return ImGui::GetStateStorage(); -} -CIMGUI_API void igCalcTextSize(ImVec2* pOut, - const char* text, - const char* text_end, - bool hide_text_after_double_hash, - float wrap_width) { - *pOut = ImGui::CalcTextSize(text, text_end, hide_text_after_double_hash, - wrap_width); -} -CIMGUI_API void igColorConvertU32ToFloat4(ImVec4* pOut, ImU32 in) { - *pOut = ImGui::ColorConvertU32ToFloat4(in); -} -CIMGUI_API ImU32 igColorConvertFloat4ToU32(const ImVec4 in) { - return ImGui::ColorConvertFloat4ToU32(in); -} -CIMGUI_API void igColorConvertRGBtoHSV(float r, - float g, - float b, - float* out_h, - float* out_s, - float* out_v) { - return ImGui::ColorConvertRGBtoHSV(r, g, b, *out_h, *out_s, *out_v); -} -CIMGUI_API void igColorConvertHSVtoRGB(float h, - float s, - float v, - float* out_r, - float* out_g, - float* out_b) { - return ImGui::ColorConvertHSVtoRGB(h, s, v, *out_r, *out_g, *out_b); -} -CIMGUI_API bool igIsKeyDown_Nil(ImGuiKey key) { - return ImGui::IsKeyDown(key); -} -CIMGUI_API bool igIsKeyPressed_Bool(ImGuiKey key, bool repeat) { - return ImGui::IsKeyPressed(key, repeat); -} -CIMGUI_API bool igIsKeyReleased_Nil(ImGuiKey key) { - return ImGui::IsKeyReleased(key); -} -CIMGUI_API bool igIsKeyChordPressed_Nil(ImGuiKeyChord key_chord) { - return ImGui::IsKeyChordPressed(key_chord); -} -CIMGUI_API int igGetKeyPressedAmount(ImGuiKey key, - float repeat_delay, - float rate) { - return ImGui::GetKeyPressedAmount(key, repeat_delay, rate); -} -CIMGUI_API const char* igGetKeyName(ImGuiKey key) { - return ImGui::GetKeyName(key); -} -CIMGUI_API void igSetNextFrameWantCaptureKeyboard(bool want_capture_keyboard) { - return ImGui::SetNextFrameWantCaptureKeyboard(want_capture_keyboard); -} -CIMGUI_API bool igIsMouseDown_Nil(ImGuiMouseButton button) { - return ImGui::IsMouseDown(button); -} -CIMGUI_API bool igIsMouseClicked_Bool(ImGuiMouseButton button, bool repeat) { - return ImGui::IsMouseClicked(button, repeat); -} -CIMGUI_API bool igIsMouseReleased_Nil(ImGuiMouseButton button) { - return ImGui::IsMouseReleased(button); -} -CIMGUI_API bool igIsMouseDoubleClicked_Nil(ImGuiMouseButton button) { - return ImGui::IsMouseDoubleClicked(button); -} -CIMGUI_API int igGetMouseClickedCount(ImGuiMouseButton button) { - return ImGui::GetMouseClickedCount(button); -} -CIMGUI_API bool igIsMouseHoveringRect(const ImVec2 r_min, - const ImVec2 r_max, - bool clip) { - return ImGui::IsMouseHoveringRect(r_min, r_max, clip); -} -CIMGUI_API bool igIsMousePosValid(const ImVec2* mouse_pos) { - return ImGui::IsMousePosValid(mouse_pos); -} -CIMGUI_API bool igIsAnyMouseDown() { - return ImGui::IsAnyMouseDown(); -} -CIMGUI_API void igGetMousePos(ImVec2* pOut) { - *pOut = ImGui::GetMousePos(); -} -CIMGUI_API void igGetMousePosOnOpeningCurrentPopup(ImVec2* pOut) { - *pOut = ImGui::GetMousePosOnOpeningCurrentPopup(); -} -CIMGUI_API bool igIsMouseDragging(ImGuiMouseButton button, - float lock_threshold) { - return ImGui::IsMouseDragging(button, lock_threshold); -} -CIMGUI_API void igGetMouseDragDelta(ImVec2* pOut, - ImGuiMouseButton button, - float lock_threshold) { - *pOut = ImGui::GetMouseDragDelta(button, lock_threshold); -} -CIMGUI_API void igResetMouseDragDelta(ImGuiMouseButton button) { - return ImGui::ResetMouseDragDelta(button); -} -CIMGUI_API ImGuiMouseCursor igGetMouseCursor() { - return ImGui::GetMouseCursor(); -} -CIMGUI_API void igSetMouseCursor(ImGuiMouseCursor cursor_type) { - return ImGui::SetMouseCursor(cursor_type); -} -CIMGUI_API void igSetNextFrameWantCaptureMouse(bool want_capture_mouse) { - return ImGui::SetNextFrameWantCaptureMouse(want_capture_mouse); -} -CIMGUI_API const char* igGetClipboardText() { - return ImGui::GetClipboardText(); -} -CIMGUI_API void igSetClipboardText(const char* text) { - return ImGui::SetClipboardText(text); -} -CIMGUI_API void igLoadIniSettingsFromDisk(const char* ini_filename) { - return ImGui::LoadIniSettingsFromDisk(ini_filename); -} -CIMGUI_API void igLoadIniSettingsFromMemory(const char* ini_data, - size_t ini_size) { - return ImGui::LoadIniSettingsFromMemory(ini_data, ini_size); -} -CIMGUI_API void igSaveIniSettingsToDisk(const char* ini_filename) { - return ImGui::SaveIniSettingsToDisk(ini_filename); -} -CIMGUI_API const char* igSaveIniSettingsToMemory(size_t* out_ini_size) { - return ImGui::SaveIniSettingsToMemory(out_ini_size); -} -CIMGUI_API void igDebugTextEncoding(const char* text) { - return ImGui::DebugTextEncoding(text); -} -CIMGUI_API void igDebugFlashStyleColor(ImGuiCol idx) { - return ImGui::DebugFlashStyleColor(idx); -} -CIMGUI_API void igDebugStartItemPicker() { - return ImGui::DebugStartItemPicker(); -} -CIMGUI_API bool igDebugCheckVersionAndDataLayout(const char* version_str, - size_t sz_io, - size_t sz_style, - size_t sz_vec2, - size_t sz_vec4, - size_t sz_drawvert, - size_t sz_drawidx) { - return ImGui::DebugCheckVersionAndDataLayout( - version_str, sz_io, sz_style, sz_vec2, sz_vec4, sz_drawvert, sz_drawidx); -} -CIMGUI_API void igSetAllocatorFunctions(ImGuiMemAllocFunc alloc_func, - ImGuiMemFreeFunc free_func, - void* user_data) { - return ImGui::SetAllocatorFunctions(alloc_func, free_func, user_data); -} -CIMGUI_API void igGetAllocatorFunctions(ImGuiMemAllocFunc* p_alloc_func, - ImGuiMemFreeFunc* p_free_func, - void** p_user_data) { - return ImGui::GetAllocatorFunctions(p_alloc_func, p_free_func, p_user_data); -} -CIMGUI_API void* igMemAlloc(size_t size) { - return ImGui::MemAlloc(size); -} -CIMGUI_API void igMemFree(void* ptr) { - return ImGui::MemFree(ptr); -} -CIMGUI_API ImGuiPlatformIO* igGetPlatformIO() { - return &ImGui::GetPlatformIO(); -} -CIMGUI_API void igUpdatePlatformWindows() { - return ImGui::UpdatePlatformWindows(); -} -CIMGUI_API void igRenderPlatformWindowsDefault(void* platform_render_arg, - void* renderer_render_arg) { - return ImGui::RenderPlatformWindowsDefault(platform_render_arg, - renderer_render_arg); -} -CIMGUI_API void igDestroyPlatformWindows() { - return ImGui::DestroyPlatformWindows(); -} -CIMGUI_API ImGuiViewport* igFindViewportByID(ImGuiID id) { - return ImGui::FindViewportByID(id); -} -CIMGUI_API ImGuiViewport* igFindViewportByPlatformHandle( - void* platform_handle) { - return ImGui::FindViewportByPlatformHandle(platform_handle); -} -CIMGUI_API ImGuiTableSortSpecs* ImGuiTableSortSpecs_ImGuiTableSortSpecs(void) { - return IM_NEW(ImGuiTableSortSpecs)(); -} -CIMGUI_API void ImGuiTableSortSpecs_destroy(ImGuiTableSortSpecs* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableColumnSortSpecs* -ImGuiTableColumnSortSpecs_ImGuiTableColumnSortSpecs(void) { - return IM_NEW(ImGuiTableColumnSortSpecs)(); -} -CIMGUI_API void ImGuiTableColumnSortSpecs_destroy( - ImGuiTableColumnSortSpecs* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiStyle* ImGuiStyle_ImGuiStyle(void) { - return IM_NEW(ImGuiStyle)(); -} -CIMGUI_API void ImGuiStyle_destroy(ImGuiStyle* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiStyle_ScaleAllSizes(ImGuiStyle* self, float scale_factor) { - return self->ScaleAllSizes(scale_factor); -} -CIMGUI_API void ImGuiIO_AddKeyEvent(ImGuiIO* self, ImGuiKey key, bool down) { - return self->AddKeyEvent(key, down); -} -CIMGUI_API void ImGuiIO_AddKeyAnalogEvent(ImGuiIO* self, - ImGuiKey key, - bool down, - float v) { - return self->AddKeyAnalogEvent(key, down, v); -} -CIMGUI_API void ImGuiIO_AddMousePosEvent(ImGuiIO* self, float x, float y) { - return self->AddMousePosEvent(x, y); -} -CIMGUI_API void ImGuiIO_AddMouseButtonEvent(ImGuiIO* self, - int button, - bool down) { - return self->AddMouseButtonEvent(button, down); -} -CIMGUI_API void ImGuiIO_AddMouseWheelEvent(ImGuiIO* self, - float wheel_x, - float wheel_y) { - return self->AddMouseWheelEvent(wheel_x, wheel_y); -} -CIMGUI_API void ImGuiIO_AddMouseSourceEvent(ImGuiIO* self, - ImGuiMouseSource source) { - return self->AddMouseSourceEvent(source); -} -CIMGUI_API void ImGuiIO_AddMouseViewportEvent(ImGuiIO* self, ImGuiID id) { - return self->AddMouseViewportEvent(id); -} -CIMGUI_API void ImGuiIO_AddFocusEvent(ImGuiIO* self, bool focused) { - return self->AddFocusEvent(focused); -} -CIMGUI_API void ImGuiIO_AddInputCharacter(ImGuiIO* self, unsigned int c) { - return self->AddInputCharacter(c); -} -CIMGUI_API void ImGuiIO_AddInputCharacterUTF16(ImGuiIO* self, ImWchar16 c) { - return self->AddInputCharacterUTF16(c); -} -CIMGUI_API void ImGuiIO_AddInputCharactersUTF8(ImGuiIO* self, const char* str) { - return self->AddInputCharactersUTF8(str); -} -CIMGUI_API void ImGuiIO_SetKeyEventNativeData(ImGuiIO* self, - ImGuiKey key, - int native_keycode, - int native_scancode, - int native_legacy_index) { - return self->SetKeyEventNativeData(key, native_keycode, native_scancode, - native_legacy_index); -} -CIMGUI_API void ImGuiIO_SetAppAcceptingEvents(ImGuiIO* self, - bool accepting_events) { - return self->SetAppAcceptingEvents(accepting_events); -} -CIMGUI_API void ImGuiIO_ClearEventsQueue(ImGuiIO* self) { - return self->ClearEventsQueue(); -} -CIMGUI_API void ImGuiIO_ClearInputKeys(ImGuiIO* self) { - return self->ClearInputKeys(); -} -CIMGUI_API ImGuiIO* ImGuiIO_ImGuiIO(void) { - return IM_NEW(ImGuiIO)(); -} -CIMGUI_API void ImGuiIO_destroy(ImGuiIO* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiInputTextCallbackData* -ImGuiInputTextCallbackData_ImGuiInputTextCallbackData(void) { - return IM_NEW(ImGuiInputTextCallbackData)(); -} -CIMGUI_API void ImGuiInputTextCallbackData_destroy( - ImGuiInputTextCallbackData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiInputTextCallbackData_DeleteChars( - ImGuiInputTextCallbackData* self, - int pos, - int bytes_count) { - return self->DeleteChars(pos, bytes_count); -} -CIMGUI_API void ImGuiInputTextCallbackData_InsertChars( - ImGuiInputTextCallbackData* self, - int pos, - const char* text, - const char* text_end) { - return self->InsertChars(pos, text, text_end); -} -CIMGUI_API void ImGuiInputTextCallbackData_SelectAll( - ImGuiInputTextCallbackData* self) { - return self->SelectAll(); -} -CIMGUI_API void ImGuiInputTextCallbackData_ClearSelection( - ImGuiInputTextCallbackData* self) { - return self->ClearSelection(); -} -CIMGUI_API bool ImGuiInputTextCallbackData_HasSelection( - ImGuiInputTextCallbackData* self) { - return self->HasSelection(); -} -CIMGUI_API ImGuiWindowClass* ImGuiWindowClass_ImGuiWindowClass(void) { - return IM_NEW(ImGuiWindowClass)(); -} -CIMGUI_API void ImGuiWindowClass_destroy(ImGuiWindowClass* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiPayload* ImGuiPayload_ImGuiPayload(void) { - return IM_NEW(ImGuiPayload)(); -} -CIMGUI_API void ImGuiPayload_destroy(ImGuiPayload* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiPayload_Clear(ImGuiPayload* self) { - return self->Clear(); -} -CIMGUI_API bool ImGuiPayload_IsDataType(ImGuiPayload* self, const char* type) { - return self->IsDataType(type); -} -CIMGUI_API bool ImGuiPayload_IsPreview(ImGuiPayload* self) { - return self->IsPreview(); -} -CIMGUI_API bool ImGuiPayload_IsDelivery(ImGuiPayload* self) { - return self->IsDelivery(); -} -CIMGUI_API ImGuiOnceUponAFrame* ImGuiOnceUponAFrame_ImGuiOnceUponAFrame(void) { - return IM_NEW(ImGuiOnceUponAFrame)(); -} -CIMGUI_API void ImGuiOnceUponAFrame_destroy(ImGuiOnceUponAFrame* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTextFilter* ImGuiTextFilter_ImGuiTextFilter( - const char* default_filter) { - return IM_NEW(ImGuiTextFilter)(default_filter); -} -CIMGUI_API void ImGuiTextFilter_destroy(ImGuiTextFilter* self) { - IM_DELETE(self); -} -CIMGUI_API bool ImGuiTextFilter_Draw(ImGuiTextFilter* self, - const char* label, - float width) { - return self->Draw(label, width); -} -CIMGUI_API bool ImGuiTextFilter_PassFilter(ImGuiTextFilter* self, - const char* text, - const char* text_end) { - return self->PassFilter(text, text_end); -} -CIMGUI_API void ImGuiTextFilter_Build(ImGuiTextFilter* self) { - return self->Build(); -} -CIMGUI_API void ImGuiTextFilter_Clear(ImGuiTextFilter* self) { - return self->Clear(); -} -CIMGUI_API bool ImGuiTextFilter_IsActive(ImGuiTextFilter* self) { - return self->IsActive(); -} -CIMGUI_API ImGuiTextRange* ImGuiTextRange_ImGuiTextRange_Nil(void) { - return IM_NEW(ImGuiTextRange)(); -} -CIMGUI_API void ImGuiTextRange_destroy(ImGuiTextRange* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTextRange* ImGuiTextRange_ImGuiTextRange_Str(const char* _b, - const char* _e) { - return IM_NEW(ImGuiTextRange)(_b, _e); -} -CIMGUI_API bool ImGuiTextRange_empty(ImGuiTextRange* self) { - return self->empty(); -} -CIMGUI_API void ImGuiTextRange_split(ImGuiTextRange* self, - char separator, - ImVector_ImGuiTextRange* out) { - return self->split(separator, out); -} -CIMGUI_API ImGuiTextBuffer* ImGuiTextBuffer_ImGuiTextBuffer(void) { - return IM_NEW(ImGuiTextBuffer)(); -} -CIMGUI_API void ImGuiTextBuffer_destroy(ImGuiTextBuffer* self) { - IM_DELETE(self); -} -CIMGUI_API const char* ImGuiTextBuffer_begin(ImGuiTextBuffer* self) { - return self->begin(); -} -CIMGUI_API const char* ImGuiTextBuffer_end(ImGuiTextBuffer* self) { - return self->end(); -} -CIMGUI_API int ImGuiTextBuffer_size(ImGuiTextBuffer* self) { - return self->size(); -} -CIMGUI_API bool ImGuiTextBuffer_empty(ImGuiTextBuffer* self) { - return self->empty(); -} -CIMGUI_API void ImGuiTextBuffer_clear(ImGuiTextBuffer* self) { - return self->clear(); -} -CIMGUI_API void ImGuiTextBuffer_reserve(ImGuiTextBuffer* self, int capacity) { - return self->reserve(capacity); -} -CIMGUI_API const char* ImGuiTextBuffer_c_str(ImGuiTextBuffer* self) { - return self->c_str(); -} -CIMGUI_API void ImGuiTextBuffer_append(ImGuiTextBuffer* self, - const char* str, - const char* str_end) { - return self->append(str, str_end); -} -CIMGUI_API void ImGuiTextBuffer_appendfv(ImGuiTextBuffer* self, - const char* fmt, - va_list args) { - return self->appendfv(fmt, args); -} -CIMGUI_API ImGuiStoragePair* ImGuiStoragePair_ImGuiStoragePair_Int(ImGuiID _key, - int _val) { - return IM_NEW(ImGuiStoragePair)(_key, _val); -} -CIMGUI_API void ImGuiStoragePair_destroy(ImGuiStoragePair* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiStoragePair* ImGuiStoragePair_ImGuiStoragePair_Float( - ImGuiID _key, - float _val) { - return IM_NEW(ImGuiStoragePair)(_key, _val); -} -CIMGUI_API ImGuiStoragePair* ImGuiStoragePair_ImGuiStoragePair_Ptr(ImGuiID _key, - void* _val) { - return IM_NEW(ImGuiStoragePair)(_key, _val); -} -CIMGUI_API void ImGuiStorage_Clear(ImGuiStorage* self) { - return self->Clear(); -} -CIMGUI_API int ImGuiStorage_GetInt(ImGuiStorage* self, - ImGuiID key, - int default_val) { - return self->GetInt(key, default_val); -} -CIMGUI_API void ImGuiStorage_SetInt(ImGuiStorage* self, ImGuiID key, int val) { - return self->SetInt(key, val); -} -CIMGUI_API bool ImGuiStorage_GetBool(ImGuiStorage* self, - ImGuiID key, - bool default_val) { - return self->GetBool(key, default_val); -} -CIMGUI_API void ImGuiStorage_SetBool(ImGuiStorage* self, - ImGuiID key, - bool val) { - return self->SetBool(key, val); -} -CIMGUI_API float ImGuiStorage_GetFloat(ImGuiStorage* self, - ImGuiID key, - float default_val) { - return self->GetFloat(key, default_val); -} -CIMGUI_API void ImGuiStorage_SetFloat(ImGuiStorage* self, - ImGuiID key, - float val) { - return self->SetFloat(key, val); -} -CIMGUI_API void* ImGuiStorage_GetVoidPtr(ImGuiStorage* self, ImGuiID key) { - return self->GetVoidPtr(key); -} -CIMGUI_API void ImGuiStorage_SetVoidPtr(ImGuiStorage* self, - ImGuiID key, - void* val) { - return self->SetVoidPtr(key, val); -} -CIMGUI_API int* ImGuiStorage_GetIntRef(ImGuiStorage* self, - ImGuiID key, - int default_val) { - return self->GetIntRef(key, default_val); -} -CIMGUI_API bool* ImGuiStorage_GetBoolRef(ImGuiStorage* self, - ImGuiID key, - bool default_val) { - return self->GetBoolRef(key, default_val); -} -CIMGUI_API float* ImGuiStorage_GetFloatRef(ImGuiStorage* self, - ImGuiID key, - float default_val) { - return self->GetFloatRef(key, default_val); -} -CIMGUI_API void** ImGuiStorage_GetVoidPtrRef(ImGuiStorage* self, - ImGuiID key, - void* default_val) { - return self->GetVoidPtrRef(key, default_val); -} -CIMGUI_API void ImGuiStorage_BuildSortByKey(ImGuiStorage* self) { - return self->BuildSortByKey(); -} -CIMGUI_API void ImGuiStorage_SetAllInt(ImGuiStorage* self, int val) { - return self->SetAllInt(val); -} -CIMGUI_API ImGuiListClipper* ImGuiListClipper_ImGuiListClipper(void) { - return IM_NEW(ImGuiListClipper)(); -} -CIMGUI_API void ImGuiListClipper_destroy(ImGuiListClipper* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiListClipper_Begin(ImGuiListClipper* self, - int items_count, - float items_height) { - return self->Begin(items_count, items_height); -} -CIMGUI_API void ImGuiListClipper_End(ImGuiListClipper* self) { - return self->End(); -} -CIMGUI_API bool ImGuiListClipper_Step(ImGuiListClipper* self) { - return self->Step(); -} -CIMGUI_API void ImGuiListClipper_IncludeItemByIndex(ImGuiListClipper* self, - int item_index) { - return self->IncludeItemByIndex(item_index); -} -CIMGUI_API void ImGuiListClipper_IncludeItemsByIndex(ImGuiListClipper* self, - int item_begin, - int item_end) { - return self->IncludeItemsByIndex(item_begin, item_end); -} -CIMGUI_API ImColor* ImColor_ImColor_Nil(void) { - return IM_NEW(ImColor)(); -} -CIMGUI_API void ImColor_destroy(ImColor* self) { - IM_DELETE(self); -} -CIMGUI_API ImColor* ImColor_ImColor_Float(float r, float g, float b, float a) { - return IM_NEW(ImColor)(r, g, b, a); -} -CIMGUI_API ImColor* ImColor_ImColor_Vec4(const ImVec4 col) { - return IM_NEW(ImColor)(col); -} -CIMGUI_API ImColor* ImColor_ImColor_Int(int r, int g, int b, int a) { - return IM_NEW(ImColor)(r, g, b, a); -} -CIMGUI_API ImColor* ImColor_ImColor_U32(ImU32 rgba) { - return IM_NEW(ImColor)(rgba); -} -CIMGUI_API void ImColor_SetHSV(ImColor* self, - float h, - float s, - float v, - float a) { - return self->SetHSV(h, s, v, a); -} -CIMGUI_API void ImColor_HSV(ImColor* pOut, float h, float s, float v, float a) { - *pOut = ImColor::HSV(h, s, v, a); -} -CIMGUI_API ImDrawCmd* ImDrawCmd_ImDrawCmd(void) { - return IM_NEW(ImDrawCmd)(); -} -CIMGUI_API void ImDrawCmd_destroy(ImDrawCmd* self) { - IM_DELETE(self); -} -CIMGUI_API ImTextureID ImDrawCmd_GetTexID(ImDrawCmd* self) { - return self->GetTexID(); -} -CIMGUI_API ImDrawListSplitter* ImDrawListSplitter_ImDrawListSplitter(void) { - return IM_NEW(ImDrawListSplitter)(); -} -CIMGUI_API void ImDrawListSplitter_destroy(ImDrawListSplitter* self) { - IM_DELETE(self); -} -CIMGUI_API void ImDrawListSplitter_Clear(ImDrawListSplitter* self) { - return self->Clear(); -} -CIMGUI_API void ImDrawListSplitter_ClearFreeMemory(ImDrawListSplitter* self) { - return self->ClearFreeMemory(); -} -CIMGUI_API void ImDrawListSplitter_Split(ImDrawListSplitter* self, - ImDrawList* draw_list, - int count) { - return self->Split(draw_list, count); -} -CIMGUI_API void ImDrawListSplitter_Merge(ImDrawListSplitter* self, - ImDrawList* draw_list) { - return self->Merge(draw_list); -} -CIMGUI_API void ImDrawListSplitter_SetCurrentChannel(ImDrawListSplitter* self, - ImDrawList* draw_list, - int channel_idx) { - return self->SetCurrentChannel(draw_list, channel_idx); -} -CIMGUI_API ImDrawList* ImDrawList_ImDrawList( - ImDrawListSharedData* shared_data) { - return IM_NEW(ImDrawList)(shared_data); -} -CIMGUI_API void ImDrawList_destroy(ImDrawList* self) { - IM_DELETE(self); -} -CIMGUI_API void ImDrawList_PushClipRect(ImDrawList* self, - const ImVec2 clip_rect_min, - const ImVec2 clip_rect_max, - bool intersect_with_current_clip_rect) { - return self->PushClipRect(clip_rect_min, clip_rect_max, - intersect_with_current_clip_rect); -} -CIMGUI_API void ImDrawList_PushClipRectFullScreen(ImDrawList* self) { - return self->PushClipRectFullScreen(); -} -CIMGUI_API void ImDrawList_PopClipRect(ImDrawList* self) { - return self->PopClipRect(); -} -CIMGUI_API void ImDrawList_PushTextureID(ImDrawList* self, - ImTextureID texture_id) { - return self->PushTextureID(texture_id); -} -CIMGUI_API void ImDrawList_PopTextureID(ImDrawList* self) { - return self->PopTextureID(); -} -CIMGUI_API void ImDrawList_GetClipRectMin(ImVec2* pOut, ImDrawList* self) { - *pOut = self->GetClipRectMin(); -} -CIMGUI_API void ImDrawList_GetClipRectMax(ImVec2* pOut, ImDrawList* self) { - *pOut = self->GetClipRectMax(); -} -CIMGUI_API void ImDrawList_AddLine(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - ImU32 col, - float thickness) { - return self->AddLine(p1, p2, col, thickness); -} -CIMGUI_API void ImDrawList_AddRect(ImDrawList* self, - const ImVec2 p_min, - const ImVec2 p_max, - ImU32 col, - float rounding, - ImDrawFlags flags, - float thickness) { - return self->AddRect(p_min, p_max, col, rounding, flags, thickness); -} -CIMGUI_API void ImDrawList_AddRectFilled(ImDrawList* self, - const ImVec2 p_min, - const ImVec2 p_max, - ImU32 col, - float rounding, - ImDrawFlags flags) { - return self->AddRectFilled(p_min, p_max, col, rounding, flags); -} -CIMGUI_API void ImDrawList_AddRectFilledMultiColor(ImDrawList* self, - const ImVec2 p_min, - const ImVec2 p_max, - ImU32 col_upr_left, - ImU32 col_upr_right, - ImU32 col_bot_right, - ImU32 col_bot_left) { - return self->AddRectFilledMultiColor( - p_min, p_max, col_upr_left, col_upr_right, col_bot_right, col_bot_left); -} -CIMGUI_API void ImDrawList_AddQuad(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - ImU32 col, - float thickness) { - return self->AddQuad(p1, p2, p3, p4, col, thickness); -} -CIMGUI_API void ImDrawList_AddQuadFilled(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - ImU32 col) { - return self->AddQuadFilled(p1, p2, p3, p4, col); -} -CIMGUI_API void ImDrawList_AddTriangle(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - ImU32 col, - float thickness) { - return self->AddTriangle(p1, p2, p3, col, thickness); -} -CIMGUI_API void ImDrawList_AddTriangleFilled(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - ImU32 col) { - return self->AddTriangleFilled(p1, p2, p3, col); -} -CIMGUI_API void ImDrawList_AddCircle(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments, - float thickness) { - return self->AddCircle(center, radius, col, num_segments, thickness); -} -CIMGUI_API void ImDrawList_AddCircleFilled(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments) { - return self->AddCircleFilled(center, radius, col, num_segments); -} -CIMGUI_API void ImDrawList_AddNgon(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments, - float thickness) { - return self->AddNgon(center, radius, col, num_segments, thickness); -} -CIMGUI_API void ImDrawList_AddNgonFilled(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments) { - return self->AddNgonFilled(center, radius, col, num_segments); -} -CIMGUI_API void ImDrawList_AddEllipse(ImDrawList* self, - const ImVec2 center, - const ImVec2 radius, - ImU32 col, - float rot, - int num_segments, - float thickness) { - return self->AddEllipse(center, radius, col, rot, num_segments, thickness); -} -CIMGUI_API void ImDrawList_AddEllipseFilled(ImDrawList* self, - const ImVec2 center, - const ImVec2 radius, - ImU32 col, - float rot, - int num_segments) { - return self->AddEllipseFilled(center, radius, col, rot, num_segments); -} -CIMGUI_API void ImDrawList_AddText_Vec2(ImDrawList* self, - const ImVec2 pos, - ImU32 col, - const char* text_begin, - const char* text_end) { - return self->AddText(pos, col, text_begin, text_end); -} -CIMGUI_API void ImDrawList_AddText_FontPtr(ImDrawList* self, - const ImFont* font, - float font_size, - const ImVec2 pos, - ImU32 col, - const char* text_begin, - const char* text_end, - float wrap_width, - const ImVec4* cpu_fine_clip_rect) { - return self->AddText(font, font_size, pos, col, text_begin, text_end, - wrap_width, cpu_fine_clip_rect); -} -CIMGUI_API void ImDrawList_AddBezierCubic(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - ImU32 col, - float thickness, - int num_segments) { - return self->AddBezierCubic(p1, p2, p3, p4, col, thickness, num_segments); -} -CIMGUI_API void ImDrawList_AddBezierQuadratic(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - ImU32 col, - float thickness, - int num_segments) { - return self->AddBezierQuadratic(p1, p2, p3, col, thickness, num_segments); -} -CIMGUI_API void ImDrawList_AddPolyline(ImDrawList* self, - const ImVec2* points, - int num_points, - ImU32 col, - ImDrawFlags flags, - float thickness) { - return self->AddPolyline(points, num_points, col, flags, thickness); -} -CIMGUI_API void ImDrawList_AddConvexPolyFilled(ImDrawList* self, - const ImVec2* points, - int num_points, - ImU32 col) { - return self->AddConvexPolyFilled(points, num_points, col); -} -CIMGUI_API void ImDrawList_AddConcavePolyFilled(ImDrawList* self, - const ImVec2* points, - int num_points, - ImU32 col) { - return self->AddConcavePolyFilled(points, num_points, col); -} -CIMGUI_API void ImDrawList_AddImage(ImDrawList* self, - ImTextureID user_texture_id, - const ImVec2 p_min, - const ImVec2 p_max, - const ImVec2 uv_min, - const ImVec2 uv_max, - ImU32 col) { - return self->AddImage(user_texture_id, p_min, p_max, uv_min, uv_max, col); -} -CIMGUI_API void ImDrawList_AddImageQuad(ImDrawList* self, - ImTextureID user_texture_id, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - const ImVec2 uv1, - const ImVec2 uv2, - const ImVec2 uv3, - const ImVec2 uv4, - ImU32 col) { - return self->AddImageQuad(user_texture_id, p1, p2, p3, p4, uv1, uv2, uv3, uv4, - col); -} -CIMGUI_API void ImDrawList_AddImageRounded(ImDrawList* self, - ImTextureID user_texture_id, - const ImVec2 p_min, - const ImVec2 p_max, - const ImVec2 uv_min, - const ImVec2 uv_max, - ImU32 col, - float rounding, - ImDrawFlags flags) { - return self->AddImageRounded(user_texture_id, p_min, p_max, uv_min, uv_max, - col, rounding, flags); -} -CIMGUI_API void ImDrawList_PathClear(ImDrawList* self) { - return self->PathClear(); -} -CIMGUI_API void ImDrawList_PathLineTo(ImDrawList* self, const ImVec2 pos) { - return self->PathLineTo(pos); -} -CIMGUI_API void ImDrawList_PathLineToMergeDuplicate(ImDrawList* self, - const ImVec2 pos) { - return self->PathLineToMergeDuplicate(pos); -} -CIMGUI_API void ImDrawList_PathFillConvex(ImDrawList* self, ImU32 col) { - return self->PathFillConvex(col); -} -CIMGUI_API void ImDrawList_PathFillConcave(ImDrawList* self, ImU32 col) { - return self->PathFillConcave(col); -} -CIMGUI_API void ImDrawList_PathStroke(ImDrawList* self, - ImU32 col, - ImDrawFlags flags, - float thickness) { - return self->PathStroke(col, flags, thickness); -} -CIMGUI_API void ImDrawList_PathArcTo(ImDrawList* self, - const ImVec2 center, - float radius, - float a_min, - float a_max, - int num_segments) { - return self->PathArcTo(center, radius, a_min, a_max, num_segments); -} -CIMGUI_API void ImDrawList_PathArcToFast(ImDrawList* self, - const ImVec2 center, - float radius, - int a_min_of_12, - int a_max_of_12) { - return self->PathArcToFast(center, radius, a_min_of_12, a_max_of_12); -} -CIMGUI_API void ImDrawList_PathEllipticalArcTo(ImDrawList* self, - const ImVec2 center, - const ImVec2 radius, - float rot, - float a_min, - float a_max, - int num_segments) { - return self->PathEllipticalArcTo(center, radius, rot, a_min, a_max, - num_segments); -} -CIMGUI_API void ImDrawList_PathBezierCubicCurveTo(ImDrawList* self, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - int num_segments) { - return self->PathBezierCubicCurveTo(p2, p3, p4, num_segments); -} -CIMGUI_API void ImDrawList_PathBezierQuadraticCurveTo(ImDrawList* self, - const ImVec2 p2, - const ImVec2 p3, - int num_segments) { - return self->PathBezierQuadraticCurveTo(p2, p3, num_segments); -} -CIMGUI_API void ImDrawList_PathRect(ImDrawList* self, - const ImVec2 rect_min, - const ImVec2 rect_max, - float rounding, - ImDrawFlags flags) { - return self->PathRect(rect_min, rect_max, rounding, flags); -} -CIMGUI_API void ImDrawList_AddCallback(ImDrawList* self, - ImDrawCallback callback, - void* callback_data) { - return self->AddCallback(callback, callback_data); -} -CIMGUI_API void ImDrawList_AddDrawCmd(ImDrawList* self) { - return self->AddDrawCmd(); -} -CIMGUI_API ImDrawList* ImDrawList_CloneOutput(ImDrawList* self) { - return self->CloneOutput(); -} -CIMGUI_API void ImDrawList_ChannelsSplit(ImDrawList* self, int count) { - return self->ChannelsSplit(count); -} -CIMGUI_API void ImDrawList_ChannelsMerge(ImDrawList* self) { - return self->ChannelsMerge(); -} -CIMGUI_API void ImDrawList_ChannelsSetCurrent(ImDrawList* self, int n) { - return self->ChannelsSetCurrent(n); -} -CIMGUI_API void ImDrawList_PrimReserve(ImDrawList* self, - int idx_count, - int vtx_count) { - return self->PrimReserve(idx_count, vtx_count); -} -CIMGUI_API void ImDrawList_PrimUnreserve(ImDrawList* self, - int idx_count, - int vtx_count) { - return self->PrimUnreserve(idx_count, vtx_count); -} -CIMGUI_API void ImDrawList_PrimRect(ImDrawList* self, - const ImVec2 a, - const ImVec2 b, - ImU32 col) { - return self->PrimRect(a, b, col); -} -CIMGUI_API void ImDrawList_PrimRectUV(ImDrawList* self, - const ImVec2 a, - const ImVec2 b, - const ImVec2 uv_a, - const ImVec2 uv_b, - ImU32 col) { - return self->PrimRectUV(a, b, uv_a, uv_b, col); -} -CIMGUI_API void ImDrawList_PrimQuadUV(ImDrawList* self, - const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 d, - const ImVec2 uv_a, - const ImVec2 uv_b, - const ImVec2 uv_c, - const ImVec2 uv_d, - ImU32 col) { - return self->PrimQuadUV(a, b, c, d, uv_a, uv_b, uv_c, uv_d, col); -} -CIMGUI_API void ImDrawList_PrimWriteVtx(ImDrawList* self, - const ImVec2 pos, - const ImVec2 uv, - ImU32 col) { - return self->PrimWriteVtx(pos, uv, col); -} -CIMGUI_API void ImDrawList_PrimWriteIdx(ImDrawList* self, ImDrawIdx idx) { - return self->PrimWriteIdx(idx); -} -CIMGUI_API void ImDrawList_PrimVtx(ImDrawList* self, - const ImVec2 pos, - const ImVec2 uv, - ImU32 col) { - return self->PrimVtx(pos, uv, col); -} -CIMGUI_API void ImDrawList__ResetForNewFrame(ImDrawList* self) { - return self->_ResetForNewFrame(); -} -CIMGUI_API void ImDrawList__ClearFreeMemory(ImDrawList* self) { - return self->_ClearFreeMemory(); -} -CIMGUI_API void ImDrawList__PopUnusedDrawCmd(ImDrawList* self) { - return self->_PopUnusedDrawCmd(); -} -CIMGUI_API void ImDrawList__TryMergeDrawCmds(ImDrawList* self) { - return self->_TryMergeDrawCmds(); -} -CIMGUI_API void ImDrawList__OnChangedClipRect(ImDrawList* self) { - return self->_OnChangedClipRect(); -} -CIMGUI_API void ImDrawList__OnChangedTextureID(ImDrawList* self) { - return self->_OnChangedTextureID(); -} -CIMGUI_API void ImDrawList__OnChangedVtxOffset(ImDrawList* self) { - return self->_OnChangedVtxOffset(); -} -CIMGUI_API int ImDrawList__CalcCircleAutoSegmentCount(ImDrawList* self, - float radius) { - return self->_CalcCircleAutoSegmentCount(radius); -} -CIMGUI_API void ImDrawList__PathArcToFastEx(ImDrawList* self, - const ImVec2 center, - float radius, - int a_min_sample, - int a_max_sample, - int a_step) { - return self->_PathArcToFastEx(center, radius, a_min_sample, a_max_sample, - a_step); -} -CIMGUI_API void ImDrawList__PathArcToN(ImDrawList* self, - const ImVec2 center, - float radius, - float a_min, - float a_max, - int num_segments) { - return self->_PathArcToN(center, radius, a_min, a_max, num_segments); -} -CIMGUI_API ImDrawData* ImDrawData_ImDrawData(void) { - return IM_NEW(ImDrawData)(); -} -CIMGUI_API void ImDrawData_destroy(ImDrawData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImDrawData_Clear(ImDrawData* self) { - return self->Clear(); -} -CIMGUI_API void ImDrawData_AddDrawList(ImDrawData* self, - ImDrawList* draw_list) { - return self->AddDrawList(draw_list); -} -CIMGUI_API void ImDrawData_DeIndexAllBuffers(ImDrawData* self) { - return self->DeIndexAllBuffers(); -} -CIMGUI_API void ImDrawData_ScaleClipRects(ImDrawData* self, - const ImVec2 fb_scale) { - return self->ScaleClipRects(fb_scale); -} -CIMGUI_API ImFontConfig* ImFontConfig_ImFontConfig(void) { - return IM_NEW(ImFontConfig)(); -} -CIMGUI_API void ImFontConfig_destroy(ImFontConfig* self) { - IM_DELETE(self); -} -CIMGUI_API ImFontGlyphRangesBuilder* -ImFontGlyphRangesBuilder_ImFontGlyphRangesBuilder(void) { - return IM_NEW(ImFontGlyphRangesBuilder)(); -} -CIMGUI_API void ImFontGlyphRangesBuilder_destroy( - ImFontGlyphRangesBuilder* self) { - IM_DELETE(self); -} -CIMGUI_API void ImFontGlyphRangesBuilder_Clear(ImFontGlyphRangesBuilder* self) { - return self->Clear(); -} -CIMGUI_API bool ImFontGlyphRangesBuilder_GetBit(ImFontGlyphRangesBuilder* self, - size_t n) { - return self->GetBit(n); -} -CIMGUI_API void ImFontGlyphRangesBuilder_SetBit(ImFontGlyphRangesBuilder* self, - size_t n) { - return self->SetBit(n); -} -CIMGUI_API void ImFontGlyphRangesBuilder_AddChar(ImFontGlyphRangesBuilder* self, - ImWchar c) { - return self->AddChar(c); -} -CIMGUI_API void ImFontGlyphRangesBuilder_AddText(ImFontGlyphRangesBuilder* self, - const char* text, - const char* text_end) { - return self->AddText(text, text_end); -} -CIMGUI_API void ImFontGlyphRangesBuilder_AddRanges( - ImFontGlyphRangesBuilder* self, - const ImWchar* ranges) { - return self->AddRanges(ranges); -} -CIMGUI_API void ImFontGlyphRangesBuilder_BuildRanges( - ImFontGlyphRangesBuilder* self, - ImVector_ImWchar* out_ranges) { - return self->BuildRanges(out_ranges); -} -CIMGUI_API ImFontAtlasCustomRect* ImFontAtlasCustomRect_ImFontAtlasCustomRect( - void) { - return IM_NEW(ImFontAtlasCustomRect)(); -} -CIMGUI_API void ImFontAtlasCustomRect_destroy(ImFontAtlasCustomRect* self) { - IM_DELETE(self); -} -CIMGUI_API bool ImFontAtlasCustomRect_IsPacked(ImFontAtlasCustomRect* self) { - return self->IsPacked(); -} -CIMGUI_API ImFontAtlas* ImFontAtlas_ImFontAtlas(void) { - return IM_NEW(ImFontAtlas)(); -} -CIMGUI_API void ImFontAtlas_destroy(ImFontAtlas* self) { - IM_DELETE(self); -} -CIMGUI_API ImFont* ImFontAtlas_AddFont(ImFontAtlas* self, - const ImFontConfig* font_cfg) { - return self->AddFont(font_cfg); -} -CIMGUI_API ImFont* ImFontAtlas_AddFontDefault(ImFontAtlas* self, - const ImFontConfig* font_cfg) { - return self->AddFontDefault(font_cfg); -} -CIMGUI_API ImFont* ImFontAtlas_AddFontFromFileTTF(ImFontAtlas* self, - const char* filename, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges) { - return self->AddFontFromFileTTF(filename, size_pixels, font_cfg, - glyph_ranges); -} -CIMGUI_API ImFont* ImFontAtlas_AddFontFromMemoryTTF( - ImFontAtlas* self, - void* font_data, - int font_data_size, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges) { - return self->AddFontFromMemoryTTF(font_data, font_data_size, size_pixels, - font_cfg, glyph_ranges); -} -CIMGUI_API ImFont* ImFontAtlas_AddFontFromMemoryCompressedTTF( - ImFontAtlas* self, - const void* compressed_font_data, - int compressed_font_data_size, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges) { - return self->AddFontFromMemoryCompressedTTF( - compressed_font_data, compressed_font_data_size, size_pixels, font_cfg, - glyph_ranges); -} -CIMGUI_API ImFont* ImFontAtlas_AddFontFromMemoryCompressedBase85TTF( - ImFontAtlas* self, - const char* compressed_font_data_base85, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges) { - return self->AddFontFromMemoryCompressedBase85TTF( - compressed_font_data_base85, size_pixels, font_cfg, glyph_ranges); -} -CIMGUI_API void ImFontAtlas_ClearInputData(ImFontAtlas* self) { - return self->ClearInputData(); -} -CIMGUI_API void ImFontAtlas_ClearTexData(ImFontAtlas* self) { - return self->ClearTexData(); -} -CIMGUI_API void ImFontAtlas_ClearFonts(ImFontAtlas* self) { - return self->ClearFonts(); -} -CIMGUI_API void ImFontAtlas_Clear(ImFontAtlas* self) { - return self->Clear(); -} -CIMGUI_API bool ImFontAtlas_Build(ImFontAtlas* self) { - return self->Build(); -} -CIMGUI_API void ImFontAtlas_GetTexDataAsAlpha8(ImFontAtlas* self, - unsigned char** out_pixels, - int* out_width, - int* out_height, - int* out_bytes_per_pixel) { - return self->GetTexDataAsAlpha8(out_pixels, out_width, out_height, - out_bytes_per_pixel); -} -CIMGUI_API void ImFontAtlas_GetTexDataAsRGBA32(ImFontAtlas* self, - unsigned char** out_pixels, - int* out_width, - int* out_height, - int* out_bytes_per_pixel) { - return self->GetTexDataAsRGBA32(out_pixels, out_width, out_height, - out_bytes_per_pixel); -} -CIMGUI_API bool ImFontAtlas_IsBuilt(ImFontAtlas* self) { - return self->IsBuilt(); -} -CIMGUI_API void ImFontAtlas_SetTexID(ImFontAtlas* self, ImTextureID id) { - return self->SetTexID(id); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesDefault(ImFontAtlas* self) { - return self->GetGlyphRangesDefault(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesGreek(ImFontAtlas* self) { - return self->GetGlyphRangesGreek(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesKorean(ImFontAtlas* self) { - return self->GetGlyphRangesKorean(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesJapanese( - ImFontAtlas* self) { - return self->GetGlyphRangesJapanese(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesChineseFull( - ImFontAtlas* self) { - return self->GetGlyphRangesChineseFull(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesChineseSimplifiedCommon( - ImFontAtlas* self) { - return self->GetGlyphRangesChineseSimplifiedCommon(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesCyrillic( - ImFontAtlas* self) { - return self->GetGlyphRangesCyrillic(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesThai(ImFontAtlas* self) { - return self->GetGlyphRangesThai(); -} -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesVietnamese( - ImFontAtlas* self) { - return self->GetGlyphRangesVietnamese(); -} -CIMGUI_API int ImFontAtlas_AddCustomRectRegular(ImFontAtlas* self, - int width, - int height) { - return self->AddCustomRectRegular(width, height); -} -CIMGUI_API int ImFontAtlas_AddCustomRectFontGlyph(ImFontAtlas* self, - ImFont* font, - ImWchar id, - int width, - int height, - float advance_x, - const ImVec2 offset) { - return self->AddCustomRectFontGlyph(font, id, width, height, advance_x, - offset); -} -CIMGUI_API ImFontAtlasCustomRect* ImFontAtlas_GetCustomRectByIndex( - ImFontAtlas* self, - int index) { - return self->GetCustomRectByIndex(index); -} -CIMGUI_API void ImFontAtlas_CalcCustomRectUV(ImFontAtlas* self, - const ImFontAtlasCustomRect* rect, - ImVec2* out_uv_min, - ImVec2* out_uv_max) { - return self->CalcCustomRectUV(rect, out_uv_min, out_uv_max); -} -CIMGUI_API bool ImFontAtlas_GetMouseCursorTexData(ImFontAtlas* self, - ImGuiMouseCursor cursor, - ImVec2* out_offset, - ImVec2* out_size, - ImVec2 out_uv_border[2], - ImVec2 out_uv_fill[2]) { - return self->GetMouseCursorTexData(cursor, out_offset, out_size, - out_uv_border, out_uv_fill); -} -CIMGUI_API ImFont* ImFont_ImFont(void) { - return IM_NEW(ImFont)(); -} -CIMGUI_API void ImFont_destroy(ImFont* self) { - IM_DELETE(self); -} -CIMGUI_API const ImFontGlyph* ImFont_FindGlyph(ImFont* self, ImWchar c) { - return self->FindGlyph(c); -} -CIMGUI_API const ImFontGlyph* ImFont_FindGlyphNoFallback(ImFont* self, - ImWchar c) { - return self->FindGlyphNoFallback(c); -} -CIMGUI_API float ImFont_GetCharAdvance(ImFont* self, ImWchar c) { - return self->GetCharAdvance(c); -} -CIMGUI_API bool ImFont_IsLoaded(ImFont* self) { - return self->IsLoaded(); -} -CIMGUI_API const char* ImFont_GetDebugName(ImFont* self) { - return self->GetDebugName(); -} -CIMGUI_API void ImFont_CalcTextSizeA(ImVec2* pOut, - ImFont* self, - float size, - float max_width, - float wrap_width, - const char* text_begin, - const char* text_end, - const char** remaining) { - *pOut = self->CalcTextSizeA(size, max_width, wrap_width, text_begin, text_end, - remaining); -} -CIMGUI_API const char* ImFont_CalcWordWrapPositionA(ImFont* self, - float scale, - const char* text, - const char* text_end, - float wrap_width) { - return self->CalcWordWrapPositionA(scale, text, text_end, wrap_width); -} -CIMGUI_API void ImFont_RenderChar(ImFont* self, - ImDrawList* draw_list, - float size, - const ImVec2 pos, - ImU32 col, - ImWchar c) { - return self->RenderChar(draw_list, size, pos, col, c); -} -CIMGUI_API void ImFont_RenderText(ImFont* self, - ImDrawList* draw_list, - float size, - const ImVec2 pos, - ImU32 col, - const ImVec4 clip_rect, - const char* text_begin, - const char* text_end, - float wrap_width, - bool cpu_fine_clip) { - return self->RenderText(draw_list, size, pos, col, clip_rect, text_begin, - text_end, wrap_width, cpu_fine_clip); -} -CIMGUI_API void ImFont_BuildLookupTable(ImFont* self) { - return self->BuildLookupTable(); -} -CIMGUI_API void ImFont_ClearOutputData(ImFont* self) { - return self->ClearOutputData(); -} -CIMGUI_API void ImFont_GrowIndex(ImFont* self, int new_size) { - return self->GrowIndex(new_size); -} -CIMGUI_API void ImFont_AddGlyph(ImFont* self, - const ImFontConfig* src_cfg, - ImWchar c, - float x0, - float y0, - float x1, - float y1, - float u0, - float v0, - float u1, - float v1, - float advance_x) { - return self->AddGlyph(src_cfg, c, x0, y0, x1, y1, u0, v0, u1, v1, advance_x); -} -CIMGUI_API void ImFont_AddRemapChar(ImFont* self, - ImWchar dst, - ImWchar src, - bool overwrite_dst) { - return self->AddRemapChar(dst, src, overwrite_dst); -} -CIMGUI_API void ImFont_SetGlyphVisible(ImFont* self, ImWchar c, bool visible) { - return self->SetGlyphVisible(c, visible); -} -CIMGUI_API bool ImFont_IsGlyphRangeUnused(ImFont* self, - unsigned int c_begin, - unsigned int c_last) { - return self->IsGlyphRangeUnused(c_begin, c_last); -} -CIMGUI_API ImGuiViewport* ImGuiViewport_ImGuiViewport(void) { - return IM_NEW(ImGuiViewport)(); -} -CIMGUI_API void ImGuiViewport_destroy(ImGuiViewport* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiViewport_GetCenter(ImVec2* pOut, ImGuiViewport* self) { - *pOut = self->GetCenter(); -} -CIMGUI_API void ImGuiViewport_GetWorkCenter(ImVec2* pOut, ImGuiViewport* self) { - *pOut = self->GetWorkCenter(); -} -CIMGUI_API ImGuiPlatformIO* ImGuiPlatformIO_ImGuiPlatformIO(void) { - return IM_NEW(ImGuiPlatformIO)(); -} -CIMGUI_API void ImGuiPlatformIO_destroy(ImGuiPlatformIO* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiPlatformMonitor* ImGuiPlatformMonitor_ImGuiPlatformMonitor( - void) { - return IM_NEW(ImGuiPlatformMonitor)(); -} -CIMGUI_API void ImGuiPlatformMonitor_destroy(ImGuiPlatformMonitor* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiPlatformImeData* ImGuiPlatformImeData_ImGuiPlatformImeData( - void) { - return IM_NEW(ImGuiPlatformImeData)(); -} -CIMGUI_API void ImGuiPlatformImeData_destroy(ImGuiPlatformImeData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiID igImHashData(const void* data, - size_t data_size, - ImGuiID seed) { - return ImHashData(data, data_size, seed); -} -CIMGUI_API ImGuiID igImHashStr(const char* data, - size_t data_size, - ImGuiID seed) { - return ImHashStr(data, data_size, seed); -} -CIMGUI_API void igImQsort(void* base, - size_t count, - size_t size_of_element, - int (*compare_func)(void const*, void const*)) { - return ImQsort(base, count, size_of_element, compare_func); -} -CIMGUI_API ImU32 igImAlphaBlendColors(ImU32 col_a, ImU32 col_b) { - return ImAlphaBlendColors(col_a, col_b); -} -CIMGUI_API bool igImIsPowerOfTwo_Int(int v) { - return ImIsPowerOfTwo(v); -} -CIMGUI_API bool igImIsPowerOfTwo_U64(ImU64 v) { - return ImIsPowerOfTwo(v); -} -CIMGUI_API int igImUpperPowerOfTwo(int v) { - return ImUpperPowerOfTwo(v); -} -CIMGUI_API int igImStricmp(const char* str1, const char* str2) { - return ImStricmp(str1, str2); -} -CIMGUI_API int igImStrnicmp(const char* str1, const char* str2, size_t count) { - return ImStrnicmp(str1, str2, count); -} -CIMGUI_API void igImStrncpy(char* dst, const char* src, size_t count) { - return ImStrncpy(dst, src, count); -} -CIMGUI_API char* igImStrdup(const char* str) { - return ImStrdup(str); -} -CIMGUI_API char* igImStrdupcpy(char* dst, size_t* p_dst_size, const char* str) { - return ImStrdupcpy(dst, p_dst_size, str); -} -CIMGUI_API const char* igImStrchrRange(const char* str_begin, - const char* str_end, - char c) { - return ImStrchrRange(str_begin, str_end, c); -} -CIMGUI_API const char* igImStreolRange(const char* str, const char* str_end) { - return ImStreolRange(str, str_end); -} -CIMGUI_API const char* igImStristr(const char* haystack, - const char* haystack_end, - const char* needle, - const char* needle_end) { - return ImStristr(haystack, haystack_end, needle, needle_end); -} -CIMGUI_API void igImStrTrimBlanks(char* str) { - return ImStrTrimBlanks(str); -} -CIMGUI_API const char* igImStrSkipBlank(const char* str) { - return ImStrSkipBlank(str); -} -CIMGUI_API int igImStrlenW(const ImWchar* str) { - return ImStrlenW(str); -} -CIMGUI_API const ImWchar* igImStrbolW(const ImWchar* buf_mid_line, - const ImWchar* buf_begin) { - return ImStrbolW(buf_mid_line, buf_begin); -} -CIMGUI_API char igImToUpper(char c) { - return ImToUpper(c); -} -CIMGUI_API bool igImCharIsBlankA(char c) { - return ImCharIsBlankA(c); -} -CIMGUI_API bool igImCharIsBlankW(unsigned int c) { - return ImCharIsBlankW(c); -} -CIMGUI_API int igImFormatString(char* buf, - size_t buf_size, - const char* fmt, - ...) { - va_list args; - va_start(args, fmt); - int ret = ImFormatStringV(buf, buf_size, fmt, args); - va_end(args); - return ret; -} -CIMGUI_API int igImFormatStringV(char* buf, - size_t buf_size, - const char* fmt, - va_list args) { - return ImFormatStringV(buf, buf_size, fmt, args); -} -CIMGUI_API void igImFormatStringToTempBuffer(const char** out_buf, - const char** out_buf_end, - const char* fmt, - ...) { - va_list args; - va_start(args, fmt); - ImFormatStringToTempBufferV(out_buf, out_buf_end, fmt, args); - va_end(args); -} -CIMGUI_API void igImFormatStringToTempBufferV(const char** out_buf, - const char** out_buf_end, - const char* fmt, - va_list args) { - return ImFormatStringToTempBufferV(out_buf, out_buf_end, fmt, args); -} -CIMGUI_API const char* igImParseFormatFindStart(const char* format) { - return ImParseFormatFindStart(format); -} -CIMGUI_API const char* igImParseFormatFindEnd(const char* format) { - return ImParseFormatFindEnd(format); -} -CIMGUI_API const char* igImParseFormatTrimDecorations(const char* format, - char* buf, - size_t buf_size) { - return ImParseFormatTrimDecorations(format, buf, buf_size); -} -CIMGUI_API void igImParseFormatSanitizeForPrinting(const char* fmt_in, - char* fmt_out, - size_t fmt_out_size) { - return ImParseFormatSanitizeForPrinting(fmt_in, fmt_out, fmt_out_size); -} -CIMGUI_API const char* igImParseFormatSanitizeForScanning(const char* fmt_in, - char* fmt_out, - size_t fmt_out_size) { - return ImParseFormatSanitizeForScanning(fmt_in, fmt_out, fmt_out_size); -} -CIMGUI_API int igImParseFormatPrecision(const char* format, int default_value) { - return ImParseFormatPrecision(format, default_value); -} -CIMGUI_API const char* igImTextCharToUtf8(char out_buf[5], unsigned int c) { - return ImTextCharToUtf8(out_buf, c); -} -CIMGUI_API int igImTextStrToUtf8(char* out_buf, - int out_buf_size, - const ImWchar* in_text, - const ImWchar* in_text_end) { - return ImTextStrToUtf8(out_buf, out_buf_size, in_text, in_text_end); -} -CIMGUI_API int igImTextCharFromUtf8(unsigned int* out_char, - const char* in_text, - const char* in_text_end) { - return ImTextCharFromUtf8(out_char, in_text, in_text_end); -} -CIMGUI_API int igImTextStrFromUtf8(ImWchar* out_buf, - int out_buf_size, - const char* in_text, - const char* in_text_end, - const char** in_remaining) { - return ImTextStrFromUtf8(out_buf, out_buf_size, in_text, in_text_end, - in_remaining); -} -CIMGUI_API int igImTextCountCharsFromUtf8(const char* in_text, - const char* in_text_end) { - return ImTextCountCharsFromUtf8(in_text, in_text_end); -} -CIMGUI_API int igImTextCountUtf8BytesFromChar(const char* in_text, - const char* in_text_end) { - return ImTextCountUtf8BytesFromChar(in_text, in_text_end); -} -CIMGUI_API int igImTextCountUtf8BytesFromStr(const ImWchar* in_text, - const ImWchar* in_text_end) { - return ImTextCountUtf8BytesFromStr(in_text, in_text_end); -} -CIMGUI_API const char* igImTextFindPreviousUtf8Codepoint( - const char* in_text_start, - const char* in_text_curr) { - return ImTextFindPreviousUtf8Codepoint(in_text_start, in_text_curr); -} -CIMGUI_API int igImTextCountLines(const char* in_text, - const char* in_text_end) { - return ImTextCountLines(in_text, in_text_end); -} -CIMGUI_API ImFileHandle igImFileOpen(const char* filename, const char* mode) { - return ImFileOpen(filename, mode); -} -CIMGUI_API bool igImFileClose(ImFileHandle file) { - return ImFileClose(file); -} -CIMGUI_API ImU64 igImFileGetSize(ImFileHandle file) { - return ImFileGetSize(file); -} -CIMGUI_API ImU64 igImFileRead(void* data, - ImU64 size, - ImU64 count, - ImFileHandle file) { - return ImFileRead(data, size, count, file); -} -CIMGUI_API ImU64 igImFileWrite(const void* data, - ImU64 size, - ImU64 count, - ImFileHandle file) { - return ImFileWrite(data, size, count, file); -} -CIMGUI_API void* igImFileLoadToMemory(const char* filename, - const char* mode, - size_t* out_file_size, - int padding_bytes) { - return ImFileLoadToMemory(filename, mode, out_file_size, padding_bytes); -} -CIMGUI_API float igImPow_Float(float x, float y) { - return ImPow(x, y); -} -CIMGUI_API double igImPow_double(double x, double y) { - return ImPow(x, y); -} -CIMGUI_API float igImLog_Float(float x) { - return ImLog(x); -} -CIMGUI_API double igImLog_double(double x) { - return ImLog(x); -} -CIMGUI_API int igImAbs_Int(int x) { - return ImAbs(x); -} -CIMGUI_API float igImAbs_Float(float x) { - return ImAbs(x); -} -CIMGUI_API double igImAbs_double(double x) { - return ImAbs(x); -} -CIMGUI_API float igImSign_Float(float x) { - return ImSign(x); -} -CIMGUI_API double igImSign_double(double x) { - return ImSign(x); -} -CIMGUI_API float igImRsqrt_Float(float x) { - return ImRsqrt(x); -} -CIMGUI_API double igImRsqrt_double(double x) { - return ImRsqrt(x); -} -CIMGUI_API void igImMin(ImVec2* pOut, const ImVec2 lhs, const ImVec2 rhs) { - *pOut = ImMin(lhs, rhs); -} -CIMGUI_API void igImMax(ImVec2* pOut, const ImVec2 lhs, const ImVec2 rhs) { - *pOut = ImMax(lhs, rhs); -} -CIMGUI_API void igImClamp(ImVec2* pOut, - const ImVec2 v, - const ImVec2 mn, - ImVec2 mx) { - *pOut = ImClamp(v, mn, mx); -} -CIMGUI_API void igImLerp_Vec2Float(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - float t) { - *pOut = ImLerp(a, b, t); -} -CIMGUI_API void igImLerp_Vec2Vec2(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - const ImVec2 t) { - *pOut = ImLerp(a, b, t); -} -CIMGUI_API void igImLerp_Vec4(ImVec4* pOut, - const ImVec4 a, - const ImVec4 b, - float t) { - *pOut = ImLerp(a, b, t); -} -CIMGUI_API float igImSaturate(float f) { - return ImSaturate(f); -} -CIMGUI_API float igImLengthSqr_Vec2(const ImVec2 lhs) { - return ImLengthSqr(lhs); -} -CIMGUI_API float igImLengthSqr_Vec4(const ImVec4 lhs) { - return ImLengthSqr(lhs); -} -CIMGUI_API float igImInvLength(const ImVec2 lhs, float fail_value) { - return ImInvLength(lhs, fail_value); -} -CIMGUI_API float igImTrunc_Float(float f) { - return ImTrunc(f); -} -CIMGUI_API void igImTrunc_Vec2(ImVec2* pOut, const ImVec2 v) { - *pOut = ImTrunc(v); -} -CIMGUI_API float igImFloor_Float(float f) { - return ImFloor(f); -} -CIMGUI_API void igImFloor_Vec2(ImVec2* pOut, const ImVec2 v) { - *pOut = ImFloor(v); -} -CIMGUI_API int igImModPositive(int a, int b) { - return ImModPositive(a, b); -} -CIMGUI_API float igImDot(const ImVec2 a, const ImVec2 b) { - return ImDot(a, b); -} -CIMGUI_API void igImRotate(ImVec2* pOut, - const ImVec2 v, - float cos_a, - float sin_a) { - *pOut = ImRotate(v, cos_a, sin_a); -} -CIMGUI_API float igImLinearSweep(float current, float target, float speed) { - return ImLinearSweep(current, target, speed); -} -CIMGUI_API void igImMul(ImVec2* pOut, const ImVec2 lhs, const ImVec2 rhs) { - *pOut = ImMul(lhs, rhs); -} -CIMGUI_API bool igImIsFloatAboveGuaranteedIntegerPrecision(float f) { - return ImIsFloatAboveGuaranteedIntegerPrecision(f); -} -CIMGUI_API float igImExponentialMovingAverage(float avg, float sample, int n) { - return ImExponentialMovingAverage(avg, sample, n); -} -CIMGUI_API void igImBezierCubicCalc(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - float t) { - *pOut = ImBezierCubicCalc(p1, p2, p3, p4, t); -} -CIMGUI_API void igImBezierCubicClosestPoint(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - const ImVec2 p, - int num_segments) { - *pOut = ImBezierCubicClosestPoint(p1, p2, p3, p4, p, num_segments); -} -CIMGUI_API void igImBezierCubicClosestPointCasteljau(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - const ImVec2 p, - float tess_tol) { - *pOut = ImBezierCubicClosestPointCasteljau(p1, p2, p3, p4, p, tess_tol); -} -CIMGUI_API void igImBezierQuadraticCalc(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - float t) { - *pOut = ImBezierQuadraticCalc(p1, p2, p3, t); -} -CIMGUI_API void igImLineClosestPoint(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - const ImVec2 p) { - *pOut = ImLineClosestPoint(a, b, p); -} -CIMGUI_API bool igImTriangleContainsPoint(const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 p) { - return ImTriangleContainsPoint(a, b, c, p); -} -CIMGUI_API void igImTriangleClosestPoint(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 p) { - *pOut = ImTriangleClosestPoint(a, b, c, p); -} -CIMGUI_API void igImTriangleBarycentricCoords(const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 p, - float* out_u, - float* out_v, - float* out_w) { - return ImTriangleBarycentricCoords(a, b, c, p, *out_u, *out_v, *out_w); -} -CIMGUI_API float igImTriangleArea(const ImVec2 a, - const ImVec2 b, - const ImVec2 c) { - return ImTriangleArea(a, b, c); -} -CIMGUI_API bool igImTriangleIsClockwise(const ImVec2 a, - const ImVec2 b, - const ImVec2 c) { - return ImTriangleIsClockwise(a, b, c); -} -CIMGUI_API ImVec1* ImVec1_ImVec1_Nil(void) { - return IM_NEW(ImVec1)(); -} -CIMGUI_API void ImVec1_destroy(ImVec1* self) { - IM_DELETE(self); -} -CIMGUI_API ImVec1* ImVec1_ImVec1_Float(float _x) { - return IM_NEW(ImVec1)(_x); -} -CIMGUI_API ImVec2ih* ImVec2ih_ImVec2ih_Nil(void) { - return IM_NEW(ImVec2ih)(); -} -CIMGUI_API void ImVec2ih_destroy(ImVec2ih* self) { - IM_DELETE(self); -} -CIMGUI_API ImVec2ih* ImVec2ih_ImVec2ih_short(short _x, short _y) { - return IM_NEW(ImVec2ih)(_x, _y); -} -CIMGUI_API ImVec2ih* ImVec2ih_ImVec2ih_Vec2(const ImVec2 rhs) { - return IM_NEW(ImVec2ih)(rhs); -} -CIMGUI_API ImRect* ImRect_ImRect_Nil(void) { - return IM_NEW(ImRect)(); -} -CIMGUI_API void ImRect_destroy(ImRect* self) { - IM_DELETE(self); -} -CIMGUI_API ImRect* ImRect_ImRect_Vec2(const ImVec2 min, const ImVec2 max) { - return IM_NEW(ImRect)(min, max); -} -CIMGUI_API ImRect* ImRect_ImRect_Vec4(const ImVec4 v) { - return IM_NEW(ImRect)(v); -} -CIMGUI_API ImRect* ImRect_ImRect_Float(float x1, float y1, float x2, float y2) { - return IM_NEW(ImRect)(x1, y1, x2, y2); -} -CIMGUI_API void ImRect_GetCenter(ImVec2* pOut, ImRect* self) { - *pOut = self->GetCenter(); -} -CIMGUI_API void ImRect_GetSize(ImVec2* pOut, ImRect* self) { - *pOut = self->GetSize(); -} -CIMGUI_API float ImRect_GetWidth(ImRect* self) { - return self->GetWidth(); -} -CIMGUI_API float ImRect_GetHeight(ImRect* self) { - return self->GetHeight(); -} -CIMGUI_API float ImRect_GetArea(ImRect* self) { - return self->GetArea(); -} -CIMGUI_API void ImRect_GetTL(ImVec2* pOut, ImRect* self) { - *pOut = self->GetTL(); -} -CIMGUI_API void ImRect_GetTR(ImVec2* pOut, ImRect* self) { - *pOut = self->GetTR(); -} -CIMGUI_API void ImRect_GetBL(ImVec2* pOut, ImRect* self) { - *pOut = self->GetBL(); -} -CIMGUI_API void ImRect_GetBR(ImVec2* pOut, ImRect* self) { - *pOut = self->GetBR(); -} -CIMGUI_API bool ImRect_Contains_Vec2(ImRect* self, const ImVec2 p) { - return self->Contains(p); -} -CIMGUI_API bool ImRect_Contains_Rect(ImRect* self, const ImRect r) { - return self->Contains(r); -} -CIMGUI_API bool ImRect_ContainsWithPad(ImRect* self, - const ImVec2 p, - const ImVec2 pad) { - return self->ContainsWithPad(p, pad); -} -CIMGUI_API bool ImRect_Overlaps(ImRect* self, const ImRect r) { - return self->Overlaps(r); -} -CIMGUI_API void ImRect_Add_Vec2(ImRect* self, const ImVec2 p) { - return self->Add(p); -} -CIMGUI_API void ImRect_Add_Rect(ImRect* self, const ImRect r) { - return self->Add(r); -} -CIMGUI_API void ImRect_Expand_Float(ImRect* self, const float amount) { - return self->Expand(amount); -} -CIMGUI_API void ImRect_Expand_Vec2(ImRect* self, const ImVec2 amount) { - return self->Expand(amount); -} -CIMGUI_API void ImRect_Translate(ImRect* self, const ImVec2 d) { - return self->Translate(d); -} -CIMGUI_API void ImRect_TranslateX(ImRect* self, float dx) { - return self->TranslateX(dx); -} -CIMGUI_API void ImRect_TranslateY(ImRect* self, float dy) { - return self->TranslateY(dy); -} -CIMGUI_API void ImRect_ClipWith(ImRect* self, const ImRect r) { - return self->ClipWith(r); -} -CIMGUI_API void ImRect_ClipWithFull(ImRect* self, const ImRect r) { - return self->ClipWithFull(r); -} -CIMGUI_API void ImRect_Floor(ImRect* self) { - return self->Floor(); -} -CIMGUI_API bool ImRect_IsInverted(ImRect* self) { - return self->IsInverted(); -} -CIMGUI_API void ImRect_ToVec4(ImVec4* pOut, ImRect* self) { - *pOut = self->ToVec4(); -} -CIMGUI_API size_t igImBitArrayGetStorageSizeInBytes(int bitcount) { - return ImBitArrayGetStorageSizeInBytes(bitcount); -} -CIMGUI_API void igImBitArrayClearAllBits(ImU32* arr, int bitcount) { - return ImBitArrayClearAllBits(arr, bitcount); -} -CIMGUI_API bool igImBitArrayTestBit(const ImU32* arr, int n) { - return ImBitArrayTestBit(arr, n); -} -CIMGUI_API void igImBitArrayClearBit(ImU32* arr, int n) { - return ImBitArrayClearBit(arr, n); -} -CIMGUI_API void igImBitArraySetBit(ImU32* arr, int n) { - return ImBitArraySetBit(arr, n); -} -CIMGUI_API void igImBitArraySetBitRange(ImU32* arr, int n, int n2) { - return ImBitArraySetBitRange(arr, n, n2); -} -CIMGUI_API void ImBitVector_Create(ImBitVector* self, int sz) { - return self->Create(sz); -} -CIMGUI_API void ImBitVector_Clear(ImBitVector* self) { - return self->Clear(); -} -CIMGUI_API bool ImBitVector_TestBit(ImBitVector* self, int n) { - return self->TestBit(n); -} -CIMGUI_API void ImBitVector_SetBit(ImBitVector* self, int n) { - return self->SetBit(n); -} -CIMGUI_API void ImBitVector_ClearBit(ImBitVector* self, int n) { - return self->ClearBit(n); -} -CIMGUI_API void ImGuiTextIndex_clear(ImGuiTextIndex* self) { - return self->clear(); -} -CIMGUI_API int ImGuiTextIndex_size(ImGuiTextIndex* self) { - return self->size(); -} -CIMGUI_API const char* ImGuiTextIndex_get_line_begin(ImGuiTextIndex* self, - const char* base, - int n) { - return self->get_line_begin(base, n); -} -CIMGUI_API const char* ImGuiTextIndex_get_line_end(ImGuiTextIndex* self, - const char* base, - int n) { - return self->get_line_end(base, n); -} -CIMGUI_API void ImGuiTextIndex_append(ImGuiTextIndex* self, - const char* base, - int old_size, - int new_size) { - return self->append(base, old_size, new_size); -} -CIMGUI_API ImDrawListSharedData* ImDrawListSharedData_ImDrawListSharedData( - void) { - return IM_NEW(ImDrawListSharedData)(); -} -CIMGUI_API void ImDrawListSharedData_destroy(ImDrawListSharedData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImDrawListSharedData_SetCircleTessellationMaxError( - ImDrawListSharedData* self, - float max_error) { - return self->SetCircleTessellationMaxError(max_error); -} -CIMGUI_API ImDrawDataBuilder* ImDrawDataBuilder_ImDrawDataBuilder(void) { - return IM_NEW(ImDrawDataBuilder)(); -} -CIMGUI_API void ImDrawDataBuilder_destroy(ImDrawDataBuilder* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiStyleMod* ImGuiStyleMod_ImGuiStyleMod_Int(ImGuiStyleVar idx, - int v) { - return IM_NEW(ImGuiStyleMod)(idx, v); -} -CIMGUI_API void ImGuiStyleMod_destroy(ImGuiStyleMod* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiStyleMod* ImGuiStyleMod_ImGuiStyleMod_Float(ImGuiStyleVar idx, - float v) { - return IM_NEW(ImGuiStyleMod)(idx, v); -} -CIMGUI_API ImGuiStyleMod* ImGuiStyleMod_ImGuiStyleMod_Vec2(ImGuiStyleVar idx, - ImVec2 v) { - return IM_NEW(ImGuiStyleMod)(idx, v); -} -CIMGUI_API ImGuiComboPreviewData* ImGuiComboPreviewData_ImGuiComboPreviewData( - void) { - return IM_NEW(ImGuiComboPreviewData)(); -} -CIMGUI_API void ImGuiComboPreviewData_destroy(ImGuiComboPreviewData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiMenuColumns* ImGuiMenuColumns_ImGuiMenuColumns(void) { - return IM_NEW(ImGuiMenuColumns)(); -} -CIMGUI_API void ImGuiMenuColumns_destroy(ImGuiMenuColumns* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiMenuColumns_Update(ImGuiMenuColumns* self, - float spacing, - bool window_reappearing) { - return self->Update(spacing, window_reappearing); -} -CIMGUI_API float ImGuiMenuColumns_DeclColumns(ImGuiMenuColumns* self, - float w_icon, - float w_label, - float w_shortcut, - float w_mark) { - return self->DeclColumns(w_icon, w_label, w_shortcut, w_mark); -} -CIMGUI_API void ImGuiMenuColumns_CalcNextTotalWidth(ImGuiMenuColumns* self, - bool update_offsets) { - return self->CalcNextTotalWidth(update_offsets); -} -CIMGUI_API ImGuiInputTextDeactivatedState* -ImGuiInputTextDeactivatedState_ImGuiInputTextDeactivatedState(void) { - return IM_NEW(ImGuiInputTextDeactivatedState)(); -} -CIMGUI_API void ImGuiInputTextDeactivatedState_destroy( - ImGuiInputTextDeactivatedState* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiInputTextDeactivatedState_ClearFreeMemory( - ImGuiInputTextDeactivatedState* self) { - return self->ClearFreeMemory(); -} -CIMGUI_API ImGuiInputTextState* ImGuiInputTextState_ImGuiInputTextState(void) { - return IM_NEW(ImGuiInputTextState)(); -} -CIMGUI_API void ImGuiInputTextState_destroy(ImGuiInputTextState* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiInputTextState_ClearText(ImGuiInputTextState* self) { - return self->ClearText(); -} -CIMGUI_API void ImGuiInputTextState_ClearFreeMemory(ImGuiInputTextState* self) { - return self->ClearFreeMemory(); -} -CIMGUI_API int ImGuiInputTextState_GetUndoAvailCount( - ImGuiInputTextState* self) { - return self->GetUndoAvailCount(); -} -CIMGUI_API int ImGuiInputTextState_GetRedoAvailCount( - ImGuiInputTextState* self) { - return self->GetRedoAvailCount(); -} -CIMGUI_API void ImGuiInputTextState_OnKeyPressed(ImGuiInputTextState* self, - int key) { - return self->OnKeyPressed(key); -} -CIMGUI_API void ImGuiInputTextState_CursorAnimReset(ImGuiInputTextState* self) { - return self->CursorAnimReset(); -} -CIMGUI_API void ImGuiInputTextState_CursorClamp(ImGuiInputTextState* self) { - return self->CursorClamp(); -} -CIMGUI_API bool ImGuiInputTextState_HasSelection(ImGuiInputTextState* self) { - return self->HasSelection(); -} -CIMGUI_API void ImGuiInputTextState_ClearSelection(ImGuiInputTextState* self) { - return self->ClearSelection(); -} -CIMGUI_API int ImGuiInputTextState_GetCursorPos(ImGuiInputTextState* self) { - return self->GetCursorPos(); -} -CIMGUI_API int ImGuiInputTextState_GetSelectionStart( - ImGuiInputTextState* self) { - return self->GetSelectionStart(); -} -CIMGUI_API int ImGuiInputTextState_GetSelectionEnd(ImGuiInputTextState* self) { - return self->GetSelectionEnd(); -} -CIMGUI_API void ImGuiInputTextState_SelectAll(ImGuiInputTextState* self) { - return self->SelectAll(); -} -CIMGUI_API void ImGuiInputTextState_ReloadUserBufAndSelectAll( - ImGuiInputTextState* self) { - return self->ReloadUserBufAndSelectAll(); -} -CIMGUI_API void ImGuiInputTextState_ReloadUserBufAndKeepSelection( - ImGuiInputTextState* self) { - return self->ReloadUserBufAndKeepSelection(); -} -CIMGUI_API void ImGuiInputTextState_ReloadUserBufAndMoveToEnd( - ImGuiInputTextState* self) { - return self->ReloadUserBufAndMoveToEnd(); -} -CIMGUI_API ImGuiNextWindowData* ImGuiNextWindowData_ImGuiNextWindowData(void) { - return IM_NEW(ImGuiNextWindowData)(); -} -CIMGUI_API void ImGuiNextWindowData_destroy(ImGuiNextWindowData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiNextWindowData_ClearFlags(ImGuiNextWindowData* self) { - return self->ClearFlags(); -} -CIMGUI_API ImGuiNextItemData* ImGuiNextItemData_ImGuiNextItemData(void) { - return IM_NEW(ImGuiNextItemData)(); -} -CIMGUI_API void ImGuiNextItemData_destroy(ImGuiNextItemData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiNextItemData_ClearFlags(ImGuiNextItemData* self) { - return self->ClearFlags(); -} -CIMGUI_API ImGuiLastItemData* ImGuiLastItemData_ImGuiLastItemData(void) { - return IM_NEW(ImGuiLastItemData)(); -} -CIMGUI_API void ImGuiLastItemData_destroy(ImGuiLastItemData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiStackSizes* ImGuiStackSizes_ImGuiStackSizes(void) { - return IM_NEW(ImGuiStackSizes)(); -} -CIMGUI_API void ImGuiStackSizes_destroy(ImGuiStackSizes* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiStackSizes_SetToContextState(ImGuiStackSizes* self, - ImGuiContext* ctx) { - return self->SetToContextState(ctx); -} -CIMGUI_API void ImGuiStackSizes_CompareWithContextState(ImGuiStackSizes* self, - ImGuiContext* ctx) { - return self->CompareWithContextState(ctx); -} -CIMGUI_API ImGuiPtrOrIndex* ImGuiPtrOrIndex_ImGuiPtrOrIndex_Ptr(void* ptr) { - return IM_NEW(ImGuiPtrOrIndex)(ptr); -} -CIMGUI_API void ImGuiPtrOrIndex_destroy(ImGuiPtrOrIndex* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiPtrOrIndex* ImGuiPtrOrIndex_ImGuiPtrOrIndex_Int(int index) { - return IM_NEW(ImGuiPtrOrIndex)(index); -} -CIMGUI_API void* ImGuiDataVarInfo_GetVarPtr(ImGuiDataVarInfo* self, - void* parent) { - return self->GetVarPtr(parent); -} -CIMGUI_API ImGuiPopupData* ImGuiPopupData_ImGuiPopupData(void) { - return IM_NEW(ImGuiPopupData)(); -} -CIMGUI_API void ImGuiPopupData_destroy(ImGuiPopupData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiInputEvent* ImGuiInputEvent_ImGuiInputEvent(void) { - return IM_NEW(ImGuiInputEvent)(); -} -CIMGUI_API void ImGuiInputEvent_destroy(ImGuiInputEvent* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiKeyRoutingData* ImGuiKeyRoutingData_ImGuiKeyRoutingData(void) { - return IM_NEW(ImGuiKeyRoutingData)(); -} -CIMGUI_API void ImGuiKeyRoutingData_destroy(ImGuiKeyRoutingData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiKeyRoutingTable* ImGuiKeyRoutingTable_ImGuiKeyRoutingTable( - void) { - return IM_NEW(ImGuiKeyRoutingTable)(); -} -CIMGUI_API void ImGuiKeyRoutingTable_destroy(ImGuiKeyRoutingTable* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiKeyRoutingTable_Clear(ImGuiKeyRoutingTable* self) { - return self->Clear(); -} -CIMGUI_API ImGuiKeyOwnerData* ImGuiKeyOwnerData_ImGuiKeyOwnerData(void) { - return IM_NEW(ImGuiKeyOwnerData)(); -} -CIMGUI_API void ImGuiKeyOwnerData_destroy(ImGuiKeyOwnerData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiListClipperRange ImGuiListClipperRange_FromIndices(int min, - int max) { - return ImGuiListClipperRange::FromIndices(min, max); -} -CIMGUI_API ImGuiListClipperRange -ImGuiListClipperRange_FromPositions(float y1, - float y2, - int off_min, - int off_max) { - return ImGuiListClipperRange::FromPositions(y1, y2, off_min, off_max); -} -CIMGUI_API ImGuiListClipperData* ImGuiListClipperData_ImGuiListClipperData( - void) { - return IM_NEW(ImGuiListClipperData)(); -} -CIMGUI_API void ImGuiListClipperData_destroy(ImGuiListClipperData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiListClipperData_Reset(ImGuiListClipperData* self, - ImGuiListClipper* clipper) { - return self->Reset(clipper); -} -CIMGUI_API ImGuiNavItemData* ImGuiNavItemData_ImGuiNavItemData(void) { - return IM_NEW(ImGuiNavItemData)(); -} -CIMGUI_API void ImGuiNavItemData_destroy(ImGuiNavItemData* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiNavItemData_Clear(ImGuiNavItemData* self) { - return self->Clear(); -} -CIMGUI_API ImGuiTypingSelectState* -ImGuiTypingSelectState_ImGuiTypingSelectState(void) { - return IM_NEW(ImGuiTypingSelectState)(); -} -CIMGUI_API void ImGuiTypingSelectState_destroy(ImGuiTypingSelectState* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiTypingSelectState_Clear(ImGuiTypingSelectState* self) { - return self->Clear(); -} -CIMGUI_API ImGuiOldColumnData* ImGuiOldColumnData_ImGuiOldColumnData(void) { - return IM_NEW(ImGuiOldColumnData)(); -} -CIMGUI_API void ImGuiOldColumnData_destroy(ImGuiOldColumnData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiOldColumns* ImGuiOldColumns_ImGuiOldColumns(void) { - return IM_NEW(ImGuiOldColumns)(); -} -CIMGUI_API void ImGuiOldColumns_destroy(ImGuiOldColumns* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiDockNode* ImGuiDockNode_ImGuiDockNode(ImGuiID id) { - return IM_NEW(ImGuiDockNode)(id); -} -CIMGUI_API void ImGuiDockNode_destroy(ImGuiDockNode* self) { - IM_DELETE(self); -} -CIMGUI_API bool ImGuiDockNode_IsRootNode(ImGuiDockNode* self) { - return self->IsRootNode(); -} -CIMGUI_API bool ImGuiDockNode_IsDockSpace(ImGuiDockNode* self) { - return self->IsDockSpace(); -} -CIMGUI_API bool ImGuiDockNode_IsFloatingNode(ImGuiDockNode* self) { - return self->IsFloatingNode(); -} -CIMGUI_API bool ImGuiDockNode_IsCentralNode(ImGuiDockNode* self) { - return self->IsCentralNode(); -} -CIMGUI_API bool ImGuiDockNode_IsHiddenTabBar(ImGuiDockNode* self) { - return self->IsHiddenTabBar(); -} -CIMGUI_API bool ImGuiDockNode_IsNoTabBar(ImGuiDockNode* self) { - return self->IsNoTabBar(); -} -CIMGUI_API bool ImGuiDockNode_IsSplitNode(ImGuiDockNode* self) { - return self->IsSplitNode(); -} -CIMGUI_API bool ImGuiDockNode_IsLeafNode(ImGuiDockNode* self) { - return self->IsLeafNode(); -} -CIMGUI_API bool ImGuiDockNode_IsEmpty(ImGuiDockNode* self) { - return self->IsEmpty(); -} -CIMGUI_API void ImGuiDockNode_Rect(ImRect* pOut, ImGuiDockNode* self) { - *pOut = self->Rect(); -} -CIMGUI_API void ImGuiDockNode_SetLocalFlags(ImGuiDockNode* self, - ImGuiDockNodeFlags flags) { - return self->SetLocalFlags(flags); -} -CIMGUI_API void ImGuiDockNode_UpdateMergedFlags(ImGuiDockNode* self) { - return self->UpdateMergedFlags(); -} -CIMGUI_API ImGuiDockContext* ImGuiDockContext_ImGuiDockContext(void) { - return IM_NEW(ImGuiDockContext)(); -} -CIMGUI_API void ImGuiDockContext_destroy(ImGuiDockContext* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiViewportP* ImGuiViewportP_ImGuiViewportP(void) { - return IM_NEW(ImGuiViewportP)(); -} -CIMGUI_API void ImGuiViewportP_destroy(ImGuiViewportP* self) { - IM_DELETE(self); -} -CIMGUI_API void ImGuiViewportP_ClearRequestFlags(ImGuiViewportP* self) { - return self->ClearRequestFlags(); -} -CIMGUI_API void ImGuiViewportP_CalcWorkRectPos(ImVec2* pOut, - ImGuiViewportP* self, - const ImVec2 off_min) { - *pOut = self->CalcWorkRectPos(off_min); -} -CIMGUI_API void ImGuiViewportP_CalcWorkRectSize(ImVec2* pOut, - ImGuiViewportP* self, - const ImVec2 off_min, - const ImVec2 off_max) { - *pOut = self->CalcWorkRectSize(off_min, off_max); -} -CIMGUI_API void ImGuiViewportP_UpdateWorkRect(ImGuiViewportP* self) { - return self->UpdateWorkRect(); -} -CIMGUI_API void ImGuiViewportP_GetMainRect(ImRect* pOut, ImGuiViewportP* self) { - *pOut = self->GetMainRect(); -} -CIMGUI_API void ImGuiViewportP_GetWorkRect(ImRect* pOut, ImGuiViewportP* self) { - *pOut = self->GetWorkRect(); -} -CIMGUI_API void ImGuiViewportP_GetBuildWorkRect(ImRect* pOut, - ImGuiViewportP* self) { - *pOut = self->GetBuildWorkRect(); -} -CIMGUI_API ImGuiWindowSettings* ImGuiWindowSettings_ImGuiWindowSettings(void) { - return IM_NEW(ImGuiWindowSettings)(); -} -CIMGUI_API void ImGuiWindowSettings_destroy(ImGuiWindowSettings* self) { - IM_DELETE(self); -} -CIMGUI_API char* ImGuiWindowSettings_GetName(ImGuiWindowSettings* self) { - return self->GetName(); -} -CIMGUI_API ImGuiSettingsHandler* ImGuiSettingsHandler_ImGuiSettingsHandler( - void) { - return IM_NEW(ImGuiSettingsHandler)(); -} -CIMGUI_API void ImGuiSettingsHandler_destroy(ImGuiSettingsHandler* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiDebugAllocInfo* ImGuiDebugAllocInfo_ImGuiDebugAllocInfo(void) { - return IM_NEW(ImGuiDebugAllocInfo)(); -} -CIMGUI_API void ImGuiDebugAllocInfo_destroy(ImGuiDebugAllocInfo* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiStackLevelInfo* ImGuiStackLevelInfo_ImGuiStackLevelInfo(void) { - return IM_NEW(ImGuiStackLevelInfo)(); -} -CIMGUI_API void ImGuiStackLevelInfo_destroy(ImGuiStackLevelInfo* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiIDStackTool* ImGuiIDStackTool_ImGuiIDStackTool(void) { - return IM_NEW(ImGuiIDStackTool)(); -} -CIMGUI_API void ImGuiIDStackTool_destroy(ImGuiIDStackTool* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiContextHook* ImGuiContextHook_ImGuiContextHook(void) { - return IM_NEW(ImGuiContextHook)(); -} -CIMGUI_API void ImGuiContextHook_destroy(ImGuiContextHook* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiContext* ImGuiContext_ImGuiContext( - ImFontAtlas* shared_font_atlas) { - return IM_NEW(ImGuiContext)(shared_font_atlas); -} -CIMGUI_API void ImGuiContext_destroy(ImGuiContext* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiWindow* ImGuiWindow_ImGuiWindow(ImGuiContext* context, - const char* name) { - return IM_NEW(ImGuiWindow)(context, name); -} -CIMGUI_API void ImGuiWindow_destroy(ImGuiWindow* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiID ImGuiWindow_GetID_Str(ImGuiWindow* self, - const char* str, - const char* str_end) { - return self->GetID(str, str_end); -} -CIMGUI_API ImGuiID ImGuiWindow_GetID_Ptr(ImGuiWindow* self, const void* ptr) { - return self->GetID(ptr); -} -CIMGUI_API ImGuiID ImGuiWindow_GetID_Int(ImGuiWindow* self, int n) { - return self->GetID(n); -} -CIMGUI_API ImGuiID ImGuiWindow_GetIDFromRectangle(ImGuiWindow* self, - const ImRect r_abs) { - return self->GetIDFromRectangle(r_abs); -} -CIMGUI_API void ImGuiWindow_Rect(ImRect* pOut, ImGuiWindow* self) { - *pOut = self->Rect(); -} -CIMGUI_API float ImGuiWindow_CalcFontSize(ImGuiWindow* self) { - return self->CalcFontSize(); -} -CIMGUI_API float ImGuiWindow_TitleBarHeight(ImGuiWindow* self) { - return self->TitleBarHeight(); -} -CIMGUI_API void ImGuiWindow_TitleBarRect(ImRect* pOut, ImGuiWindow* self) { - *pOut = self->TitleBarRect(); -} -CIMGUI_API float ImGuiWindow_MenuBarHeight(ImGuiWindow* self) { - return self->MenuBarHeight(); -} -CIMGUI_API void ImGuiWindow_MenuBarRect(ImRect* pOut, ImGuiWindow* self) { - *pOut = self->MenuBarRect(); -} -CIMGUI_API ImGuiTabItem* ImGuiTabItem_ImGuiTabItem(void) { - return IM_NEW(ImGuiTabItem)(); -} -CIMGUI_API void ImGuiTabItem_destroy(ImGuiTabItem* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTabBar* ImGuiTabBar_ImGuiTabBar(void) { - return IM_NEW(ImGuiTabBar)(); -} -CIMGUI_API void ImGuiTabBar_destroy(ImGuiTabBar* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableColumn* ImGuiTableColumn_ImGuiTableColumn(void) { - return IM_NEW(ImGuiTableColumn)(); -} -CIMGUI_API void ImGuiTableColumn_destroy(ImGuiTableColumn* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableInstanceData* -ImGuiTableInstanceData_ImGuiTableInstanceData(void) { - return IM_NEW(ImGuiTableInstanceData)(); -} -CIMGUI_API void ImGuiTableInstanceData_destroy(ImGuiTableInstanceData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTable* ImGuiTable_ImGuiTable(void) { - return IM_NEW(ImGuiTable)(); -} -CIMGUI_API void ImGuiTable_destroy(ImGuiTable* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableTempData* ImGuiTableTempData_ImGuiTableTempData(void) { - return IM_NEW(ImGuiTableTempData)(); -} -CIMGUI_API void ImGuiTableTempData_destroy(ImGuiTableTempData* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableColumnSettings* -ImGuiTableColumnSettings_ImGuiTableColumnSettings(void) { - return IM_NEW(ImGuiTableColumnSettings)(); -} -CIMGUI_API void ImGuiTableColumnSettings_destroy( - ImGuiTableColumnSettings* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableSettings* ImGuiTableSettings_ImGuiTableSettings(void) { - return IM_NEW(ImGuiTableSettings)(); -} -CIMGUI_API void ImGuiTableSettings_destroy(ImGuiTableSettings* self) { - IM_DELETE(self); -} -CIMGUI_API ImGuiTableColumnSettings* ImGuiTableSettings_GetColumnSettings( - ImGuiTableSettings* self) { - return self->GetColumnSettings(); -} -CIMGUI_API ImGuiWindow* igGetCurrentWindowRead() { - return ImGui::GetCurrentWindowRead(); -} -CIMGUI_API ImGuiWindow* igGetCurrentWindow() { - return ImGui::GetCurrentWindow(); -} -CIMGUI_API ImGuiWindow* igFindWindowByID(ImGuiID id) { - return ImGui::FindWindowByID(id); -} -CIMGUI_API ImGuiWindow* igFindWindowByName(const char* name) { - return ImGui::FindWindowByName(name); -} -CIMGUI_API void igUpdateWindowParentAndRootLinks(ImGuiWindow* window, - ImGuiWindowFlags flags, - ImGuiWindow* parent_window) { - return ImGui::UpdateWindowParentAndRootLinks(window, flags, parent_window); -} -CIMGUI_API void igUpdateWindowSkipRefresh(ImGuiWindow* window) { - return ImGui::UpdateWindowSkipRefresh(window); -} -CIMGUI_API void igCalcWindowNextAutoFitSize(ImVec2* pOut, ImGuiWindow* window) { - *pOut = ImGui::CalcWindowNextAutoFitSize(window); -} -CIMGUI_API bool igIsWindowChildOf(ImGuiWindow* window, - ImGuiWindow* potential_parent, - bool popup_hierarchy, - bool dock_hierarchy) { - return ImGui::IsWindowChildOf(window, potential_parent, popup_hierarchy, - dock_hierarchy); -} -CIMGUI_API bool igIsWindowWithinBeginStackOf(ImGuiWindow* window, - ImGuiWindow* potential_parent) { - return ImGui::IsWindowWithinBeginStackOf(window, potential_parent); -} -CIMGUI_API bool igIsWindowAbove(ImGuiWindow* potential_above, - ImGuiWindow* potential_below) { - return ImGui::IsWindowAbove(potential_above, potential_below); -} -CIMGUI_API bool igIsWindowNavFocusable(ImGuiWindow* window) { - return ImGui::IsWindowNavFocusable(window); -} -CIMGUI_API void igSetWindowPos_WindowPtr(ImGuiWindow* window, - const ImVec2 pos, - ImGuiCond cond) { - return ImGui::SetWindowPos(window, pos, cond); -} -CIMGUI_API void igSetWindowSize_WindowPtr(ImGuiWindow* window, - const ImVec2 size, - ImGuiCond cond) { - return ImGui::SetWindowSize(window, size, cond); -} -CIMGUI_API void igSetWindowCollapsed_WindowPtr(ImGuiWindow* window, - bool collapsed, - ImGuiCond cond) { - return ImGui::SetWindowCollapsed(window, collapsed, cond); -} -CIMGUI_API void igSetWindowHitTestHole(ImGuiWindow* window, - const ImVec2 pos, - const ImVec2 size) { - return ImGui::SetWindowHitTestHole(window, pos, size); -} -CIMGUI_API void igSetWindowHiddenAndSkipItemsForCurrentFrame( - ImGuiWindow* window) { - return ImGui::SetWindowHiddenAndSkipItemsForCurrentFrame(window); -} -CIMGUI_API void igSetWindowParentWindowForFocusRoute( - ImGuiWindow* window, - ImGuiWindow* parent_window) { - return ImGui::SetWindowParentWindowForFocusRoute(window, parent_window); -} -CIMGUI_API void igWindowRectAbsToRel(ImRect* pOut, - ImGuiWindow* window, - const ImRect r) { - *pOut = ImGui::WindowRectAbsToRel(window, r); -} -CIMGUI_API void igWindowRectRelToAbs(ImRect* pOut, - ImGuiWindow* window, - const ImRect r) { - *pOut = ImGui::WindowRectRelToAbs(window, r); -} -CIMGUI_API void igWindowPosRelToAbs(ImVec2* pOut, - ImGuiWindow* window, - const ImVec2 p) { - *pOut = ImGui::WindowPosRelToAbs(window, p); -} -CIMGUI_API void igFocusWindow(ImGuiWindow* window, - ImGuiFocusRequestFlags flags) { - return ImGui::FocusWindow(window, flags); -} -CIMGUI_API void igFocusTopMostWindowUnderOne(ImGuiWindow* under_this_window, - ImGuiWindow* ignore_window, - ImGuiViewport* filter_viewport, - ImGuiFocusRequestFlags flags) { - return ImGui::FocusTopMostWindowUnderOne(under_this_window, ignore_window, - filter_viewport, flags); -} -CIMGUI_API void igBringWindowToFocusFront(ImGuiWindow* window) { - return ImGui::BringWindowToFocusFront(window); -} -CIMGUI_API void igBringWindowToDisplayFront(ImGuiWindow* window) { - return ImGui::BringWindowToDisplayFront(window); -} -CIMGUI_API void igBringWindowToDisplayBack(ImGuiWindow* window) { - return ImGui::BringWindowToDisplayBack(window); -} -CIMGUI_API void igBringWindowToDisplayBehind(ImGuiWindow* window, - ImGuiWindow* above_window) { - return ImGui::BringWindowToDisplayBehind(window, above_window); -} -CIMGUI_API int igFindWindowDisplayIndex(ImGuiWindow* window) { - return ImGui::FindWindowDisplayIndex(window); -} -CIMGUI_API ImGuiWindow* igFindBottomMostVisibleWindowWithinBeginStack( - ImGuiWindow* window) { - return ImGui::FindBottomMostVisibleWindowWithinBeginStack(window); -} -CIMGUI_API void igSetNextWindowRefreshPolicy(ImGuiWindowRefreshFlags flags) { - return ImGui::SetNextWindowRefreshPolicy(flags); -} -CIMGUI_API void igSetCurrentFont(ImFont* font) { - return ImGui::SetCurrentFont(font); -} -CIMGUI_API ImFont* igGetDefaultFont() { - return ImGui::GetDefaultFont(); -} -CIMGUI_API ImDrawList* igGetForegroundDrawList_WindowPtr(ImGuiWindow* window) { - return ImGui::GetForegroundDrawList(window); -} -CIMGUI_API void igAddDrawListToDrawDataEx(ImDrawData* draw_data, - ImVector_ImDrawListPtr* out_list, - ImDrawList* draw_list) { - return ImGui::AddDrawListToDrawDataEx(draw_data, out_list, draw_list); -} -CIMGUI_API void igInitialize() { - return ImGui::Initialize(); -} -CIMGUI_API void igShutdown() { - return ImGui::Shutdown(); -} -CIMGUI_API void igUpdateInputEvents(bool trickle_fast_inputs) { - return ImGui::UpdateInputEvents(trickle_fast_inputs); -} -CIMGUI_API void igUpdateHoveredWindowAndCaptureFlags() { - return ImGui::UpdateHoveredWindowAndCaptureFlags(); -} -CIMGUI_API void igStartMouseMovingWindow(ImGuiWindow* window) { - return ImGui::StartMouseMovingWindow(window); -} -CIMGUI_API void igStartMouseMovingWindowOrNode(ImGuiWindow* window, - ImGuiDockNode* node, - bool undock) { - return ImGui::StartMouseMovingWindowOrNode(window, node, undock); -} -CIMGUI_API void igUpdateMouseMovingWindowNewFrame() { - return ImGui::UpdateMouseMovingWindowNewFrame(); -} -CIMGUI_API void igUpdateMouseMovingWindowEndFrame() { - return ImGui::UpdateMouseMovingWindowEndFrame(); -} -CIMGUI_API ImGuiID igAddContextHook(ImGuiContext* context, - const ImGuiContextHook* hook) { - return ImGui::AddContextHook(context, hook); -} -CIMGUI_API void igRemoveContextHook(ImGuiContext* context, - ImGuiID hook_to_remove) { - return ImGui::RemoveContextHook(context, hook_to_remove); -} -CIMGUI_API void igCallContextHooks(ImGuiContext* context, - ImGuiContextHookType type) { - return ImGui::CallContextHooks(context, type); -} -CIMGUI_API void igTranslateWindowsInViewport(ImGuiViewportP* viewport, - const ImVec2 old_pos, - const ImVec2 new_pos) { - return ImGui::TranslateWindowsInViewport(viewport, old_pos, new_pos); -} -CIMGUI_API void igScaleWindowsInViewport(ImGuiViewportP* viewport, - float scale) { - return ImGui::ScaleWindowsInViewport(viewport, scale); -} -CIMGUI_API void igDestroyPlatformWindow(ImGuiViewportP* viewport) { - return ImGui::DestroyPlatformWindow(viewport); -} -CIMGUI_API void igSetWindowViewport(ImGuiWindow* window, - ImGuiViewportP* viewport) { - return ImGui::SetWindowViewport(window, viewport); -} -CIMGUI_API void igSetCurrentViewport(ImGuiWindow* window, - ImGuiViewportP* viewport) { - return ImGui::SetCurrentViewport(window, viewport); -} -CIMGUI_API const ImGuiPlatformMonitor* igGetViewportPlatformMonitor( - ImGuiViewport* viewport) { - return ImGui::GetViewportPlatformMonitor(viewport); -} -CIMGUI_API ImGuiViewportP* igFindHoveredViewportFromPlatformWindowStack( - const ImVec2 mouse_platform_pos) { - return ImGui::FindHoveredViewportFromPlatformWindowStack(mouse_platform_pos); -} -CIMGUI_API void igMarkIniSettingsDirty_Nil() { - return ImGui::MarkIniSettingsDirty(); -} -CIMGUI_API void igMarkIniSettingsDirty_WindowPtr(ImGuiWindow* window) { - return ImGui::MarkIniSettingsDirty(window); -} -CIMGUI_API void igClearIniSettings() { - return ImGui::ClearIniSettings(); -} -CIMGUI_API void igAddSettingsHandler(const ImGuiSettingsHandler* handler) { - return ImGui::AddSettingsHandler(handler); -} -CIMGUI_API void igRemoveSettingsHandler(const char* type_name) { - return ImGui::RemoveSettingsHandler(type_name); -} -CIMGUI_API ImGuiSettingsHandler* igFindSettingsHandler(const char* type_name) { - return ImGui::FindSettingsHandler(type_name); -} -CIMGUI_API ImGuiWindowSettings* igCreateNewWindowSettings(const char* name) { - return ImGui::CreateNewWindowSettings(name); -} -CIMGUI_API ImGuiWindowSettings* igFindWindowSettingsByID(ImGuiID id) { - return ImGui::FindWindowSettingsByID(id); -} -CIMGUI_API ImGuiWindowSettings* igFindWindowSettingsByWindow( - ImGuiWindow* window) { - return ImGui::FindWindowSettingsByWindow(window); -} -CIMGUI_API void igClearWindowSettings(const char* name) { - return ImGui::ClearWindowSettings(name); -} -CIMGUI_API void igLocalizeRegisterEntries(const ImGuiLocEntry* entries, - int count) { - return ImGui::LocalizeRegisterEntries(entries, count); -} -CIMGUI_API const char* igLocalizeGetMsg(ImGuiLocKey key) { - return ImGui::LocalizeGetMsg(key); -} -CIMGUI_API void igSetScrollX_WindowPtr(ImGuiWindow* window, float scroll_x) { - return ImGui::SetScrollX(window, scroll_x); -} -CIMGUI_API void igSetScrollY_WindowPtr(ImGuiWindow* window, float scroll_y) { - return ImGui::SetScrollY(window, scroll_y); -} -CIMGUI_API void igSetScrollFromPosX_WindowPtr(ImGuiWindow* window, - float local_x, - float center_x_ratio) { - return ImGui::SetScrollFromPosX(window, local_x, center_x_ratio); -} -CIMGUI_API void igSetScrollFromPosY_WindowPtr(ImGuiWindow* window, - float local_y, - float center_y_ratio) { - return ImGui::SetScrollFromPosY(window, local_y, center_y_ratio); -} -CIMGUI_API void igScrollToItem(ImGuiScrollFlags flags) { - return ImGui::ScrollToItem(flags); -} -CIMGUI_API void igScrollToRect(ImGuiWindow* window, - const ImRect rect, - ImGuiScrollFlags flags) { - return ImGui::ScrollToRect(window, rect, flags); -} -CIMGUI_API void igScrollToRectEx(ImVec2* pOut, - ImGuiWindow* window, - const ImRect rect, - ImGuiScrollFlags flags) { - *pOut = ImGui::ScrollToRectEx(window, rect, flags); -} -CIMGUI_API void igScrollToBringRectIntoView(ImGuiWindow* window, - const ImRect rect) { - return ImGui::ScrollToBringRectIntoView(window, rect); -} -CIMGUI_API ImGuiItemStatusFlags igGetItemStatusFlags() { - return ImGui::GetItemStatusFlags(); -} -CIMGUI_API ImGuiItemFlags igGetItemFlags() { - return ImGui::GetItemFlags(); -} -CIMGUI_API ImGuiID igGetActiveID() { - return ImGui::GetActiveID(); -} -CIMGUI_API ImGuiID igGetFocusID() { - return ImGui::GetFocusID(); -} -CIMGUI_API void igSetActiveID(ImGuiID id, ImGuiWindow* window) { - return ImGui::SetActiveID(id, window); -} -CIMGUI_API void igSetFocusID(ImGuiID id, ImGuiWindow* window) { - return ImGui::SetFocusID(id, window); -} -CIMGUI_API void igClearActiveID() { - return ImGui::ClearActiveID(); -} -CIMGUI_API ImGuiID igGetHoveredID() { - return ImGui::GetHoveredID(); -} -CIMGUI_API void igSetHoveredID(ImGuiID id) { - return ImGui::SetHoveredID(id); -} -CIMGUI_API void igKeepAliveID(ImGuiID id) { - return ImGui::KeepAliveID(id); -} -CIMGUI_API void igMarkItemEdited(ImGuiID id) { - return ImGui::MarkItemEdited(id); -} -CIMGUI_API void igPushOverrideID(ImGuiID id) { - return ImGui::PushOverrideID(id); -} -CIMGUI_API ImGuiID igGetIDWithSeed_Str(const char* str_id_begin, - const char* str_id_end, - ImGuiID seed) { - return ImGui::GetIDWithSeed(str_id_begin, str_id_end, seed); -} -CIMGUI_API ImGuiID igGetIDWithSeed_Int(int n, ImGuiID seed) { - return ImGui::GetIDWithSeed(n, seed); -} -CIMGUI_API void igItemSize_Vec2(const ImVec2 size, float text_baseline_y) { - return ImGui::ItemSize(size, text_baseline_y); -} -CIMGUI_API void igItemSize_Rect(const ImRect bb, float text_baseline_y) { - return ImGui::ItemSize(bb, text_baseline_y); -} -CIMGUI_API bool igItemAdd(const ImRect bb, - ImGuiID id, - const ImRect* nav_bb, - ImGuiItemFlags extra_flags) { - return ImGui::ItemAdd(bb, id, nav_bb, extra_flags); -} -CIMGUI_API bool igItemHoverable(const ImRect bb, - ImGuiID id, - ImGuiItemFlags item_flags) { - return ImGui::ItemHoverable(bb, id, item_flags); -} -CIMGUI_API bool igIsWindowContentHoverable(ImGuiWindow* window, - ImGuiHoveredFlags flags) { - return ImGui::IsWindowContentHoverable(window, flags); -} -CIMGUI_API bool igIsClippedEx(const ImRect bb, ImGuiID id) { - return ImGui::IsClippedEx(bb, id); -} -CIMGUI_API void igSetLastItemData(ImGuiID item_id, - ImGuiItemFlags in_flags, - ImGuiItemStatusFlags status_flags, - const ImRect item_rect) { - return ImGui::SetLastItemData(item_id, in_flags, status_flags, item_rect); -} -CIMGUI_API void igCalcItemSize(ImVec2* pOut, - ImVec2 size, - float default_w, - float default_h) { - *pOut = ImGui::CalcItemSize(size, default_w, default_h); -} -CIMGUI_API float igCalcWrapWidthForPos(const ImVec2 pos, float wrap_pos_x) { - return ImGui::CalcWrapWidthForPos(pos, wrap_pos_x); -} -CIMGUI_API void igPushMultiItemsWidths(int components, float width_full) { - return ImGui::PushMultiItemsWidths(components, width_full); -} -CIMGUI_API bool igIsItemToggledSelection() { - return ImGui::IsItemToggledSelection(); -} -CIMGUI_API void igGetContentRegionMaxAbs(ImVec2* pOut) { - *pOut = ImGui::GetContentRegionMaxAbs(); -} -CIMGUI_API void igShrinkWidths(ImGuiShrinkWidthItem* items, - int count, - float width_excess) { - return ImGui::ShrinkWidths(items, count, width_excess); -} -CIMGUI_API void igPushItemFlag(ImGuiItemFlags option, bool enabled) { - return ImGui::PushItemFlag(option, enabled); -} -CIMGUI_API void igPopItemFlag() { - return ImGui::PopItemFlag(); -} -CIMGUI_API const ImGuiDataVarInfo* igGetStyleVarInfo(ImGuiStyleVar idx) { - return ImGui::GetStyleVarInfo(idx); -} -CIMGUI_API void igLogBegin(ImGuiLogType type, int auto_open_depth) { - return ImGui::LogBegin(type, auto_open_depth); -} -CIMGUI_API void igLogToBuffer(int auto_open_depth) { - return ImGui::LogToBuffer(auto_open_depth); -} -CIMGUI_API void igLogRenderedText(const ImVec2* ref_pos, - const char* text, - const char* text_end) { - return ImGui::LogRenderedText(ref_pos, text, text_end); -} -CIMGUI_API void igLogSetNextTextDecoration(const char* prefix, - const char* suffix) { - return ImGui::LogSetNextTextDecoration(prefix, suffix); -} -CIMGUI_API bool igBeginChildEx(const char* name, - ImGuiID id, - const ImVec2 size_arg, - ImGuiChildFlags child_flags, - ImGuiWindowFlags window_flags) { - return ImGui::BeginChildEx(name, id, size_arg, child_flags, window_flags); -} -CIMGUI_API void igOpenPopupEx(ImGuiID id, ImGuiPopupFlags popup_flags) { - return ImGui::OpenPopupEx(id, popup_flags); -} -CIMGUI_API void igClosePopupToLevel(int remaining, - bool restore_focus_to_window_under_popup) { - return ImGui::ClosePopupToLevel(remaining, - restore_focus_to_window_under_popup); -} -CIMGUI_API void igClosePopupsOverWindow( - ImGuiWindow* ref_window, - bool restore_focus_to_window_under_popup) { - return ImGui::ClosePopupsOverWindow(ref_window, - restore_focus_to_window_under_popup); -} -CIMGUI_API void igClosePopupsExceptModals() { - return ImGui::ClosePopupsExceptModals(); -} -CIMGUI_API bool igIsPopupOpen_ID(ImGuiID id, ImGuiPopupFlags popup_flags) { - return ImGui::IsPopupOpen(id, popup_flags); -} -CIMGUI_API bool igBeginPopupEx(ImGuiID id, ImGuiWindowFlags extra_flags) { - return ImGui::BeginPopupEx(id, extra_flags); -} -CIMGUI_API bool igBeginTooltipEx(ImGuiTooltipFlags tooltip_flags, - ImGuiWindowFlags extra_window_flags) { - return ImGui::BeginTooltipEx(tooltip_flags, extra_window_flags); -} -CIMGUI_API bool igBeginTooltipHidden() { - return ImGui::BeginTooltipHidden(); -} -CIMGUI_API void igGetPopupAllowedExtentRect(ImRect* pOut, ImGuiWindow* window) { - *pOut = ImGui::GetPopupAllowedExtentRect(window); -} -CIMGUI_API ImGuiWindow* igGetTopMostPopupModal() { - return ImGui::GetTopMostPopupModal(); -} -CIMGUI_API ImGuiWindow* igGetTopMostAndVisiblePopupModal() { - return ImGui::GetTopMostAndVisiblePopupModal(); -} -CIMGUI_API ImGuiWindow* igFindBlockingModal(ImGuiWindow* window) { - return ImGui::FindBlockingModal(window); -} -CIMGUI_API void igFindBestWindowPosForPopup(ImVec2* pOut, ImGuiWindow* window) { - *pOut = ImGui::FindBestWindowPosForPopup(window); -} -CIMGUI_API void igFindBestWindowPosForPopupEx(ImVec2* pOut, - const ImVec2 ref_pos, - const ImVec2 size, - ImGuiDir* last_dir, - const ImRect r_outer, - const ImRect r_avoid, - ImGuiPopupPositionPolicy policy) { - *pOut = ImGui::FindBestWindowPosForPopupEx(ref_pos, size, last_dir, r_outer, - r_avoid, policy); -} -CIMGUI_API bool igBeginViewportSideBar(const char* name, - ImGuiViewport* viewport, - ImGuiDir dir, - float size, - ImGuiWindowFlags window_flags) { - return ImGui::BeginViewportSideBar(name, viewport, dir, size, window_flags); -} -CIMGUI_API bool igBeginMenuEx(const char* label, - const char* icon, - bool enabled) { - return ImGui::BeginMenuEx(label, icon, enabled); -} -CIMGUI_API bool igMenuItemEx(const char* label, - const char* icon, - const char* shortcut, - bool selected, - bool enabled) { - return ImGui::MenuItemEx(label, icon, shortcut, selected, enabled); -} -CIMGUI_API bool igBeginComboPopup(ImGuiID popup_id, - const ImRect bb, - ImGuiComboFlags flags) { - return ImGui::BeginComboPopup(popup_id, bb, flags); -} -CIMGUI_API bool igBeginComboPreview() { - return ImGui::BeginComboPreview(); -} -CIMGUI_API void igEndComboPreview() { - return ImGui::EndComboPreview(); -} -CIMGUI_API void igNavInitWindow(ImGuiWindow* window, bool force_reinit) { - return ImGui::NavInitWindow(window, force_reinit); -} -CIMGUI_API void igNavInitRequestApplyResult() { - return ImGui::NavInitRequestApplyResult(); -} -CIMGUI_API bool igNavMoveRequestButNoResultYet() { - return ImGui::NavMoveRequestButNoResultYet(); -} -CIMGUI_API void igNavMoveRequestSubmit(ImGuiDir move_dir, - ImGuiDir clip_dir, - ImGuiNavMoveFlags move_flags, - ImGuiScrollFlags scroll_flags) { - return ImGui::NavMoveRequestSubmit(move_dir, clip_dir, move_flags, - scroll_flags); -} -CIMGUI_API void igNavMoveRequestForward(ImGuiDir move_dir, - ImGuiDir clip_dir, - ImGuiNavMoveFlags move_flags, - ImGuiScrollFlags scroll_flags) { - return ImGui::NavMoveRequestForward(move_dir, clip_dir, move_flags, - scroll_flags); -} -CIMGUI_API void igNavMoveRequestResolveWithLastItem(ImGuiNavItemData* result) { - return ImGui::NavMoveRequestResolveWithLastItem(result); -} -CIMGUI_API void igNavMoveRequestResolveWithPastTreeNode( - ImGuiNavItemData* result, - ImGuiNavTreeNodeData* tree_node_data) { - return ImGui::NavMoveRequestResolveWithPastTreeNode(result, tree_node_data); -} -CIMGUI_API void igNavMoveRequestCancel() { - return ImGui::NavMoveRequestCancel(); -} -CIMGUI_API void igNavMoveRequestApplyResult() { - return ImGui::NavMoveRequestApplyResult(); -} -CIMGUI_API void igNavMoveRequestTryWrapping(ImGuiWindow* window, - ImGuiNavMoveFlags move_flags) { - return ImGui::NavMoveRequestTryWrapping(window, move_flags); -} -CIMGUI_API void igNavHighlightActivated(ImGuiID id) { - return ImGui::NavHighlightActivated(id); -} -CIMGUI_API void igNavClearPreferredPosForAxis(ImGuiAxis axis) { - return ImGui::NavClearPreferredPosForAxis(axis); -} -CIMGUI_API void igNavRestoreHighlightAfterMove() { - return ImGui::NavRestoreHighlightAfterMove(); -} -CIMGUI_API void igNavUpdateCurrentWindowIsScrollPushableX() { - return ImGui::NavUpdateCurrentWindowIsScrollPushableX(); -} -CIMGUI_API void igSetNavWindow(ImGuiWindow* window) { - return ImGui::SetNavWindow(window); -} -CIMGUI_API void igSetNavID(ImGuiID id, - ImGuiNavLayer nav_layer, - ImGuiID focus_scope_id, - const ImRect rect_rel) { - return ImGui::SetNavID(id, nav_layer, focus_scope_id, rect_rel); -} -CIMGUI_API void igSetNavFocusScope(ImGuiID focus_scope_id) { - return ImGui::SetNavFocusScope(focus_scope_id); -} -CIMGUI_API void igFocusItem() { - return ImGui::FocusItem(); -} -CIMGUI_API void igActivateItemByID(ImGuiID id) { - return ImGui::ActivateItemByID(id); -} -CIMGUI_API bool igIsNamedKey(ImGuiKey key) { - return ImGui::IsNamedKey(key); -} -CIMGUI_API bool igIsNamedKeyOrModKey(ImGuiKey key) { - return ImGui::IsNamedKeyOrModKey(key); -} -CIMGUI_API bool igIsLegacyKey(ImGuiKey key) { - return ImGui::IsLegacyKey(key); -} -CIMGUI_API bool igIsKeyboardKey(ImGuiKey key) { - return ImGui::IsKeyboardKey(key); -} -CIMGUI_API bool igIsGamepadKey(ImGuiKey key) { - return ImGui::IsGamepadKey(key); -} -CIMGUI_API bool igIsMouseKey(ImGuiKey key) { - return ImGui::IsMouseKey(key); -} -CIMGUI_API bool igIsAliasKey(ImGuiKey key) { - return ImGui::IsAliasKey(key); -} -CIMGUI_API bool igIsModKey(ImGuiKey key) { - return ImGui::IsModKey(key); -} -CIMGUI_API ImGuiKeyChord igFixupKeyChord(ImGuiContext* ctx, - ImGuiKeyChord key_chord) { - return ImGui::FixupKeyChord(ctx, key_chord); -} -CIMGUI_API ImGuiKey igConvertSingleModFlagToKey(ImGuiContext* ctx, - ImGuiKey key) { - return ImGui::ConvertSingleModFlagToKey(ctx, key); -} -CIMGUI_API ImGuiKeyData* igGetKeyData_ContextPtr(ImGuiContext* ctx, - ImGuiKey key) { - return ImGui::GetKeyData(ctx, key); -} -CIMGUI_API ImGuiKeyData* igGetKeyData_Key(ImGuiKey key) { - return ImGui::GetKeyData(key); -} -CIMGUI_API const char* igGetKeyChordName(ImGuiKeyChord key_chord) { - return ImGui::GetKeyChordName(key_chord); -} -CIMGUI_API ImGuiKey igMouseButtonToKey(ImGuiMouseButton button) { - return ImGui::MouseButtonToKey(button); -} -CIMGUI_API bool igIsMouseDragPastThreshold(ImGuiMouseButton button, - float lock_threshold) { - return ImGui::IsMouseDragPastThreshold(button, lock_threshold); -} -CIMGUI_API void igGetKeyMagnitude2d(ImVec2* pOut, - ImGuiKey key_left, - ImGuiKey key_right, - ImGuiKey key_up, - ImGuiKey key_down) { - *pOut = ImGui::GetKeyMagnitude2d(key_left, key_right, key_up, key_down); -} -CIMGUI_API float igGetNavTweakPressedAmount(ImGuiAxis axis) { - return ImGui::GetNavTweakPressedAmount(axis); -} -CIMGUI_API int igCalcTypematicRepeatAmount(float t0, - float t1, - float repeat_delay, - float repeat_rate) { - return ImGui::CalcTypematicRepeatAmount(t0, t1, repeat_delay, repeat_rate); -} -CIMGUI_API void igGetTypematicRepeatRate(ImGuiInputFlags flags, - float* repeat_delay, - float* repeat_rate) { - return ImGui::GetTypematicRepeatRate(flags, repeat_delay, repeat_rate); -} -CIMGUI_API void igTeleportMousePos(const ImVec2 pos) { - return ImGui::TeleportMousePos(pos); -} -CIMGUI_API void igSetActiveIdUsingAllKeyboardKeys() { - return ImGui::SetActiveIdUsingAllKeyboardKeys(); -} -CIMGUI_API bool igIsActiveIdUsingNavDir(ImGuiDir dir) { - return ImGui::IsActiveIdUsingNavDir(dir); -} -CIMGUI_API ImGuiID igGetKeyOwner(ImGuiKey key) { - return ImGui::GetKeyOwner(key); -} -CIMGUI_API void igSetKeyOwner(ImGuiKey key, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::SetKeyOwner(key, owner_id, flags); -} -CIMGUI_API void igSetKeyOwnersForKeyChord(ImGuiKeyChord key, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::SetKeyOwnersForKeyChord(key, owner_id, flags); -} -CIMGUI_API void igSetItemKeyOwner(ImGuiKey key, ImGuiInputFlags flags) { - return ImGui::SetItemKeyOwner(key, flags); -} -CIMGUI_API bool igTestKeyOwner(ImGuiKey key, ImGuiID owner_id) { - return ImGui::TestKeyOwner(key, owner_id); -} -CIMGUI_API ImGuiKeyOwnerData* igGetKeyOwnerData(ImGuiContext* ctx, - ImGuiKey key) { - return ImGui::GetKeyOwnerData(ctx, key); -} -CIMGUI_API bool igIsKeyDown_ID(ImGuiKey key, ImGuiID owner_id) { - return ImGui::IsKeyDown(key, owner_id); -} -CIMGUI_API bool igIsKeyPressed_ID(ImGuiKey key, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::IsKeyPressed(key, owner_id, flags); -} -CIMGUI_API bool igIsKeyReleased_ID(ImGuiKey key, ImGuiID owner_id) { - return ImGui::IsKeyReleased(key, owner_id); -} -CIMGUI_API bool igIsMouseDown_ID(ImGuiMouseButton button, ImGuiID owner_id) { - return ImGui::IsMouseDown(button, owner_id); -} -CIMGUI_API bool igIsMouseClicked_ID(ImGuiMouseButton button, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::IsMouseClicked(button, owner_id, flags); -} -CIMGUI_API bool igIsMouseReleased_ID(ImGuiMouseButton button, - ImGuiID owner_id) { - return ImGui::IsMouseReleased(button, owner_id); -} -CIMGUI_API bool igIsMouseDoubleClicked_ID(ImGuiMouseButton button, - ImGuiID owner_id) { - return ImGui::IsMouseDoubleClicked(button, owner_id); -} -CIMGUI_API bool igIsKeyChordPressed_ID(ImGuiKeyChord key_chord, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::IsKeyChordPressed(key_chord, owner_id, flags); -} -CIMGUI_API void igSetNextItemShortcut(ImGuiKeyChord key_chord) { - return ImGui::SetNextItemShortcut(key_chord); -} -CIMGUI_API bool igShortcut(ImGuiKeyChord key_chord, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::Shortcut(key_chord, owner_id, flags); -} -CIMGUI_API bool igSetShortcutRouting(ImGuiKeyChord key_chord, - ImGuiID owner_id, - ImGuiInputFlags flags) { - return ImGui::SetShortcutRouting(key_chord, owner_id, flags); -} -CIMGUI_API bool igTestShortcutRouting(ImGuiKeyChord key_chord, - ImGuiID owner_id) { - return ImGui::TestShortcutRouting(key_chord, owner_id); -} -CIMGUI_API ImGuiKeyRoutingData* igGetShortcutRoutingData( - ImGuiKeyChord key_chord) { - return ImGui::GetShortcutRoutingData(key_chord); -} -CIMGUI_API void igDockContextInitialize(ImGuiContext* ctx) { - return ImGui::DockContextInitialize(ctx); -} -CIMGUI_API void igDockContextShutdown(ImGuiContext* ctx) { - return ImGui::DockContextShutdown(ctx); -} -CIMGUI_API void igDockContextClearNodes(ImGuiContext* ctx, - ImGuiID root_id, - bool clear_settings_refs) { - return ImGui::DockContextClearNodes(ctx, root_id, clear_settings_refs); -} -CIMGUI_API void igDockContextRebuildNodes(ImGuiContext* ctx) { - return ImGui::DockContextRebuildNodes(ctx); -} -CIMGUI_API void igDockContextNewFrameUpdateUndocking(ImGuiContext* ctx) { - return ImGui::DockContextNewFrameUpdateUndocking(ctx); -} -CIMGUI_API void igDockContextNewFrameUpdateDocking(ImGuiContext* ctx) { - return ImGui::DockContextNewFrameUpdateDocking(ctx); -} -CIMGUI_API void igDockContextEndFrame(ImGuiContext* ctx) { - return ImGui::DockContextEndFrame(ctx); -} -CIMGUI_API ImGuiID igDockContextGenNodeID(ImGuiContext* ctx) { - return ImGui::DockContextGenNodeID(ctx); -} -CIMGUI_API void igDockContextQueueDock(ImGuiContext* ctx, - ImGuiWindow* target, - ImGuiDockNode* target_node, - ImGuiWindow* payload, - ImGuiDir split_dir, - float split_ratio, - bool split_outer) { - return ImGui::DockContextQueueDock(ctx, target, target_node, payload, - split_dir, split_ratio, split_outer); -} -CIMGUI_API void igDockContextQueueUndockWindow(ImGuiContext* ctx, - ImGuiWindow* window) { - return ImGui::DockContextQueueUndockWindow(ctx, window); -} -CIMGUI_API void igDockContextQueueUndockNode(ImGuiContext* ctx, - ImGuiDockNode* node) { - return ImGui::DockContextQueueUndockNode(ctx, node); -} -CIMGUI_API void igDockContextProcessUndockWindow( - ImGuiContext* ctx, - ImGuiWindow* window, - bool clear_persistent_docking_ref) { - return ImGui::DockContextProcessUndockWindow(ctx, window, - clear_persistent_docking_ref); -} -CIMGUI_API void igDockContextProcessUndockNode(ImGuiContext* ctx, - ImGuiDockNode* node) { - return ImGui::DockContextProcessUndockNode(ctx, node); -} -CIMGUI_API bool igDockContextCalcDropPosForDocking(ImGuiWindow* target, - ImGuiDockNode* target_node, - ImGuiWindow* payload_window, - ImGuiDockNode* payload_node, - ImGuiDir split_dir, - bool split_outer, - ImVec2* out_pos) { - return ImGui::DockContextCalcDropPosForDocking( - target, target_node, payload_window, payload_node, split_dir, split_outer, - out_pos); -} -CIMGUI_API ImGuiDockNode* igDockContextFindNodeByID(ImGuiContext* ctx, - ImGuiID id) { - return ImGui::DockContextFindNodeByID(ctx, id); -} -CIMGUI_API void igDockNodeWindowMenuHandler_Default(ImGuiContext* ctx, - ImGuiDockNode* node, - ImGuiTabBar* tab_bar) { - return ImGui::DockNodeWindowMenuHandler_Default(ctx, node, tab_bar); -} -CIMGUI_API bool igDockNodeBeginAmendTabBar(ImGuiDockNode* node) { - return ImGui::DockNodeBeginAmendTabBar(node); -} -CIMGUI_API void igDockNodeEndAmendTabBar() { - return ImGui::DockNodeEndAmendTabBar(); -} -CIMGUI_API ImGuiDockNode* igDockNodeGetRootNode(ImGuiDockNode* node) { - return ImGui::DockNodeGetRootNode(node); -} -CIMGUI_API bool igDockNodeIsInHierarchyOf(ImGuiDockNode* node, - ImGuiDockNode* parent) { - return ImGui::DockNodeIsInHierarchyOf(node, parent); -} -CIMGUI_API int igDockNodeGetDepth(const ImGuiDockNode* node) { - return ImGui::DockNodeGetDepth(node); -} -CIMGUI_API ImGuiID igDockNodeGetWindowMenuButtonId(const ImGuiDockNode* node) { - return ImGui::DockNodeGetWindowMenuButtonId(node); -} -CIMGUI_API ImGuiDockNode* igGetWindowDockNode() { - return ImGui::GetWindowDockNode(); -} -CIMGUI_API bool igGetWindowAlwaysWantOwnTabBar(ImGuiWindow* window) { - return ImGui::GetWindowAlwaysWantOwnTabBar(window); -} -CIMGUI_API void igBeginDocked(ImGuiWindow* window, bool* p_open) { - return ImGui::BeginDocked(window, p_open); -} -CIMGUI_API void igBeginDockableDragDropSource(ImGuiWindow* window) { - return ImGui::BeginDockableDragDropSource(window); -} -CIMGUI_API void igBeginDockableDragDropTarget(ImGuiWindow* window) { - return ImGui::BeginDockableDragDropTarget(window); -} -CIMGUI_API void igSetWindowDock(ImGuiWindow* window, - ImGuiID dock_id, - ImGuiCond cond) { - return ImGui::SetWindowDock(window, dock_id, cond); -} -CIMGUI_API void igDockBuilderDockWindow(const char* window_name, - ImGuiID node_id) { - return ImGui::DockBuilderDockWindow(window_name, node_id); -} -CIMGUI_API ImGuiDockNode* igDockBuilderGetNode(ImGuiID node_id) { - return ImGui::DockBuilderGetNode(node_id); -} -CIMGUI_API ImGuiDockNode* igDockBuilderGetCentralNode(ImGuiID node_id) { - return ImGui::DockBuilderGetCentralNode(node_id); -} -CIMGUI_API ImGuiID igDockBuilderAddNode(ImGuiID node_id, - ImGuiDockNodeFlags flags) { - return ImGui::DockBuilderAddNode(node_id, flags); -} -CIMGUI_API void igDockBuilderRemoveNode(ImGuiID node_id) { - return ImGui::DockBuilderRemoveNode(node_id); -} -CIMGUI_API void igDockBuilderRemoveNodeDockedWindows(ImGuiID node_id, - bool clear_settings_refs) { - return ImGui::DockBuilderRemoveNodeDockedWindows(node_id, - clear_settings_refs); -} -CIMGUI_API void igDockBuilderRemoveNodeChildNodes(ImGuiID node_id) { - return ImGui::DockBuilderRemoveNodeChildNodes(node_id); -} -CIMGUI_API void igDockBuilderSetNodePos(ImGuiID node_id, ImVec2 pos) { - return ImGui::DockBuilderSetNodePos(node_id, pos); -} -CIMGUI_API void igDockBuilderSetNodeSize(ImGuiID node_id, ImVec2 size) { - return ImGui::DockBuilderSetNodeSize(node_id, size); -} -CIMGUI_API ImGuiID igDockBuilderSplitNode(ImGuiID node_id, - ImGuiDir split_dir, - float size_ratio_for_node_at_dir, - ImGuiID* out_id_at_dir, - ImGuiID* out_id_at_opposite_dir) { - return ImGui::DockBuilderSplitNode(node_id, split_dir, - size_ratio_for_node_at_dir, out_id_at_dir, - out_id_at_opposite_dir); -} -CIMGUI_API void igDockBuilderCopyDockSpace( - ImGuiID src_dockspace_id, - ImGuiID dst_dockspace_id, - ImVector_const_charPtr* in_window_remap_pairs) { - return ImGui::DockBuilderCopyDockSpace(src_dockspace_id, dst_dockspace_id, - in_window_remap_pairs); -} -CIMGUI_API void igDockBuilderCopyNode(ImGuiID src_node_id, - ImGuiID dst_node_id, - ImVector_ImGuiID* out_node_remap_pairs) { - return ImGui::DockBuilderCopyNode(src_node_id, dst_node_id, - out_node_remap_pairs); -} -CIMGUI_API void igDockBuilderCopyWindowSettings(const char* src_name, - const char* dst_name) { - return ImGui::DockBuilderCopyWindowSettings(src_name, dst_name); -} -CIMGUI_API void igDockBuilderFinish(ImGuiID node_id) { - return ImGui::DockBuilderFinish(node_id); -} -CIMGUI_API void igPushFocusScope(ImGuiID id) { - return ImGui::PushFocusScope(id); -} -CIMGUI_API void igPopFocusScope() { - return ImGui::PopFocusScope(); -} -CIMGUI_API ImGuiID igGetCurrentFocusScope() { - return ImGui::GetCurrentFocusScope(); -} -CIMGUI_API bool igIsDragDropActive() { - return ImGui::IsDragDropActive(); -} -CIMGUI_API bool igBeginDragDropTargetCustom(const ImRect bb, ImGuiID id) { - return ImGui::BeginDragDropTargetCustom(bb, id); -} -CIMGUI_API void igClearDragDrop() { - return ImGui::ClearDragDrop(); -} -CIMGUI_API bool igIsDragDropPayloadBeingAccepted() { - return ImGui::IsDragDropPayloadBeingAccepted(); -} -CIMGUI_API void igRenderDragDropTargetRect(const ImRect bb, - const ImRect item_clip_rect) { - return ImGui::RenderDragDropTargetRect(bb, item_clip_rect); -} -CIMGUI_API ImGuiTypingSelectRequest* igGetTypingSelectRequest( - ImGuiTypingSelectFlags flags) { - return ImGui::GetTypingSelectRequest(flags); -} -CIMGUI_API int igTypingSelectFindMatch(ImGuiTypingSelectRequest* req, - int items_count, - const char* (*get_item_name_func)(void*, - int), - void* user_data, - int nav_item_idx) { - return ImGui::TypingSelectFindMatch(req, items_count, get_item_name_func, - user_data, nav_item_idx); -} -CIMGUI_API int igTypingSelectFindNextSingleCharMatch( - ImGuiTypingSelectRequest* req, - int items_count, - const char* (*get_item_name_func)(void*, int), - void* user_data, - int nav_item_idx) { - return ImGui::TypingSelectFindNextSingleCharMatch( - req, items_count, get_item_name_func, user_data, nav_item_idx); -} -CIMGUI_API int igTypingSelectFindBestLeadingMatch( - ImGuiTypingSelectRequest* req, - int items_count, - const char* (*get_item_name_func)(void*, int), - void* user_data) { - return ImGui::TypingSelectFindBestLeadingMatch(req, items_count, - get_item_name_func, user_data); -} -CIMGUI_API void igSetWindowClipRectBeforeSetChannel(ImGuiWindow* window, - const ImRect clip_rect) { - return ImGui::SetWindowClipRectBeforeSetChannel(window, clip_rect); -} -CIMGUI_API void igBeginColumns(const char* str_id, - int count, - ImGuiOldColumnFlags flags) { - return ImGui::BeginColumns(str_id, count, flags); -} -CIMGUI_API void igEndColumns() { - return ImGui::EndColumns(); -} -CIMGUI_API void igPushColumnClipRect(int column_index) { - return ImGui::PushColumnClipRect(column_index); -} -CIMGUI_API void igPushColumnsBackground() { - return ImGui::PushColumnsBackground(); -} -CIMGUI_API void igPopColumnsBackground() { - return ImGui::PopColumnsBackground(); -} -CIMGUI_API ImGuiID igGetColumnsID(const char* str_id, int count) { - return ImGui::GetColumnsID(str_id, count); -} -CIMGUI_API ImGuiOldColumns* igFindOrCreateColumns(ImGuiWindow* window, - ImGuiID id) { - return ImGui::FindOrCreateColumns(window, id); -} -CIMGUI_API float igGetColumnOffsetFromNorm(const ImGuiOldColumns* columns, - float offset_norm) { - return ImGui::GetColumnOffsetFromNorm(columns, offset_norm); -} -CIMGUI_API float igGetColumnNormFromOffset(const ImGuiOldColumns* columns, - float offset) { - return ImGui::GetColumnNormFromOffset(columns, offset); -} -CIMGUI_API void igTableOpenContextMenu(int column_n) { - return ImGui::TableOpenContextMenu(column_n); -} -CIMGUI_API void igTableSetColumnWidth(int column_n, float width) { - return ImGui::TableSetColumnWidth(column_n, width); -} -CIMGUI_API void igTableSetColumnSortDirection(int column_n, - ImGuiSortDirection sort_direction, - bool append_to_sort_specs) { - return ImGui::TableSetColumnSortDirection(column_n, sort_direction, - append_to_sort_specs); -} -CIMGUI_API int igTableGetHoveredColumn() { - return ImGui::TableGetHoveredColumn(); -} -CIMGUI_API int igTableGetHoveredRow() { - return ImGui::TableGetHoveredRow(); -} -CIMGUI_API float igTableGetHeaderRowHeight() { - return ImGui::TableGetHeaderRowHeight(); -} -CIMGUI_API float igTableGetHeaderAngledMaxLabelWidth() { - return ImGui::TableGetHeaderAngledMaxLabelWidth(); -} -CIMGUI_API void igTablePushBackgroundChannel() { - return ImGui::TablePushBackgroundChannel(); -} -CIMGUI_API void igTablePopBackgroundChannel() { - return ImGui::TablePopBackgroundChannel(); -} -CIMGUI_API void igTableAngledHeadersRowEx(ImGuiID row_id, - float angle, - float max_label_width, - const ImGuiTableHeaderData* data, - int data_count) { - return ImGui::TableAngledHeadersRowEx(row_id, angle, max_label_width, data, - data_count); -} -CIMGUI_API ImGuiTable* igGetCurrentTable() { - return ImGui::GetCurrentTable(); -} -CIMGUI_API ImGuiTable* igTableFindByID(ImGuiID id) { - return ImGui::TableFindByID(id); -} -CIMGUI_API bool igBeginTableEx(const char* name, - ImGuiID id, - int columns_count, - ImGuiTableFlags flags, - const ImVec2 outer_size, - float inner_width) { - return ImGui::BeginTableEx(name, id, columns_count, flags, outer_size, - inner_width); -} -CIMGUI_API void igTableBeginInitMemory(ImGuiTable* table, int columns_count) { - return ImGui::TableBeginInitMemory(table, columns_count); -} -CIMGUI_API void igTableBeginApplyRequests(ImGuiTable* table) { - return ImGui::TableBeginApplyRequests(table); -} -CIMGUI_API void igTableSetupDrawChannels(ImGuiTable* table) { - return ImGui::TableSetupDrawChannels(table); -} -CIMGUI_API void igTableUpdateLayout(ImGuiTable* table) { - return ImGui::TableUpdateLayout(table); -} -CIMGUI_API void igTableUpdateBorders(ImGuiTable* table) { - return ImGui::TableUpdateBorders(table); -} -CIMGUI_API void igTableUpdateColumnsWeightFromWidth(ImGuiTable* table) { - return ImGui::TableUpdateColumnsWeightFromWidth(table); -} -CIMGUI_API void igTableDrawBorders(ImGuiTable* table) { - return ImGui::TableDrawBorders(table); -} -CIMGUI_API void igTableDrawDefaultContextMenu( - ImGuiTable* table, - ImGuiTableFlags flags_for_section_to_display) { - return ImGui::TableDrawDefaultContextMenu(table, - flags_for_section_to_display); -} -CIMGUI_API bool igTableBeginContextMenuPopup(ImGuiTable* table) { - return ImGui::TableBeginContextMenuPopup(table); -} -CIMGUI_API void igTableMergeDrawChannels(ImGuiTable* table) { - return ImGui::TableMergeDrawChannels(table); -} -CIMGUI_API ImGuiTableInstanceData* igTableGetInstanceData(ImGuiTable* table, - int instance_no) { - return ImGui::TableGetInstanceData(table, instance_no); -} -CIMGUI_API ImGuiID igTableGetInstanceID(ImGuiTable* table, int instance_no) { - return ImGui::TableGetInstanceID(table, instance_no); -} -CIMGUI_API void igTableSortSpecsSanitize(ImGuiTable* table) { - return ImGui::TableSortSpecsSanitize(table); -} -CIMGUI_API void igTableSortSpecsBuild(ImGuiTable* table) { - return ImGui::TableSortSpecsBuild(table); -} -CIMGUI_API ImGuiSortDirection -igTableGetColumnNextSortDirection(ImGuiTableColumn* column) { - return ImGui::TableGetColumnNextSortDirection(column); -} -CIMGUI_API void igTableFixColumnSortDirection(ImGuiTable* table, - ImGuiTableColumn* column) { - return ImGui::TableFixColumnSortDirection(table, column); -} -CIMGUI_API float igTableGetColumnWidthAuto(ImGuiTable* table, - ImGuiTableColumn* column) { - return ImGui::TableGetColumnWidthAuto(table, column); -} -CIMGUI_API void igTableBeginRow(ImGuiTable* table) { - return ImGui::TableBeginRow(table); -} -CIMGUI_API void igTableEndRow(ImGuiTable* table) { - return ImGui::TableEndRow(table); -} -CIMGUI_API void igTableBeginCell(ImGuiTable* table, int column_n) { - return ImGui::TableBeginCell(table, column_n); -} -CIMGUI_API void igTableEndCell(ImGuiTable* table) { - return ImGui::TableEndCell(table); -} -CIMGUI_API void igTableGetCellBgRect(ImRect* pOut, - const ImGuiTable* table, - int column_n) { - *pOut = ImGui::TableGetCellBgRect(table, column_n); -} -CIMGUI_API const char* igTableGetColumnName_TablePtr(const ImGuiTable* table, - int column_n) { - return ImGui::TableGetColumnName(table, column_n); -} -CIMGUI_API ImGuiID igTableGetColumnResizeID(ImGuiTable* table, - int column_n, - int instance_no) { - return ImGui::TableGetColumnResizeID(table, column_n, instance_no); -} -CIMGUI_API float igTableGetMaxColumnWidth(const ImGuiTable* table, - int column_n) { - return ImGui::TableGetMaxColumnWidth(table, column_n); -} -CIMGUI_API void igTableSetColumnWidthAutoSingle(ImGuiTable* table, - int column_n) { - return ImGui::TableSetColumnWidthAutoSingle(table, column_n); -} -CIMGUI_API void igTableSetColumnWidthAutoAll(ImGuiTable* table) { - return ImGui::TableSetColumnWidthAutoAll(table); -} -CIMGUI_API void igTableRemove(ImGuiTable* table) { - return ImGui::TableRemove(table); -} -CIMGUI_API void igTableGcCompactTransientBuffers_TablePtr(ImGuiTable* table) { - return ImGui::TableGcCompactTransientBuffers(table); -} -CIMGUI_API void igTableGcCompactTransientBuffers_TableTempDataPtr( - ImGuiTableTempData* table) { - return ImGui::TableGcCompactTransientBuffers(table); -} -CIMGUI_API void igTableGcCompactSettings() { - return ImGui::TableGcCompactSettings(); -} -CIMGUI_API void igTableLoadSettings(ImGuiTable* table) { - return ImGui::TableLoadSettings(table); -} -CIMGUI_API void igTableSaveSettings(ImGuiTable* table) { - return ImGui::TableSaveSettings(table); -} -CIMGUI_API void igTableResetSettings(ImGuiTable* table) { - return ImGui::TableResetSettings(table); -} -CIMGUI_API ImGuiTableSettings* igTableGetBoundSettings(ImGuiTable* table) { - return ImGui::TableGetBoundSettings(table); -} -CIMGUI_API void igTableSettingsAddSettingsHandler() { - return ImGui::TableSettingsAddSettingsHandler(); -} -CIMGUI_API ImGuiTableSettings* igTableSettingsCreate(ImGuiID id, - int columns_count) { - return ImGui::TableSettingsCreate(id, columns_count); -} -CIMGUI_API ImGuiTableSettings* igTableSettingsFindByID(ImGuiID id) { - return ImGui::TableSettingsFindByID(id); -} -CIMGUI_API ImGuiTabBar* igGetCurrentTabBar() { - return ImGui::GetCurrentTabBar(); -} -CIMGUI_API bool igBeginTabBarEx(ImGuiTabBar* tab_bar, - const ImRect bb, - ImGuiTabBarFlags flags) { - return ImGui::BeginTabBarEx(tab_bar, bb, flags); -} -CIMGUI_API ImGuiTabItem* igTabBarFindTabByID(ImGuiTabBar* tab_bar, - ImGuiID tab_id) { - return ImGui::TabBarFindTabByID(tab_bar, tab_id); -} -CIMGUI_API ImGuiTabItem* igTabBarFindTabByOrder(ImGuiTabBar* tab_bar, - int order) { - return ImGui::TabBarFindTabByOrder(tab_bar, order); -} -CIMGUI_API ImGuiTabItem* igTabBarFindMostRecentlySelectedTabForActiveWindow( - ImGuiTabBar* tab_bar) { - return ImGui::TabBarFindMostRecentlySelectedTabForActiveWindow(tab_bar); -} -CIMGUI_API ImGuiTabItem* igTabBarGetCurrentTab(ImGuiTabBar* tab_bar) { - return ImGui::TabBarGetCurrentTab(tab_bar); -} -CIMGUI_API int igTabBarGetTabOrder(ImGuiTabBar* tab_bar, ImGuiTabItem* tab) { - return ImGui::TabBarGetTabOrder(tab_bar, tab); -} -CIMGUI_API const char* igTabBarGetTabName(ImGuiTabBar* tab_bar, - ImGuiTabItem* tab) { - return ImGui::TabBarGetTabName(tab_bar, tab); -} -CIMGUI_API void igTabBarAddTab(ImGuiTabBar* tab_bar, - ImGuiTabItemFlags tab_flags, - ImGuiWindow* window) { - return ImGui::TabBarAddTab(tab_bar, tab_flags, window); -} -CIMGUI_API void igTabBarRemoveTab(ImGuiTabBar* tab_bar, ImGuiID tab_id) { - return ImGui::TabBarRemoveTab(tab_bar, tab_id); -} -CIMGUI_API void igTabBarCloseTab(ImGuiTabBar* tab_bar, ImGuiTabItem* tab) { - return ImGui::TabBarCloseTab(tab_bar, tab); -} -CIMGUI_API void igTabBarQueueFocus(ImGuiTabBar* tab_bar, ImGuiTabItem* tab) { - return ImGui::TabBarQueueFocus(tab_bar, tab); -} -CIMGUI_API void igTabBarQueueReorder(ImGuiTabBar* tab_bar, - ImGuiTabItem* tab, - int offset) { - return ImGui::TabBarQueueReorder(tab_bar, tab, offset); -} -CIMGUI_API void igTabBarQueueReorderFromMousePos(ImGuiTabBar* tab_bar, - ImGuiTabItem* tab, - ImVec2 mouse_pos) { - return ImGui::TabBarQueueReorderFromMousePos(tab_bar, tab, mouse_pos); -} -CIMGUI_API bool igTabBarProcessReorder(ImGuiTabBar* tab_bar) { - return ImGui::TabBarProcessReorder(tab_bar); -} -CIMGUI_API bool igTabItemEx(ImGuiTabBar* tab_bar, - const char* label, - bool* p_open, - ImGuiTabItemFlags flags, - ImGuiWindow* docked_window) { - return ImGui::TabItemEx(tab_bar, label, p_open, flags, docked_window); -} -CIMGUI_API void igTabItemCalcSize_Str(ImVec2* pOut, - const char* label, - bool has_close_button_or_unsaved_marker) { - *pOut = ImGui::TabItemCalcSize(label, has_close_button_or_unsaved_marker); -} -CIMGUI_API void igTabItemCalcSize_WindowPtr(ImVec2* pOut, ImGuiWindow* window) { - *pOut = ImGui::TabItemCalcSize(window); -} -CIMGUI_API void igTabItemBackground(ImDrawList* draw_list, - const ImRect bb, - ImGuiTabItemFlags flags, - ImU32 col) { - return ImGui::TabItemBackground(draw_list, bb, flags, col); -} -CIMGUI_API void igTabItemLabelAndCloseButton(ImDrawList* draw_list, - const ImRect bb, - ImGuiTabItemFlags flags, - ImVec2 frame_padding, - const char* label, - ImGuiID tab_id, - ImGuiID close_button_id, - bool is_contents_visible, - bool* out_just_closed, - bool* out_text_clipped) { - return ImGui::TabItemLabelAndCloseButton( - draw_list, bb, flags, frame_padding, label, tab_id, close_button_id, - is_contents_visible, out_just_closed, out_text_clipped); -} -CIMGUI_API void igRenderText(ImVec2 pos, - const char* text, - const char* text_end, - bool hide_text_after_hash) { - return ImGui::RenderText(pos, text, text_end, hide_text_after_hash); -} -CIMGUI_API void igRenderTextWrapped(ImVec2 pos, - const char* text, - const char* text_end, - float wrap_width) { - return ImGui::RenderTextWrapped(pos, text, text_end, wrap_width); -} -CIMGUI_API void igRenderTextClipped(const ImVec2 pos_min, - const ImVec2 pos_max, - const char* text, - const char* text_end, - const ImVec2* text_size_if_known, - const ImVec2 align, - const ImRect* clip_rect) { - return ImGui::RenderTextClipped(pos_min, pos_max, text, text_end, - text_size_if_known, align, clip_rect); -} -CIMGUI_API void igRenderTextClippedEx(ImDrawList* draw_list, - const ImVec2 pos_min, - const ImVec2 pos_max, - const char* text, - const char* text_end, - const ImVec2* text_size_if_known, - const ImVec2 align, - const ImRect* clip_rect) { - return ImGui::RenderTextClippedEx(draw_list, pos_min, pos_max, text, text_end, - text_size_if_known, align, clip_rect); -} -CIMGUI_API void igRenderTextEllipsis(ImDrawList* draw_list, - const ImVec2 pos_min, - const ImVec2 pos_max, - float clip_max_x, - float ellipsis_max_x, - const char* text, - const char* text_end, - const ImVec2* text_size_if_known) { - return ImGui::RenderTextEllipsis(draw_list, pos_min, pos_max, clip_max_x, - ellipsis_max_x, text, text_end, - text_size_if_known); -} -CIMGUI_API void igRenderFrame(ImVec2 p_min, - ImVec2 p_max, - ImU32 fill_col, - bool border, - float rounding) { - return ImGui::RenderFrame(p_min, p_max, fill_col, border, rounding); -} -CIMGUI_API void igRenderFrameBorder(ImVec2 p_min, - ImVec2 p_max, - float rounding) { - return ImGui::RenderFrameBorder(p_min, p_max, rounding); -} -CIMGUI_API void igRenderColorRectWithAlphaCheckerboard(ImDrawList* draw_list, - ImVec2 p_min, - ImVec2 p_max, - ImU32 fill_col, - float grid_step, - ImVec2 grid_off, - float rounding, - ImDrawFlags flags) { - return ImGui::RenderColorRectWithAlphaCheckerboard( - draw_list, p_min, p_max, fill_col, grid_step, grid_off, rounding, flags); -} -CIMGUI_API void igRenderNavHighlight(const ImRect bb, - ImGuiID id, - ImGuiNavHighlightFlags flags) { - return ImGui::RenderNavHighlight(bb, id, flags); -} -CIMGUI_API const char* igFindRenderedTextEnd(const char* text, - const char* text_end) { - return ImGui::FindRenderedTextEnd(text, text_end); -} -CIMGUI_API void igRenderMouseCursor(ImVec2 pos, - float scale, - ImGuiMouseCursor mouse_cursor, - ImU32 col_fill, - ImU32 col_border, - ImU32 col_shadow) { - return ImGui::RenderMouseCursor(pos, scale, mouse_cursor, col_fill, - col_border, col_shadow); -} -CIMGUI_API void igRenderArrow(ImDrawList* draw_list, - ImVec2 pos, - ImU32 col, - ImGuiDir dir, - float scale) { - return ImGui::RenderArrow(draw_list, pos, col, dir, scale); -} -CIMGUI_API void igRenderBullet(ImDrawList* draw_list, ImVec2 pos, ImU32 col) { - return ImGui::RenderBullet(draw_list, pos, col); -} -CIMGUI_API void igRenderCheckMark(ImDrawList* draw_list, - ImVec2 pos, - ImU32 col, - float sz) { - return ImGui::RenderCheckMark(draw_list, pos, col, sz); -} -CIMGUI_API void igRenderArrowPointingAt(ImDrawList* draw_list, - ImVec2 pos, - ImVec2 half_sz, - ImGuiDir direction, - ImU32 col) { - return ImGui::RenderArrowPointingAt(draw_list, pos, half_sz, direction, col); -} -CIMGUI_API void igRenderArrowDockMenu(ImDrawList* draw_list, - ImVec2 p_min, - float sz, - ImU32 col) { - return ImGui::RenderArrowDockMenu(draw_list, p_min, sz, col); -} -CIMGUI_API void igRenderRectFilledRangeH(ImDrawList* draw_list, - const ImRect rect, - ImU32 col, - float x_start_norm, - float x_end_norm, - float rounding) { - return ImGui::RenderRectFilledRangeH(draw_list, rect, col, x_start_norm, - x_end_norm, rounding); -} -CIMGUI_API void igRenderRectFilledWithHole(ImDrawList* draw_list, - const ImRect outer, - const ImRect inner, - ImU32 col, - float rounding) { - return ImGui::RenderRectFilledWithHole(draw_list, outer, inner, col, - rounding); -} -CIMGUI_API ImDrawFlags igCalcRoundingFlagsForRectInRect(const ImRect r_in, - const ImRect r_outer, - float threshold) { - return ImGui::CalcRoundingFlagsForRectInRect(r_in, r_outer, threshold); -} -CIMGUI_API void igTextEx(const char* text, - const char* text_end, - ImGuiTextFlags flags) { - return ImGui::TextEx(text, text_end, flags); -} -CIMGUI_API bool igButtonEx(const char* label, - const ImVec2 size_arg, - ImGuiButtonFlags flags) { - return ImGui::ButtonEx(label, size_arg, flags); -} -CIMGUI_API bool igArrowButtonEx(const char* str_id, - ImGuiDir dir, - ImVec2 size_arg, - ImGuiButtonFlags flags) { - return ImGui::ArrowButtonEx(str_id, dir, size_arg, flags); -} -CIMGUI_API bool igImageButtonEx(ImGuiID id, - ImTextureID texture_id, - const ImVec2 image_size, - const ImVec2 uv0, - const ImVec2 uv1, - const ImVec4 bg_col, - const ImVec4 tint_col, - ImGuiButtonFlags flags) { - return ImGui::ImageButtonEx(id, texture_id, image_size, uv0, uv1, bg_col, - tint_col, flags); -} -CIMGUI_API void igSeparatorEx(ImGuiSeparatorFlags flags, float thickness) { - return ImGui::SeparatorEx(flags, thickness); -} -CIMGUI_API void igSeparatorTextEx(ImGuiID id, - const char* label, - const char* label_end, - float extra_width) { - return ImGui::SeparatorTextEx(id, label, label_end, extra_width); -} -CIMGUI_API bool igCheckboxFlags_S64Ptr(const char* label, - ImS64* flags, - ImS64 flags_value) { - return ImGui::CheckboxFlags(label, flags, flags_value); -} -CIMGUI_API bool igCheckboxFlags_U64Ptr(const char* label, - ImU64* flags, - ImU64 flags_value) { - return ImGui::CheckboxFlags(label, flags, flags_value); -} -CIMGUI_API bool igCloseButton(ImGuiID id, const ImVec2 pos) { - return ImGui::CloseButton(id, pos); -} -CIMGUI_API bool igCollapseButton(ImGuiID id, - const ImVec2 pos, - ImGuiDockNode* dock_node) { - return ImGui::CollapseButton(id, pos, dock_node); -} -CIMGUI_API void igScrollbar(ImGuiAxis axis) { - return ImGui::Scrollbar(axis); -} -CIMGUI_API bool igScrollbarEx(const ImRect bb, - ImGuiID id, - ImGuiAxis axis, - ImS64* p_scroll_v, - ImS64 avail_v, - ImS64 contents_v, - ImDrawFlags flags) { - return ImGui::ScrollbarEx(bb, id, axis, p_scroll_v, avail_v, contents_v, - flags); -} -CIMGUI_API void igGetWindowScrollbarRect(ImRect* pOut, - ImGuiWindow* window, - ImGuiAxis axis) { - *pOut = ImGui::GetWindowScrollbarRect(window, axis); -} -CIMGUI_API ImGuiID igGetWindowScrollbarID(ImGuiWindow* window, ImGuiAxis axis) { - return ImGui::GetWindowScrollbarID(window, axis); -} -CIMGUI_API ImGuiID igGetWindowResizeCornerID(ImGuiWindow* window, int n) { - return ImGui::GetWindowResizeCornerID(window, n); -} -CIMGUI_API ImGuiID igGetWindowResizeBorderID(ImGuiWindow* window, - ImGuiDir dir) { - return ImGui::GetWindowResizeBorderID(window, dir); -} -CIMGUI_API bool igButtonBehavior(const ImRect bb, - ImGuiID id, - bool* out_hovered, - bool* out_held, - ImGuiButtonFlags flags) { - return ImGui::ButtonBehavior(bb, id, out_hovered, out_held, flags); -} -CIMGUI_API bool igDragBehavior(ImGuiID id, - ImGuiDataType data_type, - void* p_v, - float v_speed, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags) { - return ImGui::DragBehavior(id, data_type, p_v, v_speed, p_min, p_max, format, - flags); -} -CIMGUI_API bool igSliderBehavior(const ImRect bb, - ImGuiID id, - ImGuiDataType data_type, - void* p_v, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags, - ImRect* out_grab_bb) { - return ImGui::SliderBehavior(bb, id, data_type, p_v, p_min, p_max, format, - flags, out_grab_bb); -} -CIMGUI_API bool igSplitterBehavior(const ImRect bb, - ImGuiID id, - ImGuiAxis axis, - float* size1, - float* size2, - float min_size1, - float min_size2, - float hover_extend, - float hover_visibility_delay, - ImU32 bg_col) { - return ImGui::SplitterBehavior(bb, id, axis, size1, size2, min_size1, - min_size2, hover_extend, - hover_visibility_delay, bg_col); -} -CIMGUI_API bool igTreeNodeBehavior(ImGuiID id, - ImGuiTreeNodeFlags flags, - const char* label, - const char* label_end) { - return ImGui::TreeNodeBehavior(id, flags, label, label_end); -} -CIMGUI_API void igTreePushOverrideID(ImGuiID id) { - return ImGui::TreePushOverrideID(id); -} -CIMGUI_API void igTreeNodeSetOpen(ImGuiID id, bool open) { - return ImGui::TreeNodeSetOpen(id, open); -} -CIMGUI_API bool igTreeNodeUpdateNextOpen(ImGuiID id, ImGuiTreeNodeFlags flags) { - return ImGui::TreeNodeUpdateNextOpen(id, flags); -} -CIMGUI_API void igSetNextItemSelectionUserData( - ImGuiSelectionUserData selection_user_data) { - return ImGui::SetNextItemSelectionUserData(selection_user_data); -} -CIMGUI_API const ImGuiDataTypeInfo* igDataTypeGetInfo(ImGuiDataType data_type) { - return ImGui::DataTypeGetInfo(data_type); -} -CIMGUI_API int igDataTypeFormatString(char* buf, - int buf_size, - ImGuiDataType data_type, - const void* p_data, - const char* format) { - return ImGui::DataTypeFormatString(buf, buf_size, data_type, p_data, format); -} -CIMGUI_API void igDataTypeApplyOp(ImGuiDataType data_type, - int op, - void* output, - const void* arg_1, - const void* arg_2) { - return ImGui::DataTypeApplyOp(data_type, op, output, arg_1, arg_2); -} -CIMGUI_API bool igDataTypeApplyFromText(const char* buf, - ImGuiDataType data_type, - void* p_data, - const char* format) { - return ImGui::DataTypeApplyFromText(buf, data_type, p_data, format); -} -CIMGUI_API int igDataTypeCompare(ImGuiDataType data_type, - const void* arg_1, - const void* arg_2) { - return ImGui::DataTypeCompare(data_type, arg_1, arg_2); -} -CIMGUI_API bool igDataTypeClamp(ImGuiDataType data_type, - void* p_data, - const void* p_min, - const void* p_max) { - return ImGui::DataTypeClamp(data_type, p_data, p_min, p_max); -} -CIMGUI_API bool igInputTextEx(const char* label, - const char* hint, - char* buf, - int buf_size, - const ImVec2 size_arg, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data) { - return ImGui::InputTextEx(label, hint, buf, buf_size, size_arg, flags, - callback, user_data); -} -CIMGUI_API void igInputTextDeactivateHook(ImGuiID id) { - return ImGui::InputTextDeactivateHook(id); -} -CIMGUI_API bool igTempInputText(const ImRect bb, - ImGuiID id, - const char* label, - char* buf, - int buf_size, - ImGuiInputTextFlags flags) { - return ImGui::TempInputText(bb, id, label, buf, buf_size, flags); -} -CIMGUI_API bool igTempInputScalar(const ImRect bb, - ImGuiID id, - const char* label, - ImGuiDataType data_type, - void* p_data, - const char* format, - const void* p_clamp_min, - const void* p_clamp_max) { - return ImGui::TempInputScalar(bb, id, label, data_type, p_data, format, - p_clamp_min, p_clamp_max); -} -CIMGUI_API bool igTempInputIsActive(ImGuiID id) { - return ImGui::TempInputIsActive(id); -} -CIMGUI_API ImGuiInputTextState* igGetInputTextState(ImGuiID id) { - return ImGui::GetInputTextState(id); -} -CIMGUI_API void igColorTooltip(const char* text, - const float* col, - ImGuiColorEditFlags flags) { - return ImGui::ColorTooltip(text, col, flags); -} -CIMGUI_API void igColorEditOptionsPopup(const float* col, - ImGuiColorEditFlags flags) { - return ImGui::ColorEditOptionsPopup(col, flags); -} -CIMGUI_API void igColorPickerOptionsPopup(const float* ref_col, - ImGuiColorEditFlags flags) { - return ImGui::ColorPickerOptionsPopup(ref_col, flags); -} -CIMGUI_API int igPlotEx(ImGuiPlotType plot_type, - const char* label, - float (*values_getter)(void* data, int idx), - void* data, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - const ImVec2 size_arg) { - return ImGui::PlotEx(plot_type, label, values_getter, data, values_count, - values_offset, overlay_text, scale_min, scale_max, - size_arg); -} -CIMGUI_API void igShadeVertsLinearColorGradientKeepAlpha(ImDrawList* draw_list, - int vert_start_idx, - int vert_end_idx, - ImVec2 gradient_p0, - ImVec2 gradient_p1, - ImU32 col0, - ImU32 col1) { - return ImGui::ShadeVertsLinearColorGradientKeepAlpha( - draw_list, vert_start_idx, vert_end_idx, gradient_p0, gradient_p1, col0, - col1); -} -CIMGUI_API void igShadeVertsLinearUV(ImDrawList* draw_list, - int vert_start_idx, - int vert_end_idx, - const ImVec2 a, - const ImVec2 b, - const ImVec2 uv_a, - const ImVec2 uv_b, - bool clamp) { - return ImGui::ShadeVertsLinearUV(draw_list, vert_start_idx, vert_end_idx, a, - b, uv_a, uv_b, clamp); -} -CIMGUI_API void igShadeVertsTransformPos(ImDrawList* draw_list, - int vert_start_idx, - int vert_end_idx, - const ImVec2 pivot_in, - float cos_a, - float sin_a, - const ImVec2 pivot_out) { - return ImGui::ShadeVertsTransformPos(draw_list, vert_start_idx, vert_end_idx, - pivot_in, cos_a, sin_a, pivot_out); -} -CIMGUI_API void igGcCompactTransientMiscBuffers() { - return ImGui::GcCompactTransientMiscBuffers(); -} -CIMGUI_API void igGcCompactTransientWindowBuffers(ImGuiWindow* window) { - return ImGui::GcCompactTransientWindowBuffers(window); -} -CIMGUI_API void igGcAwakeTransientWindowBuffers(ImGuiWindow* window) { - return ImGui::GcAwakeTransientWindowBuffers(window); -} -CIMGUI_API void igDebugLog(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - ImGui::DebugLogV(fmt, args); - va_end(args); -} -CIMGUI_API void igDebugLogV(const char* fmt, va_list args) { - return ImGui::DebugLogV(fmt, args); -} -CIMGUI_API void igDebugAllocHook(ImGuiDebugAllocInfo* info, - int frame_count, - void* ptr, - size_t size) { - return ImGui::DebugAllocHook(info, frame_count, ptr, size); -} -CIMGUI_API void igErrorCheckEndFrameRecover(ImGuiErrorLogCallback log_callback, - void* user_data) { - return ImGui::ErrorCheckEndFrameRecover(log_callback, user_data); -} -CIMGUI_API void igErrorCheckEndWindowRecover(ImGuiErrorLogCallback log_callback, - void* user_data) { - return ImGui::ErrorCheckEndWindowRecover(log_callback, user_data); -} -CIMGUI_API void igErrorCheckUsingSetCursorPosToExtendParentBoundaries() { - return ImGui::ErrorCheckUsingSetCursorPosToExtendParentBoundaries(); -} -CIMGUI_API void igDebugDrawCursorPos(ImU32 col) { - return ImGui::DebugDrawCursorPos(col); -} -CIMGUI_API void igDebugDrawLineExtents(ImU32 col) { - return ImGui::DebugDrawLineExtents(col); -} -CIMGUI_API void igDebugDrawItemRect(ImU32 col) { - return ImGui::DebugDrawItemRect(col); -} -CIMGUI_API void igDebugLocateItem(ImGuiID target_id) { - return ImGui::DebugLocateItem(target_id); -} -CIMGUI_API void igDebugLocateItemOnHover(ImGuiID target_id) { - return ImGui::DebugLocateItemOnHover(target_id); -} -CIMGUI_API void igDebugLocateItemResolveWithLastItem() { - return ImGui::DebugLocateItemResolveWithLastItem(); -} -CIMGUI_API void igDebugBreakClearData() { - return ImGui::DebugBreakClearData(); -} -CIMGUI_API bool igDebugBreakButton(const char* label, - const char* description_of_location) { - return ImGui::DebugBreakButton(label, description_of_location); -} -CIMGUI_API void igDebugBreakButtonTooltip(bool keyboard_only, - const char* description_of_location) { - return ImGui::DebugBreakButtonTooltip(keyboard_only, description_of_location); -} -CIMGUI_API void igShowFontAtlas(ImFontAtlas* atlas) { - return ImGui::ShowFontAtlas(atlas); -} -CIMGUI_API void igDebugHookIdInfo(ImGuiID id, - ImGuiDataType data_type, - const void* data_id, - const void* data_id_end) { - return ImGui::DebugHookIdInfo(id, data_type, data_id, data_id_end); -} -CIMGUI_API void igDebugNodeColumns(ImGuiOldColumns* columns) { - return ImGui::DebugNodeColumns(columns); -} -CIMGUI_API void igDebugNodeDockNode(ImGuiDockNode* node, const char* label) { - return ImGui::DebugNodeDockNode(node, label); -} -CIMGUI_API void igDebugNodeDrawList(ImGuiWindow* window, - ImGuiViewportP* viewport, - const ImDrawList* draw_list, - const char* label) { - return ImGui::DebugNodeDrawList(window, viewport, draw_list, label); -} -CIMGUI_API void igDebugNodeDrawCmdShowMeshAndBoundingBox( - ImDrawList* out_draw_list, - const ImDrawList* draw_list, - const ImDrawCmd* draw_cmd, - bool show_mesh, - bool show_aabb) { - return ImGui::DebugNodeDrawCmdShowMeshAndBoundingBox( - out_draw_list, draw_list, draw_cmd, show_mesh, show_aabb); -} -CIMGUI_API void igDebugNodeFont(ImFont* font) { - return ImGui::DebugNodeFont(font); -} -CIMGUI_API void igDebugNodeFontGlyph(ImFont* font, const ImFontGlyph* glyph) { - return ImGui::DebugNodeFontGlyph(font, glyph); -} -CIMGUI_API void igDebugNodeStorage(ImGuiStorage* storage, const char* label) { - return ImGui::DebugNodeStorage(storage, label); -} -CIMGUI_API void igDebugNodeTabBar(ImGuiTabBar* tab_bar, const char* label) { - return ImGui::DebugNodeTabBar(tab_bar, label); -} -CIMGUI_API void igDebugNodeTable(ImGuiTable* table) { - return ImGui::DebugNodeTable(table); -} -CIMGUI_API void igDebugNodeTableSettings(ImGuiTableSettings* settings) { - return ImGui::DebugNodeTableSettings(settings); -} -CIMGUI_API void igDebugNodeInputTextState(ImGuiInputTextState* state) { - return ImGui::DebugNodeInputTextState(state); -} -CIMGUI_API void igDebugNodeTypingSelectState(ImGuiTypingSelectState* state) { - return ImGui::DebugNodeTypingSelectState(state); -} -CIMGUI_API void igDebugNodeWindow(ImGuiWindow* window, const char* label) { - return ImGui::DebugNodeWindow(window, label); -} -CIMGUI_API void igDebugNodeWindowSettings(ImGuiWindowSettings* settings) { - return ImGui::DebugNodeWindowSettings(settings); -} -CIMGUI_API void igDebugNodeWindowsList(ImVector_ImGuiWindowPtr* windows, - const char* label) { - return ImGui::DebugNodeWindowsList(windows, label); -} -CIMGUI_API void igDebugNodeWindowsListByBeginStackParent( - ImGuiWindow** windows, - int windows_size, - ImGuiWindow* parent_in_begin_stack) { - return ImGui::DebugNodeWindowsListByBeginStackParent(windows, windows_size, - parent_in_begin_stack); -} -CIMGUI_API void igDebugNodeViewport(ImGuiViewportP* viewport) { - return ImGui::DebugNodeViewport(viewport); -} -CIMGUI_API void igDebugRenderKeyboardPreview(ImDrawList* draw_list) { - return ImGui::DebugRenderKeyboardPreview(draw_list); -} -CIMGUI_API void igDebugRenderViewportThumbnail(ImDrawList* draw_list, - ImGuiViewportP* viewport, - const ImRect bb) { - return ImGui::DebugRenderViewportThumbnail(draw_list, viewport, bb); -} - -CIMGUI_API void igImFontAtlasUpdateConfigDataPointers(ImFontAtlas* atlas) { - return ImFontAtlasUpdateConfigDataPointers(atlas); -} -CIMGUI_API void igImFontAtlasBuildInit(ImFontAtlas* atlas) { - return ImFontAtlasBuildInit(atlas); -} -CIMGUI_API void igImFontAtlasBuildSetupFont(ImFontAtlas* atlas, - ImFont* font, - ImFontConfig* font_config, - float ascent, - float descent) { - return ImFontAtlasBuildSetupFont(atlas, font, font_config, ascent, descent); -} -CIMGUI_API void igImFontAtlasBuildPackCustomRects(ImFontAtlas* atlas, - void* stbrp_context_opaque) { - return ImFontAtlasBuildPackCustomRects(atlas, stbrp_context_opaque); -} -CIMGUI_API void igImFontAtlasBuildFinish(ImFontAtlas* atlas) { - return ImFontAtlasBuildFinish(atlas); -} -CIMGUI_API void igImFontAtlasBuildRender8bppRectFromString( - ImFontAtlas* atlas, - int x, - int y, - int w, - int h, - const char* in_str, - char in_marker_char, - unsigned char in_marker_pixel_value) { - return ImFontAtlasBuildRender8bppRectFromString( - atlas, x, y, w, h, in_str, in_marker_char, in_marker_pixel_value); -} -CIMGUI_API void igImFontAtlasBuildRender32bppRectFromString( - ImFontAtlas* atlas, - int x, - int y, - int w, - int h, - const char* in_str, - char in_marker_char, - unsigned int in_marker_pixel_value) { - return ImFontAtlasBuildRender32bppRectFromString( - atlas, x, y, w, h, in_str, in_marker_char, in_marker_pixel_value); -} -CIMGUI_API void igImFontAtlasBuildMultiplyCalcLookupTable( - unsigned char out_table[256], - float in_multiply_factor) { - return ImFontAtlasBuildMultiplyCalcLookupTable(out_table, in_multiply_factor); -} -CIMGUI_API void igImFontAtlasBuildMultiplyRectAlpha8( - const unsigned char table[256], - unsigned char* pixels, - int x, - int y, - int w, - int h, - int stride) { - return ImFontAtlasBuildMultiplyRectAlpha8(table, pixels, x, y, w, h, stride); -} - -/////////////////////////////manual written functions -CIMGUI_API void igLogText(CONST char* fmt, ...) { - char buffer[256]; - va_list args; - va_start(args, fmt); - vsnprintf(buffer, 256, fmt, args); - va_end(args); - - ImGui::LogText("%s", buffer); -} -CIMGUI_API void ImGuiTextBuffer_appendf(struct ImGuiTextBuffer* buffer, - const char* fmt, - ...) { - va_list args; - va_start(args, fmt); - buffer->appendfv(fmt, args); - va_end(args); -} - -CIMGUI_API float igGET_FLT_MAX() { - return FLT_MAX; -} - -CIMGUI_API float igGET_FLT_MIN() { - return FLT_MIN; -} - -CIMGUI_API ImVector_ImWchar* ImVector_ImWchar_create() { - return IM_NEW(ImVector)(); -} - -CIMGUI_API void ImVector_ImWchar_destroy(ImVector_ImWchar* self) { - IM_DELETE(self); -} - -CIMGUI_API void ImVector_ImWchar_Init(ImVector_ImWchar* p) { - IM_PLACEMENT_NEW(p) ImVector(); -} -CIMGUI_API void ImVector_ImWchar_UnInit(ImVector_ImWchar* p) { - p->~ImVector(); -} - -#ifdef IMGUI_HAS_DOCK - -// NOTE: Some function pointers in the ImGuiPlatformIO structure are not -// C-compatible because of their use of a complex return type. To work around -// this, we store a custom CimguiStorage object inside -// ImGuiIO::BackendLanguageUserData, which contains C-compatible function -// pointer variants for these functions. When a user function pointer is -// provided, we hook up the underlying ImGuiPlatformIO function pointer to a -// thunk which accesses the user function pointer through CimguiStorage. - -struct CimguiStorage { - void (*Platform_GetWindowPos)(ImGuiViewport* vp, ImVec2* out_pos); - void (*Platform_GetWindowSize)(ImGuiViewport* vp, ImVec2* out_pos); -}; - -// Gets a reference to the CimguiStorage object stored in the current ImGui -// context's BackendLanguageUserData. -CimguiStorage& GetCimguiStorage() { - ImGuiIO& io = ImGui::GetIO(); - if (io.BackendLanguageUserData == NULL) { - io.BackendLanguageUserData = new CimguiStorage(); - } - - return *(CimguiStorage*)io.BackendLanguageUserData; -} - -// Thunk satisfying the signature of ImGuiPlatformIO::Platform_GetWindowPos. -ImVec2 Platform_GetWindowPos_hook(ImGuiViewport* vp) { - ImVec2 pos; - GetCimguiStorage().Platform_GetWindowPos(vp, &pos); - return pos; -}; - -// Fully C-compatible function pointer setter for -// ImGuiPlatformIO::Platform_GetWindowPos. -CIMGUI_API void ImGuiPlatformIO_Set_Platform_GetWindowPos( - ImGuiPlatformIO* platform_io, - void (*user_callback)(ImGuiViewport* vp, ImVec2* out_pos)) { - CimguiStorage& storage = GetCimguiStorage(); - storage.Platform_GetWindowPos = user_callback; - platform_io->Platform_GetWindowPos = &Platform_GetWindowPos_hook; -} - -// Thunk satisfying the signature of ImGuiPlatformIO::Platform_GetWindowSize. -ImVec2 Platform_GetWindowSize_hook(ImGuiViewport* vp) { - ImVec2 size; - GetCimguiStorage().Platform_GetWindowSize(vp, &size); - return size; -}; - -// Fully C-compatible function pointer setter for -// ImGuiPlatformIO::Platform_GetWindowSize. -CIMGUI_API void ImGuiPlatformIO_Set_Platform_GetWindowSize( - ImGuiPlatformIO* platform_io, - void (*user_callback)(ImGuiViewport* vp, ImVec2* out_size)) { - CimguiStorage& storage = GetCimguiStorage(); - storage.Platform_GetWindowSize = user_callback; - platform_io->Platform_GetWindowSize = &Platform_GetWindowSize_hook; -} - -#endif diff --git a/pkg/cimgui/vendor/cimgui.h b/pkg/cimgui/vendor/cimgui.h deleted file mode 100644 index f00b4d9b7..000000000 --- a/pkg/cimgui/vendor/cimgui.h +++ /dev/null @@ -1,6554 +0,0 @@ -// This file is automatically generated by generator.lua from -// https://github.com/cimgui/cimgui based on imgui.h file version "1.90.6" 19060 -// from Dear ImGui https://github.com/ocornut/imgui with imgui_internal.h api -// docking branch -#ifndef CIMGUI_INCLUDED -#define CIMGUI_INCLUDED -#include -#include -#if defined _WIN32 || defined __CYGWIN__ -#ifdef CIMGUI_NO_EXPORT -#define API -#else -#define API __declspec(dllexport) -#endif -#else -#ifdef __GNUC__ -#define API __attribute__((__visibility__("default"))) -#else -#define API -#endif -#endif - -#if defined __cplusplus -#define EXTERN extern "C" -#else -#include -#include -#define EXTERN extern -#endif - -#define CIMGUI_API EXTERN API -#define CONST const - -#ifdef _MSC_VER -typedef unsigned __int64 ImU64; -#else -// typedef unsigned long long ImU64; -#endif - -#ifdef CIMGUI_DEFINE_ENUMS_AND_STRUCTS - -typedef struct ImDrawChannel ImDrawChannel; -typedef struct ImDrawCmd ImDrawCmd; -typedef struct ImDrawData ImDrawData; -typedef struct ImDrawList ImDrawList; -typedef struct ImDrawListSharedData ImDrawListSharedData; -typedef struct ImDrawListSplitter ImDrawListSplitter; -typedef struct ImDrawVert ImDrawVert; -typedef struct ImFont ImFont; -typedef struct ImFontAtlas ImFontAtlas; -typedef struct ImFontBuilderIO ImFontBuilderIO; -typedef struct ImFontConfig ImFontConfig; -typedef struct ImFontGlyph ImFontGlyph; -typedef struct ImFontGlyphRangesBuilder ImFontGlyphRangesBuilder; -typedef struct ImColor ImColor; -typedef struct ImGuiContext ImGuiContext; -typedef struct ImGuiIO ImGuiIO; -typedef struct ImGuiInputTextCallbackData ImGuiInputTextCallbackData; -typedef struct ImGuiKeyData ImGuiKeyData; -typedef struct ImGuiListClipper ImGuiListClipper; -typedef struct ImGuiOnceUponAFrame ImGuiOnceUponAFrame; -typedef struct ImGuiPayload ImGuiPayload; -typedef struct ImGuiPlatformIO ImGuiPlatformIO; -typedef struct ImGuiPlatformMonitor ImGuiPlatformMonitor; -typedef struct ImGuiPlatformImeData ImGuiPlatformImeData; -typedef struct ImGuiSizeCallbackData ImGuiSizeCallbackData; -typedef struct ImGuiStorage ImGuiStorage; -typedef struct ImGuiStyle ImGuiStyle; -typedef struct ImGuiTableSortSpecs ImGuiTableSortSpecs; -typedef struct ImGuiTableColumnSortSpecs ImGuiTableColumnSortSpecs; -typedef struct ImGuiTextBuffer ImGuiTextBuffer; -typedef struct ImGuiTextFilter ImGuiTextFilter; -typedef struct ImGuiViewport ImGuiViewport; -typedef struct ImGuiWindowClass ImGuiWindowClass; -typedef struct ImBitVector ImBitVector; -typedef struct ImRect ImRect; -typedef struct ImDrawDataBuilder ImDrawDataBuilder; -typedef struct ImGuiColorMod ImGuiColorMod; -typedef struct ImGuiContextHook ImGuiContextHook; -typedef struct ImGuiDataVarInfo ImGuiDataVarInfo; -typedef struct ImGuiDataTypeInfo ImGuiDataTypeInfo; -typedef struct ImGuiDockContext ImGuiDockContext; -typedef struct ImGuiDockRequest ImGuiDockRequest; -typedef struct ImGuiDockNode ImGuiDockNode; -typedef struct ImGuiDockNodeSettings ImGuiDockNodeSettings; -typedef struct ImGuiGroupData ImGuiGroupData; -typedef struct ImGuiInputTextState ImGuiInputTextState; -typedef struct ImGuiInputTextDeactivateData ImGuiInputTextDeactivateData; -typedef struct ImGuiLastItemData ImGuiLastItemData; -typedef struct ImGuiLocEntry ImGuiLocEntry; -typedef struct ImGuiMenuColumns ImGuiMenuColumns; -typedef struct ImGuiNavItemData ImGuiNavItemData; -typedef struct ImGuiNavTreeNodeData ImGuiNavTreeNodeData; -typedef struct ImGuiMetricsConfig ImGuiMetricsConfig; -typedef struct ImGuiNextWindowData ImGuiNextWindowData; -typedef struct ImGuiNextItemData ImGuiNextItemData; -typedef struct ImGuiOldColumnData ImGuiOldColumnData; -typedef struct ImGuiOldColumns ImGuiOldColumns; -typedef struct ImGuiPopupData ImGuiPopupData; -typedef struct ImGuiSettingsHandler ImGuiSettingsHandler; -typedef struct ImGuiStackSizes ImGuiStackSizes; -typedef struct ImGuiStyleMod ImGuiStyleMod; -typedef struct ImGuiTabBar ImGuiTabBar; -typedef struct ImGuiTabItem ImGuiTabItem; -typedef struct ImGuiTable ImGuiTable; -typedef struct ImGuiTableHeaderData ImGuiTableHeaderData; -typedef struct ImGuiTableColumn ImGuiTableColumn; -typedef struct ImGuiTableInstanceData ImGuiTableInstanceData; -typedef struct ImGuiTableTempData ImGuiTableTempData; -typedef struct ImGuiTableSettings ImGuiTableSettings; -typedef struct ImGuiTableColumnsSettings ImGuiTableColumnsSettings; -typedef struct ImGuiTypingSelectState ImGuiTypingSelectState; -typedef struct ImGuiTypingSelectRequest ImGuiTypingSelectRequest; -typedef struct ImGuiWindow ImGuiWindow; -typedef struct ImGuiWindowDockStyle ImGuiWindowDockStyle; -typedef struct ImGuiWindowTempData ImGuiWindowTempData; -typedef struct ImGuiWindowSettings ImGuiWindowSettings; -typedef struct ImVector_const_charPtr { - int Size; - int Capacity; - const char** Data; -} ImVector_const_charPtr; - -struct ImDrawChannel; -struct ImDrawCmd; -struct ImDrawData; -struct ImDrawList; -struct ImDrawListSharedData; -struct ImDrawListSplitter; -struct ImDrawVert; -struct ImFont; -struct ImFontAtlas; -struct ImFontBuilderIO; -struct ImFontConfig; -struct ImFontGlyph; -struct ImFontGlyphRangesBuilder; -struct ImColor; -struct ImGuiContext; -struct ImGuiIO; -struct ImGuiInputTextCallbackData; -struct ImGuiKeyData; -struct ImGuiListClipper; -struct ImGuiOnceUponAFrame; -struct ImGuiPayload; -struct ImGuiPlatformIO; -struct ImGuiPlatformMonitor; -struct ImGuiPlatformImeData; -struct ImGuiSizeCallbackData; -struct ImGuiStorage; -struct ImGuiStyle; -struct ImGuiTableSortSpecs; -struct ImGuiTableColumnSortSpecs; -struct ImGuiTextBuffer; -struct ImGuiTextFilter; -struct ImGuiViewport; -struct ImGuiWindowClass; -typedef int ImGuiCol; -typedef int ImGuiCond; -typedef int ImGuiDataType; -typedef int ImGuiDir; -typedef int ImGuiMouseButton; -typedef int ImGuiMouseCursor; -typedef int ImGuiSortDirection; -typedef int ImGuiStyleVar; -typedef int ImGuiTableBgTarget; -typedef int ImDrawFlags; -typedef int ImDrawListFlags; -typedef int ImFontAtlasFlags; -typedef int ImGuiBackendFlags; -typedef int ImGuiButtonFlags; -typedef int ImGuiChildFlags; -typedef int ImGuiColorEditFlags; -typedef int ImGuiConfigFlags; -typedef int ImGuiComboFlags; -typedef int ImGuiDockNodeFlags; -typedef int ImGuiDragDropFlags; -typedef int ImGuiFocusedFlags; -typedef int ImGuiHoveredFlags; -typedef int ImGuiInputTextFlags; -typedef int ImGuiKeyChord; -typedef int ImGuiPopupFlags; -typedef int ImGuiSelectableFlags; -typedef int ImGuiSliderFlags; -typedef int ImGuiTabBarFlags; -typedef int ImGuiTabItemFlags; -typedef int ImGuiTableFlags; -typedef int ImGuiTableColumnFlags; -typedef int ImGuiTableRowFlags; -typedef int ImGuiTreeNodeFlags; -typedef int ImGuiViewportFlags; -typedef int ImGuiWindowFlags; -typedef void* ImTextureID; -typedef unsigned short ImDrawIdx; -typedef unsigned int ImGuiID; -typedef signed char ImS8; -typedef unsigned char ImU8; -typedef signed short ImS16; -typedef unsigned short ImU16; -typedef signed int ImS32; -typedef unsigned int ImU32; -typedef signed long long ImS64; -typedef unsigned long long ImU64; -typedef unsigned int ImWchar32; -typedef unsigned short ImWchar16; -typedef ImWchar16 ImWchar; -typedef int (*ImGuiInputTextCallback)(ImGuiInputTextCallbackData* data); -typedef void (*ImGuiSizeCallback)(ImGuiSizeCallbackData* data); -typedef void* (*ImGuiMemAllocFunc)(size_t sz, void* user_data); -typedef void (*ImGuiMemFreeFunc)(void* ptr, void* user_data); -typedef struct ImVec2 ImVec2; -struct ImVec2 { - float x, y; -}; -typedef struct ImVec4 ImVec4; -struct ImVec4 { - float x, y, z, w; -}; -typedef enum { - ImGuiWindowFlags_None = 0, - ImGuiWindowFlags_NoTitleBar = 1 << 0, - ImGuiWindowFlags_NoResize = 1 << 1, - ImGuiWindowFlags_NoMove = 1 << 2, - ImGuiWindowFlags_NoScrollbar = 1 << 3, - ImGuiWindowFlags_NoScrollWithMouse = 1 << 4, - ImGuiWindowFlags_NoCollapse = 1 << 5, - ImGuiWindowFlags_AlwaysAutoResize = 1 << 6, - ImGuiWindowFlags_NoBackground = 1 << 7, - ImGuiWindowFlags_NoSavedSettings = 1 << 8, - ImGuiWindowFlags_NoMouseInputs = 1 << 9, - ImGuiWindowFlags_MenuBar = 1 << 10, - ImGuiWindowFlags_HorizontalScrollbar = 1 << 11, - ImGuiWindowFlags_NoFocusOnAppearing = 1 << 12, - ImGuiWindowFlags_NoBringToFrontOnFocus = 1 << 13, - ImGuiWindowFlags_AlwaysVerticalScrollbar = 1 << 14, - ImGuiWindowFlags_AlwaysHorizontalScrollbar = 1 << 15, - ImGuiWindowFlags_NoNavInputs = 1 << 16, - ImGuiWindowFlags_NoNavFocus = 1 << 17, - ImGuiWindowFlags_UnsavedDocument = 1 << 18, - ImGuiWindowFlags_NoDocking = 1 << 19, - ImGuiWindowFlags_NoNav = - ImGuiWindowFlags_NoNavInputs | ImGuiWindowFlags_NoNavFocus, - ImGuiWindowFlags_NoDecoration = - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoCollapse, - ImGuiWindowFlags_NoInputs = ImGuiWindowFlags_NoMouseInputs | - ImGuiWindowFlags_NoNavInputs | - ImGuiWindowFlags_NoNavFocus, - ImGuiWindowFlags_NavFlattened = 1 << 23, - ImGuiWindowFlags_ChildWindow = 1 << 24, - ImGuiWindowFlags_Tooltip = 1 << 25, - ImGuiWindowFlags_Popup = 1 << 26, - ImGuiWindowFlags_Modal = 1 << 27, - ImGuiWindowFlags_ChildMenu = 1 << 28, - ImGuiWindowFlags_DockNodeHost = 1 << 29, -} ImGuiWindowFlags_; -typedef enum { - ImGuiChildFlags_None = 0, - ImGuiChildFlags_Border = 1 << 0, - ImGuiChildFlags_AlwaysUseWindowPadding = 1 << 1, - ImGuiChildFlags_ResizeX = 1 << 2, - ImGuiChildFlags_ResizeY = 1 << 3, - ImGuiChildFlags_AutoResizeX = 1 << 4, - ImGuiChildFlags_AutoResizeY = 1 << 5, - ImGuiChildFlags_AlwaysAutoResize = 1 << 6, - ImGuiChildFlags_FrameStyle = 1 << 7, -} ImGuiChildFlags_; -typedef enum { - ImGuiInputTextFlags_None = 0, - ImGuiInputTextFlags_CharsDecimal = 1 << 0, - ImGuiInputTextFlags_CharsHexadecimal = 1 << 1, - ImGuiInputTextFlags_CharsUppercase = 1 << 2, - ImGuiInputTextFlags_CharsNoBlank = 1 << 3, - ImGuiInputTextFlags_AutoSelectAll = 1 << 4, - ImGuiInputTextFlags_EnterReturnsTrue = 1 << 5, - ImGuiInputTextFlags_CallbackCompletion = 1 << 6, - ImGuiInputTextFlags_CallbackHistory = 1 << 7, - ImGuiInputTextFlags_CallbackAlways = 1 << 8, - ImGuiInputTextFlags_CallbackCharFilter = 1 << 9, - ImGuiInputTextFlags_AllowTabInput = 1 << 10, - ImGuiInputTextFlags_CtrlEnterForNewLine = 1 << 11, - ImGuiInputTextFlags_NoHorizontalScroll = 1 << 12, - ImGuiInputTextFlags_AlwaysOverwrite = 1 << 13, - ImGuiInputTextFlags_ReadOnly = 1 << 14, - ImGuiInputTextFlags_Password = 1 << 15, - ImGuiInputTextFlags_NoUndoRedo = 1 << 16, - ImGuiInputTextFlags_CharsScientific = 1 << 17, - ImGuiInputTextFlags_CallbackResize = 1 << 18, - ImGuiInputTextFlags_CallbackEdit = 1 << 19, - ImGuiInputTextFlags_EscapeClearsAll = 1 << 20, -} ImGuiInputTextFlags_; -typedef enum { - ImGuiTreeNodeFlags_None = 0, - ImGuiTreeNodeFlags_Selected = 1 << 0, - ImGuiTreeNodeFlags_Framed = 1 << 1, - ImGuiTreeNodeFlags_AllowOverlap = 1 << 2, - ImGuiTreeNodeFlags_NoTreePushOnOpen = 1 << 3, - ImGuiTreeNodeFlags_NoAutoOpenOnLog = 1 << 4, - ImGuiTreeNodeFlags_DefaultOpen = 1 << 5, - ImGuiTreeNodeFlags_OpenOnDoubleClick = 1 << 6, - ImGuiTreeNodeFlags_OpenOnArrow = 1 << 7, - ImGuiTreeNodeFlags_Leaf = 1 << 8, - ImGuiTreeNodeFlags_Bullet = 1 << 9, - ImGuiTreeNodeFlags_FramePadding = 1 << 10, - ImGuiTreeNodeFlags_SpanAvailWidth = 1 << 11, - ImGuiTreeNodeFlags_SpanFullWidth = 1 << 12, - ImGuiTreeNodeFlags_SpanTextWidth = 1 << 13, - ImGuiTreeNodeFlags_SpanAllColumns = 1 << 14, - ImGuiTreeNodeFlags_NavLeftJumpsBackHere = 1 << 15, - ImGuiTreeNodeFlags_CollapsingHeader = ImGuiTreeNodeFlags_Framed | - ImGuiTreeNodeFlags_NoTreePushOnOpen | - ImGuiTreeNodeFlags_NoAutoOpenOnLog, -} ImGuiTreeNodeFlags_; -typedef enum { - ImGuiPopupFlags_None = 0, - ImGuiPopupFlags_MouseButtonLeft = 0, - ImGuiPopupFlags_MouseButtonRight = 1, - ImGuiPopupFlags_MouseButtonMiddle = 2, - ImGuiPopupFlags_MouseButtonMask_ = 0x1F, - ImGuiPopupFlags_MouseButtonDefault_ = 1, - ImGuiPopupFlags_NoReopen = 1 << 5, - ImGuiPopupFlags_NoOpenOverExistingPopup = 1 << 7, - ImGuiPopupFlags_NoOpenOverItems = 1 << 8, - ImGuiPopupFlags_AnyPopupId = 1 << 10, - ImGuiPopupFlags_AnyPopupLevel = 1 << 11, - ImGuiPopupFlags_AnyPopup = - ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel, -} ImGuiPopupFlags_; -typedef enum { - ImGuiSelectableFlags_None = 0, - ImGuiSelectableFlags_DontClosePopups = 1 << 0, - ImGuiSelectableFlags_SpanAllColumns = 1 << 1, - ImGuiSelectableFlags_AllowDoubleClick = 1 << 2, - ImGuiSelectableFlags_Disabled = 1 << 3, - ImGuiSelectableFlags_AllowOverlap = 1 << 4, -} ImGuiSelectableFlags_; -typedef enum { - ImGuiComboFlags_None = 0, - ImGuiComboFlags_PopupAlignLeft = 1 << 0, - ImGuiComboFlags_HeightSmall = 1 << 1, - ImGuiComboFlags_HeightRegular = 1 << 2, - ImGuiComboFlags_HeightLarge = 1 << 3, - ImGuiComboFlags_HeightLargest = 1 << 4, - ImGuiComboFlags_NoArrowButton = 1 << 5, - ImGuiComboFlags_NoPreview = 1 << 6, - ImGuiComboFlags_WidthFitPreview = 1 << 7, - ImGuiComboFlags_HeightMask_ = - ImGuiComboFlags_HeightSmall | ImGuiComboFlags_HeightRegular | - ImGuiComboFlags_HeightLarge | ImGuiComboFlags_HeightLargest, -} ImGuiComboFlags_; -typedef enum { - ImGuiTabBarFlags_None = 0, - ImGuiTabBarFlags_Reorderable = 1 << 0, - ImGuiTabBarFlags_AutoSelectNewTabs = 1 << 1, - ImGuiTabBarFlags_TabListPopupButton = 1 << 2, - ImGuiTabBarFlags_NoCloseWithMiddleMouseButton = 1 << 3, - ImGuiTabBarFlags_NoTabListScrollingButtons = 1 << 4, - ImGuiTabBarFlags_NoTooltip = 1 << 5, - ImGuiTabBarFlags_FittingPolicyResizeDown = 1 << 6, - ImGuiTabBarFlags_FittingPolicyScroll = 1 << 7, - ImGuiTabBarFlags_FittingPolicyMask_ = - ImGuiTabBarFlags_FittingPolicyResizeDown | - ImGuiTabBarFlags_FittingPolicyScroll, - ImGuiTabBarFlags_FittingPolicyDefault_ = - ImGuiTabBarFlags_FittingPolicyResizeDown, -} ImGuiTabBarFlags_; -typedef enum { - ImGuiTabItemFlags_None = 0, - ImGuiTabItemFlags_UnsavedDocument = 1 << 0, - ImGuiTabItemFlags_SetSelected = 1 << 1, - ImGuiTabItemFlags_NoCloseWithMiddleMouseButton = 1 << 2, - ImGuiTabItemFlags_NoPushId = 1 << 3, - ImGuiTabItemFlags_NoTooltip = 1 << 4, - ImGuiTabItemFlags_NoReorder = 1 << 5, - ImGuiTabItemFlags_Leading = 1 << 6, - ImGuiTabItemFlags_Trailing = 1 << 7, - ImGuiTabItemFlags_NoAssumedClosure = 1 << 8, -} ImGuiTabItemFlags_; -typedef enum { - ImGuiFocusedFlags_None = 0, - ImGuiFocusedFlags_ChildWindows = 1 << 0, - ImGuiFocusedFlags_RootWindow = 1 << 1, - ImGuiFocusedFlags_AnyWindow = 1 << 2, - ImGuiFocusedFlags_NoPopupHierarchy = 1 << 3, - ImGuiFocusedFlags_DockHierarchy = 1 << 4, - ImGuiFocusedFlags_RootAndChildWindows = - ImGuiFocusedFlags_RootWindow | ImGuiFocusedFlags_ChildWindows, -} ImGuiFocusedFlags_; -typedef enum { - ImGuiHoveredFlags_None = 0, - ImGuiHoveredFlags_ChildWindows = 1 << 0, - ImGuiHoveredFlags_RootWindow = 1 << 1, - ImGuiHoveredFlags_AnyWindow = 1 << 2, - ImGuiHoveredFlags_NoPopupHierarchy = 1 << 3, - ImGuiHoveredFlags_DockHierarchy = 1 << 4, - ImGuiHoveredFlags_AllowWhenBlockedByPopup = 1 << 5, - ImGuiHoveredFlags_AllowWhenBlockedByActiveItem = 1 << 7, - ImGuiHoveredFlags_AllowWhenOverlappedByItem = 1 << 8, - ImGuiHoveredFlags_AllowWhenOverlappedByWindow = 1 << 9, - ImGuiHoveredFlags_AllowWhenDisabled = 1 << 10, - ImGuiHoveredFlags_NoNavOverride = 1 << 11, - ImGuiHoveredFlags_AllowWhenOverlapped = - ImGuiHoveredFlags_AllowWhenOverlappedByItem | - ImGuiHoveredFlags_AllowWhenOverlappedByWindow, - ImGuiHoveredFlags_RectOnly = ImGuiHoveredFlags_AllowWhenBlockedByPopup | - ImGuiHoveredFlags_AllowWhenBlockedByActiveItem | - ImGuiHoveredFlags_AllowWhenOverlapped, - ImGuiHoveredFlags_RootAndChildWindows = - ImGuiHoveredFlags_RootWindow | ImGuiHoveredFlags_ChildWindows, - ImGuiHoveredFlags_ForTooltip = 1 << 12, - ImGuiHoveredFlags_Stationary = 1 << 13, - ImGuiHoveredFlags_DelayNone = 1 << 14, - ImGuiHoveredFlags_DelayShort = 1 << 15, - ImGuiHoveredFlags_DelayNormal = 1 << 16, - ImGuiHoveredFlags_NoSharedDelay = 1 << 17, -} ImGuiHoveredFlags_; -typedef enum { - ImGuiDockNodeFlags_None = 0, - ImGuiDockNodeFlags_KeepAliveOnly = 1 << 0, - ImGuiDockNodeFlags_NoDockingOverCentralNode = 1 << 2, - ImGuiDockNodeFlags_PassthruCentralNode = 1 << 3, - ImGuiDockNodeFlags_NoDockingSplit = 1 << 4, - ImGuiDockNodeFlags_NoResize = 1 << 5, - ImGuiDockNodeFlags_AutoHideTabBar = 1 << 6, - ImGuiDockNodeFlags_NoUndocking = 1 << 7, -} ImGuiDockNodeFlags_; -typedef enum { - ImGuiDragDropFlags_None = 0, - ImGuiDragDropFlags_SourceNoPreviewTooltip = 1 << 0, - ImGuiDragDropFlags_SourceNoDisableHover = 1 << 1, - ImGuiDragDropFlags_SourceNoHoldToOpenOthers = 1 << 2, - ImGuiDragDropFlags_SourceAllowNullID = 1 << 3, - ImGuiDragDropFlags_SourceExtern = 1 << 4, - ImGuiDragDropFlags_SourceAutoExpirePayload = 1 << 5, - ImGuiDragDropFlags_AcceptBeforeDelivery = 1 << 10, - ImGuiDragDropFlags_AcceptNoDrawDefaultRect = 1 << 11, - ImGuiDragDropFlags_AcceptNoPreviewTooltip = 1 << 12, - ImGuiDragDropFlags_AcceptPeekOnly = - ImGuiDragDropFlags_AcceptBeforeDelivery | - ImGuiDragDropFlags_AcceptNoDrawDefaultRect, -} ImGuiDragDropFlags_; -typedef enum { - ImGuiDataType_S8, - ImGuiDataType_U8, - ImGuiDataType_S16, - ImGuiDataType_U16, - ImGuiDataType_S32, - ImGuiDataType_U32, - ImGuiDataType_S64, - ImGuiDataType_U64, - ImGuiDataType_Float, - ImGuiDataType_Double, - ImGuiDataType_COUNT -} ImGuiDataType_; -typedef enum { - ImGuiDir_None = -1, - ImGuiDir_Left = 0, - ImGuiDir_Right = 1, - ImGuiDir_Up = 2, - ImGuiDir_Down = 3, - ImGuiDir_COUNT -} ImGuiDir_; -typedef enum { - ImGuiSortDirection_None = 0, - ImGuiSortDirection_Ascending = 1, - ImGuiSortDirection_Descending = 2 -} ImGuiSortDirection_; -typedef enum { - ImGuiKey_None = 0, - ImGuiKey_Tab = 512, - ImGuiKey_LeftArrow = 513, - ImGuiKey_RightArrow = 514, - ImGuiKey_UpArrow = 515, - ImGuiKey_DownArrow = 516, - ImGuiKey_PageUp = 517, - ImGuiKey_PageDown = 518, - ImGuiKey_Home = 519, - ImGuiKey_End = 520, - ImGuiKey_Insert = 521, - ImGuiKey_Delete = 522, - ImGuiKey_Backspace = 523, - ImGuiKey_Space = 524, - ImGuiKey_Enter = 525, - ImGuiKey_Escape = 526, - ImGuiKey_LeftCtrl = 527, - ImGuiKey_LeftShift = 528, - ImGuiKey_LeftAlt = 529, - ImGuiKey_LeftSuper = 530, - ImGuiKey_RightCtrl = 531, - ImGuiKey_RightShift = 532, - ImGuiKey_RightAlt = 533, - ImGuiKey_RightSuper = 534, - ImGuiKey_Menu = 535, - ImGuiKey_0 = 536, - ImGuiKey_1 = 537, - ImGuiKey_2 = 538, - ImGuiKey_3 = 539, - ImGuiKey_4 = 540, - ImGuiKey_5 = 541, - ImGuiKey_6 = 542, - ImGuiKey_7 = 543, - ImGuiKey_8 = 544, - ImGuiKey_9 = 545, - ImGuiKey_A = 546, - ImGuiKey_B = 547, - ImGuiKey_C = 548, - ImGuiKey_D = 549, - ImGuiKey_E = 550, - ImGuiKey_F = 551, - ImGuiKey_G = 552, - ImGuiKey_H = 553, - ImGuiKey_I = 554, - ImGuiKey_J = 555, - ImGuiKey_K = 556, - ImGuiKey_L = 557, - ImGuiKey_M = 558, - ImGuiKey_N = 559, - ImGuiKey_O = 560, - ImGuiKey_P = 561, - ImGuiKey_Q = 562, - ImGuiKey_R = 563, - ImGuiKey_S = 564, - ImGuiKey_T = 565, - ImGuiKey_U = 566, - ImGuiKey_V = 567, - ImGuiKey_W = 568, - ImGuiKey_X = 569, - ImGuiKey_Y = 570, - ImGuiKey_Z = 571, - ImGuiKey_F1 = 572, - ImGuiKey_F2 = 573, - ImGuiKey_F3 = 574, - ImGuiKey_F4 = 575, - ImGuiKey_F5 = 576, - ImGuiKey_F6 = 577, - ImGuiKey_F7 = 578, - ImGuiKey_F8 = 579, - ImGuiKey_F9 = 580, - ImGuiKey_F10 = 581, - ImGuiKey_F11 = 582, - ImGuiKey_F12 = 583, - ImGuiKey_F13 = 584, - ImGuiKey_F14 = 585, - ImGuiKey_F15 = 586, - ImGuiKey_F16 = 587, - ImGuiKey_F17 = 588, - ImGuiKey_F18 = 589, - ImGuiKey_F19 = 590, - ImGuiKey_F20 = 591, - ImGuiKey_F21 = 592, - ImGuiKey_F22 = 593, - ImGuiKey_F23 = 594, - ImGuiKey_F24 = 595, - ImGuiKey_Apostrophe = 596, - ImGuiKey_Comma = 597, - ImGuiKey_Minus = 598, - ImGuiKey_Period = 599, - ImGuiKey_Slash = 600, - ImGuiKey_Semicolon = 601, - ImGuiKey_Equal = 602, - ImGuiKey_LeftBracket = 603, - ImGuiKey_Backslash = 604, - ImGuiKey_RightBracket = 605, - ImGuiKey_GraveAccent = 606, - ImGuiKey_CapsLock = 607, - ImGuiKey_ScrollLock = 608, - ImGuiKey_NumLock = 609, - ImGuiKey_PrintScreen = 610, - ImGuiKey_Pause = 611, - ImGuiKey_Keypad0 = 612, - ImGuiKey_Keypad1 = 613, - ImGuiKey_Keypad2 = 614, - ImGuiKey_Keypad3 = 615, - ImGuiKey_Keypad4 = 616, - ImGuiKey_Keypad5 = 617, - ImGuiKey_Keypad6 = 618, - ImGuiKey_Keypad7 = 619, - ImGuiKey_Keypad8 = 620, - ImGuiKey_Keypad9 = 621, - ImGuiKey_KeypadDecimal = 622, - ImGuiKey_KeypadDivide = 623, - ImGuiKey_KeypadMultiply = 624, - ImGuiKey_KeypadSubtract = 625, - ImGuiKey_KeypadAdd = 626, - ImGuiKey_KeypadEnter = 627, - ImGuiKey_KeypadEqual = 628, - ImGuiKey_AppBack = 629, - ImGuiKey_AppForward = 630, - ImGuiKey_GamepadStart = 631, - ImGuiKey_GamepadBack = 632, - ImGuiKey_GamepadFaceLeft = 633, - ImGuiKey_GamepadFaceRight = 634, - ImGuiKey_GamepadFaceUp = 635, - ImGuiKey_GamepadFaceDown = 636, - ImGuiKey_GamepadDpadLeft = 637, - ImGuiKey_GamepadDpadRight = 638, - ImGuiKey_GamepadDpadUp = 639, - ImGuiKey_GamepadDpadDown = 640, - ImGuiKey_GamepadL1 = 641, - ImGuiKey_GamepadR1 = 642, - ImGuiKey_GamepadL2 = 643, - ImGuiKey_GamepadR2 = 644, - ImGuiKey_GamepadL3 = 645, - ImGuiKey_GamepadR3 = 646, - ImGuiKey_GamepadLStickLeft = 647, - ImGuiKey_GamepadLStickRight = 648, - ImGuiKey_GamepadLStickUp = 649, - ImGuiKey_GamepadLStickDown = 650, - ImGuiKey_GamepadRStickLeft = 651, - ImGuiKey_GamepadRStickRight = 652, - ImGuiKey_GamepadRStickUp = 653, - ImGuiKey_GamepadRStickDown = 654, - ImGuiKey_MouseLeft = 655, - ImGuiKey_MouseRight = 656, - ImGuiKey_MouseMiddle = 657, - ImGuiKey_MouseX1 = 658, - ImGuiKey_MouseX2 = 659, - ImGuiKey_MouseWheelX = 660, - ImGuiKey_MouseWheelY = 661, - ImGuiKey_ReservedForModCtrl = 662, - ImGuiKey_ReservedForModShift = 663, - ImGuiKey_ReservedForModAlt = 664, - ImGuiKey_ReservedForModSuper = 665, - ImGuiKey_COUNT = 666, - ImGuiMod_None = 0, - ImGuiMod_Ctrl = 1 << 12, - ImGuiMod_Shift = 1 << 13, - ImGuiMod_Alt = 1 << 14, - ImGuiMod_Super = 1 << 15, - ImGuiMod_Shortcut = 1 << 11, - ImGuiMod_Mask_ = 0xF800, - ImGuiKey_NamedKey_BEGIN = 512, - ImGuiKey_NamedKey_END = ImGuiKey_COUNT, - ImGuiKey_NamedKey_COUNT = ImGuiKey_NamedKey_END - ImGuiKey_NamedKey_BEGIN, - ImGuiKey_KeysData_SIZE = ImGuiKey_NamedKey_COUNT, - ImGuiKey_KeysData_OFFSET = ImGuiKey_NamedKey_BEGIN, -} ImGuiKey; -typedef enum { - ImGuiConfigFlags_None = 0, - ImGuiConfigFlags_NavEnableKeyboard = 1 << 0, - ImGuiConfigFlags_NavEnableGamepad = 1 << 1, - ImGuiConfigFlags_NavEnableSetMousePos = 1 << 2, - ImGuiConfigFlags_NavNoCaptureKeyboard = 1 << 3, - ImGuiConfigFlags_NoMouse = 1 << 4, - ImGuiConfigFlags_NoMouseCursorChange = 1 << 5, - ImGuiConfigFlags_DockingEnable = 1 << 6, - ImGuiConfigFlags_ViewportsEnable = 1 << 10, - ImGuiConfigFlags_DpiEnableScaleViewports = 1 << 14, - ImGuiConfigFlags_DpiEnableScaleFonts = 1 << 15, - ImGuiConfigFlags_IsSRGB = 1 << 20, - ImGuiConfigFlags_IsTouchScreen = 1 << 21, -} ImGuiConfigFlags_; -typedef enum { - ImGuiBackendFlags_None = 0, - ImGuiBackendFlags_HasGamepad = 1 << 0, - ImGuiBackendFlags_HasMouseCursors = 1 << 1, - ImGuiBackendFlags_HasSetMousePos = 1 << 2, - ImGuiBackendFlags_RendererHasVtxOffset = 1 << 3, - ImGuiBackendFlags_PlatformHasViewports = 1 << 10, - ImGuiBackendFlags_HasMouseHoveredViewport = 1 << 11, - ImGuiBackendFlags_RendererHasViewports = 1 << 12, -} ImGuiBackendFlags_; -typedef enum { - ImGuiCol_Text, - ImGuiCol_TextDisabled, - ImGuiCol_WindowBg, - ImGuiCol_ChildBg, - ImGuiCol_PopupBg, - ImGuiCol_Border, - ImGuiCol_BorderShadow, - ImGuiCol_FrameBg, - ImGuiCol_FrameBgHovered, - ImGuiCol_FrameBgActive, - ImGuiCol_TitleBg, - ImGuiCol_TitleBgActive, - ImGuiCol_TitleBgCollapsed, - ImGuiCol_MenuBarBg, - ImGuiCol_ScrollbarBg, - ImGuiCol_ScrollbarGrab, - ImGuiCol_ScrollbarGrabHovered, - ImGuiCol_ScrollbarGrabActive, - ImGuiCol_CheckMark, - ImGuiCol_SliderGrab, - ImGuiCol_SliderGrabActive, - ImGuiCol_Button, - ImGuiCol_ButtonHovered, - ImGuiCol_ButtonActive, - ImGuiCol_Header, - ImGuiCol_HeaderHovered, - ImGuiCol_HeaderActive, - ImGuiCol_Separator, - ImGuiCol_SeparatorHovered, - ImGuiCol_SeparatorActive, - ImGuiCol_ResizeGrip, - ImGuiCol_ResizeGripHovered, - ImGuiCol_ResizeGripActive, - ImGuiCol_Tab, - ImGuiCol_TabHovered, - ImGuiCol_TabActive, - ImGuiCol_TabUnfocused, - ImGuiCol_TabUnfocusedActive, - ImGuiCol_DockingPreview, - ImGuiCol_DockingEmptyBg, - ImGuiCol_PlotLines, - ImGuiCol_PlotLinesHovered, - ImGuiCol_PlotHistogram, - ImGuiCol_PlotHistogramHovered, - ImGuiCol_TableHeaderBg, - ImGuiCol_TableBorderStrong, - ImGuiCol_TableBorderLight, - ImGuiCol_TableRowBg, - ImGuiCol_TableRowBgAlt, - ImGuiCol_TextSelectedBg, - ImGuiCol_DragDropTarget, - ImGuiCol_NavHighlight, - ImGuiCol_NavWindowingHighlight, - ImGuiCol_NavWindowingDimBg, - ImGuiCol_ModalWindowDimBg, - ImGuiCol_COUNT -} ImGuiCol_; -typedef enum { - ImGuiStyleVar_Alpha, - ImGuiStyleVar_DisabledAlpha, - ImGuiStyleVar_WindowPadding, - ImGuiStyleVar_WindowRounding, - ImGuiStyleVar_WindowBorderSize, - ImGuiStyleVar_WindowMinSize, - ImGuiStyleVar_WindowTitleAlign, - ImGuiStyleVar_ChildRounding, - ImGuiStyleVar_ChildBorderSize, - ImGuiStyleVar_PopupRounding, - ImGuiStyleVar_PopupBorderSize, - ImGuiStyleVar_FramePadding, - ImGuiStyleVar_FrameRounding, - ImGuiStyleVar_FrameBorderSize, - ImGuiStyleVar_ItemSpacing, - ImGuiStyleVar_ItemInnerSpacing, - ImGuiStyleVar_IndentSpacing, - ImGuiStyleVar_CellPadding, - ImGuiStyleVar_ScrollbarSize, - ImGuiStyleVar_ScrollbarRounding, - ImGuiStyleVar_GrabMinSize, - ImGuiStyleVar_GrabRounding, - ImGuiStyleVar_TabRounding, - ImGuiStyleVar_TabBorderSize, - ImGuiStyleVar_TabBarBorderSize, - ImGuiStyleVar_TableAngledHeadersAngle, - ImGuiStyleVar_TableAngledHeadersTextAlign, - ImGuiStyleVar_ButtonTextAlign, - ImGuiStyleVar_SelectableTextAlign, - ImGuiStyleVar_SeparatorTextBorderSize, - ImGuiStyleVar_SeparatorTextAlign, - ImGuiStyleVar_SeparatorTextPadding, - ImGuiStyleVar_DockingSeparatorSize, - ImGuiStyleVar_COUNT -} ImGuiStyleVar_; -typedef enum { - ImGuiButtonFlags_None = 0, - ImGuiButtonFlags_MouseButtonLeft = 1 << 0, - ImGuiButtonFlags_MouseButtonRight = 1 << 1, - ImGuiButtonFlags_MouseButtonMiddle = 1 << 2, - ImGuiButtonFlags_MouseButtonMask_ = ImGuiButtonFlags_MouseButtonLeft | - ImGuiButtonFlags_MouseButtonRight | - ImGuiButtonFlags_MouseButtonMiddle, - ImGuiButtonFlags_MouseButtonDefault_ = ImGuiButtonFlags_MouseButtonLeft, -} ImGuiButtonFlags_; -typedef enum { - ImGuiColorEditFlags_None = 0, - ImGuiColorEditFlags_NoAlpha = 1 << 1, - ImGuiColorEditFlags_NoPicker = 1 << 2, - ImGuiColorEditFlags_NoOptions = 1 << 3, - ImGuiColorEditFlags_NoSmallPreview = 1 << 4, - ImGuiColorEditFlags_NoInputs = 1 << 5, - ImGuiColorEditFlags_NoTooltip = 1 << 6, - ImGuiColorEditFlags_NoLabel = 1 << 7, - ImGuiColorEditFlags_NoSidePreview = 1 << 8, - ImGuiColorEditFlags_NoDragDrop = 1 << 9, - ImGuiColorEditFlags_NoBorder = 1 << 10, - ImGuiColorEditFlags_AlphaBar = 1 << 16, - ImGuiColorEditFlags_AlphaPreview = 1 << 17, - ImGuiColorEditFlags_AlphaPreviewHalf = 1 << 18, - ImGuiColorEditFlags_HDR = 1 << 19, - ImGuiColorEditFlags_DisplayRGB = 1 << 20, - ImGuiColorEditFlags_DisplayHSV = 1 << 21, - ImGuiColorEditFlags_DisplayHex = 1 << 22, - ImGuiColorEditFlags_Uint8 = 1 << 23, - ImGuiColorEditFlags_Float = 1 << 24, - ImGuiColorEditFlags_PickerHueBar = 1 << 25, - ImGuiColorEditFlags_PickerHueWheel = 1 << 26, - ImGuiColorEditFlags_InputRGB = 1 << 27, - ImGuiColorEditFlags_InputHSV = 1 << 28, - ImGuiColorEditFlags_DefaultOptions_ = - ImGuiColorEditFlags_Uint8 | ImGuiColorEditFlags_DisplayRGB | - ImGuiColorEditFlags_InputRGB | ImGuiColorEditFlags_PickerHueBar, - ImGuiColorEditFlags_DisplayMask_ = ImGuiColorEditFlags_DisplayRGB | - ImGuiColorEditFlags_DisplayHSV | - ImGuiColorEditFlags_DisplayHex, - ImGuiColorEditFlags_DataTypeMask_ = - ImGuiColorEditFlags_Uint8 | ImGuiColorEditFlags_Float, - ImGuiColorEditFlags_PickerMask_ = - ImGuiColorEditFlags_PickerHueWheel | ImGuiColorEditFlags_PickerHueBar, - ImGuiColorEditFlags_InputMask_ = - ImGuiColorEditFlags_InputRGB | ImGuiColorEditFlags_InputHSV, -} ImGuiColorEditFlags_; -typedef enum { - ImGuiSliderFlags_None = 0, - ImGuiSliderFlags_AlwaysClamp = 1 << 4, - ImGuiSliderFlags_Logarithmic = 1 << 5, - ImGuiSliderFlags_NoRoundToFormat = 1 << 6, - ImGuiSliderFlags_NoInput = 1 << 7, - ImGuiSliderFlags_InvalidMask_ = 0x7000000F, -} ImGuiSliderFlags_; -typedef enum { - ImGuiMouseButton_Left = 0, - ImGuiMouseButton_Right = 1, - ImGuiMouseButton_Middle = 2, - ImGuiMouseButton_COUNT = 5 -} ImGuiMouseButton_; -typedef enum { - ImGuiMouseCursor_None = -1, - ImGuiMouseCursor_Arrow = 0, - ImGuiMouseCursor_TextInput, - ImGuiMouseCursor_ResizeAll, - ImGuiMouseCursor_ResizeNS, - ImGuiMouseCursor_ResizeEW, - ImGuiMouseCursor_ResizeNESW, - ImGuiMouseCursor_ResizeNWSE, - ImGuiMouseCursor_Hand, - ImGuiMouseCursor_NotAllowed, - ImGuiMouseCursor_COUNT -} ImGuiMouseCursor_; -typedef enum { - ImGuiMouseSource_Mouse = 0, - ImGuiMouseSource_TouchScreen = 1, - ImGuiMouseSource_Pen = 2, - ImGuiMouseSource_COUNT = 3, -} ImGuiMouseSource; -typedef enum { - ImGuiCond_None = 0, - ImGuiCond_Always = 1 << 0, - ImGuiCond_Once = 1 << 1, - ImGuiCond_FirstUseEver = 1 << 2, - ImGuiCond_Appearing = 1 << 3, -} ImGuiCond_; -typedef enum { - ImGuiTableFlags_None = 0, - ImGuiTableFlags_Resizable = 1 << 0, - ImGuiTableFlags_Reorderable = 1 << 1, - ImGuiTableFlags_Hideable = 1 << 2, - ImGuiTableFlags_Sortable = 1 << 3, - ImGuiTableFlags_NoSavedSettings = 1 << 4, - ImGuiTableFlags_ContextMenuInBody = 1 << 5, - ImGuiTableFlags_RowBg = 1 << 6, - ImGuiTableFlags_BordersInnerH = 1 << 7, - ImGuiTableFlags_BordersOuterH = 1 << 8, - ImGuiTableFlags_BordersInnerV = 1 << 9, - ImGuiTableFlags_BordersOuterV = 1 << 10, - ImGuiTableFlags_BordersH = - ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_BordersOuterH, - ImGuiTableFlags_BordersV = - ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_BordersOuterV, - ImGuiTableFlags_BordersInner = - ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_BordersInnerH, - ImGuiTableFlags_BordersOuter = - ImGuiTableFlags_BordersOuterV | ImGuiTableFlags_BordersOuterH, - ImGuiTableFlags_Borders = - ImGuiTableFlags_BordersInner | ImGuiTableFlags_BordersOuter, - ImGuiTableFlags_NoBordersInBody = 1 << 11, - ImGuiTableFlags_NoBordersInBodyUntilResize = 1 << 12, - ImGuiTableFlags_SizingFixedFit = 1 << 13, - ImGuiTableFlags_SizingFixedSame = 2 << 13, - ImGuiTableFlags_SizingStretchProp = 3 << 13, - ImGuiTableFlags_SizingStretchSame = 4 << 13, - ImGuiTableFlags_NoHostExtendX = 1 << 16, - ImGuiTableFlags_NoHostExtendY = 1 << 17, - ImGuiTableFlags_NoKeepColumnsVisible = 1 << 18, - ImGuiTableFlags_PreciseWidths = 1 << 19, - ImGuiTableFlags_NoClip = 1 << 20, - ImGuiTableFlags_PadOuterX = 1 << 21, - ImGuiTableFlags_NoPadOuterX = 1 << 22, - ImGuiTableFlags_NoPadInnerX = 1 << 23, - ImGuiTableFlags_ScrollX = 1 << 24, - ImGuiTableFlags_ScrollY = 1 << 25, - ImGuiTableFlags_SortMulti = 1 << 26, - ImGuiTableFlags_SortTristate = 1 << 27, - ImGuiTableFlags_HighlightHoveredColumn = 1 << 28, - ImGuiTableFlags_SizingMask_ = - ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_SizingFixedSame | - ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_SizingStretchSame, -} ImGuiTableFlags_; -typedef enum { - ImGuiTableColumnFlags_None = 0, - ImGuiTableColumnFlags_Disabled = 1 << 0, - ImGuiTableColumnFlags_DefaultHide = 1 << 1, - ImGuiTableColumnFlags_DefaultSort = 1 << 2, - ImGuiTableColumnFlags_WidthStretch = 1 << 3, - ImGuiTableColumnFlags_WidthFixed = 1 << 4, - ImGuiTableColumnFlags_NoResize = 1 << 5, - ImGuiTableColumnFlags_NoReorder = 1 << 6, - ImGuiTableColumnFlags_NoHide = 1 << 7, - ImGuiTableColumnFlags_NoClip = 1 << 8, - ImGuiTableColumnFlags_NoSort = 1 << 9, - ImGuiTableColumnFlags_NoSortAscending = 1 << 10, - ImGuiTableColumnFlags_NoSortDescending = 1 << 11, - ImGuiTableColumnFlags_NoHeaderLabel = 1 << 12, - ImGuiTableColumnFlags_NoHeaderWidth = 1 << 13, - ImGuiTableColumnFlags_PreferSortAscending = 1 << 14, - ImGuiTableColumnFlags_PreferSortDescending = 1 << 15, - ImGuiTableColumnFlags_IndentEnable = 1 << 16, - ImGuiTableColumnFlags_IndentDisable = 1 << 17, - ImGuiTableColumnFlags_AngledHeader = 1 << 18, - ImGuiTableColumnFlags_IsEnabled = 1 << 24, - ImGuiTableColumnFlags_IsVisible = 1 << 25, - ImGuiTableColumnFlags_IsSorted = 1 << 26, - ImGuiTableColumnFlags_IsHovered = 1 << 27, - ImGuiTableColumnFlags_WidthMask_ = - ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_WidthFixed, - ImGuiTableColumnFlags_IndentMask_ = - ImGuiTableColumnFlags_IndentEnable | ImGuiTableColumnFlags_IndentDisable, - ImGuiTableColumnFlags_StatusMask_ = - ImGuiTableColumnFlags_IsEnabled | ImGuiTableColumnFlags_IsVisible | - ImGuiTableColumnFlags_IsSorted | ImGuiTableColumnFlags_IsHovered, - ImGuiTableColumnFlags_NoDirectResize_ = 1 << 30, -} ImGuiTableColumnFlags_; -typedef enum { - ImGuiTableRowFlags_None = 0, - ImGuiTableRowFlags_Headers = 1 << 0, -} ImGuiTableRowFlags_; -typedef enum { - ImGuiTableBgTarget_None = 0, - ImGuiTableBgTarget_RowBg0 = 1, - ImGuiTableBgTarget_RowBg1 = 2, - ImGuiTableBgTarget_CellBg = 3, -} ImGuiTableBgTarget_; -struct ImGuiTableSortSpecs { - const ImGuiTableColumnSortSpecs* Specs; - int SpecsCount; - bool SpecsDirty; -}; -struct ImGuiTableColumnSortSpecs { - ImGuiID ColumnUserID; - ImS16 ColumnIndex; - ImS16 SortOrder; - ImGuiSortDirection SortDirection : 8; -}; -struct ImGuiStyle { - float Alpha; - float DisabledAlpha; - ImVec2 WindowPadding; - float WindowRounding; - float WindowBorderSize; - ImVec2 WindowMinSize; - ImVec2 WindowTitleAlign; - ImGuiDir WindowMenuButtonPosition; - float ChildRounding; - float ChildBorderSize; - float PopupRounding; - float PopupBorderSize; - ImVec2 FramePadding; - float FrameRounding; - float FrameBorderSize; - ImVec2 ItemSpacing; - ImVec2 ItemInnerSpacing; - ImVec2 CellPadding; - ImVec2 TouchExtraPadding; - float IndentSpacing; - float ColumnsMinSpacing; - float ScrollbarSize; - float ScrollbarRounding; - float GrabMinSize; - float GrabRounding; - float LogSliderDeadzone; - float TabRounding; - float TabBorderSize; - float TabMinWidthForCloseButton; - float TabBarBorderSize; - float TableAngledHeadersAngle; - ImVec2 TableAngledHeadersTextAlign; - ImGuiDir ColorButtonPosition; - ImVec2 ButtonTextAlign; - ImVec2 SelectableTextAlign; - float SeparatorTextBorderSize; - ImVec2 SeparatorTextAlign; - ImVec2 SeparatorTextPadding; - ImVec2 DisplayWindowPadding; - ImVec2 DisplaySafeAreaPadding; - float DockingSeparatorSize; - float MouseCursorScale; - bool AntiAliasedLines; - bool AntiAliasedLinesUseTex; - bool AntiAliasedFill; - float CurveTessellationTol; - float CircleTessellationMaxError; - ImVec4 Colors[ImGuiCol_COUNT]; - float HoverStationaryDelay; - float HoverDelayShort; - float HoverDelayNormal; - ImGuiHoveredFlags HoverFlagsForTooltipMouse; - ImGuiHoveredFlags HoverFlagsForTooltipNav; -}; -struct ImGuiKeyData { - bool Down; - float DownDuration; - float DownDurationPrev; - float AnalogValue; -}; -typedef struct ImVector_ImWchar { - int Size; - int Capacity; - ImWchar* Data; -} ImVector_ImWchar; - -struct ImGuiIO { - ImGuiConfigFlags ConfigFlags; - ImGuiBackendFlags BackendFlags; - ImVec2 DisplaySize; - float DeltaTime; - float IniSavingRate; - const char* IniFilename; - const char* LogFilename; - void* UserData; - ImFontAtlas* Fonts; - float FontGlobalScale; - bool FontAllowUserScaling; - ImFont* FontDefault; - ImVec2 DisplayFramebufferScale; - bool ConfigDockingNoSplit; - bool ConfigDockingWithShift; - bool ConfigDockingAlwaysTabBar; - bool ConfigDockingTransparentPayload; - bool ConfigViewportsNoAutoMerge; - bool ConfigViewportsNoTaskBarIcon; - bool ConfigViewportsNoDecoration; - bool ConfigViewportsNoDefaultParent; - bool MouseDrawCursor; - bool ConfigMacOSXBehaviors; - bool ConfigInputTrickleEventQueue; - bool ConfigInputTextCursorBlink; - bool ConfigInputTextEnterKeepActive; - bool ConfigDragClickToInputText; - bool ConfigWindowsResizeFromEdges; - bool ConfigWindowsMoveFromTitleBarOnly; - float ConfigMemoryCompactTimer; - float MouseDoubleClickTime; - float MouseDoubleClickMaxDist; - float MouseDragThreshold; - float KeyRepeatDelay; - float KeyRepeatRate; - bool ConfigDebugIsDebuggerPresent; - bool ConfigDebugBeginReturnValueOnce; - bool ConfigDebugBeginReturnValueLoop; - bool ConfigDebugIgnoreFocusLoss; - bool ConfigDebugIniSettings; - const char* BackendPlatformName; - const char* BackendRendererName; - void* BackendPlatformUserData; - void* BackendRendererUserData; - void* BackendLanguageUserData; - const char* (*GetClipboardTextFn)(void* user_data); - void (*SetClipboardTextFn)(void* user_data, const char* text); - void* ClipboardUserData; - void (*SetPlatformImeDataFn)(ImGuiViewport* viewport, - ImGuiPlatformImeData* data); - ImWchar PlatformLocaleDecimalPoint; - bool WantCaptureMouse; - bool WantCaptureKeyboard; - bool WantTextInput; - bool WantSetMousePos; - bool WantSaveIniSettings; - bool NavActive; - bool NavVisible; - float Framerate; - int MetricsRenderVertices; - int MetricsRenderIndices; - int MetricsRenderWindows; - int MetricsActiveWindows; - ImVec2 MouseDelta; - ImGuiContext* Ctx; - ImVec2 MousePos; - bool MouseDown[5]; - float MouseWheel; - float MouseWheelH; - ImGuiMouseSource MouseSource; - ImGuiID MouseHoveredViewport; - bool KeyCtrl; - bool KeyShift; - bool KeyAlt; - bool KeySuper; - ImGuiKeyChord KeyMods; - ImGuiKeyData KeysData[ImGuiKey_KeysData_SIZE]; - bool WantCaptureMouseUnlessPopupClose; - ImVec2 MousePosPrev; - ImVec2 MouseClickedPos[5]; - double MouseClickedTime[5]; - bool MouseClicked[5]; - bool MouseDoubleClicked[5]; - ImU16 MouseClickedCount[5]; - ImU16 MouseClickedLastCount[5]; - bool MouseReleased[5]; - bool MouseDownOwned[5]; - bool MouseDownOwnedUnlessPopupClose[5]; - bool MouseWheelRequestAxisSwap; - float MouseDownDuration[5]; - float MouseDownDurationPrev[5]; - ImVec2 MouseDragMaxDistanceAbs[5]; - float MouseDragMaxDistanceSqr[5]; - float PenPressure; - bool AppFocusLost; - bool AppAcceptingEvents; - ImS8 BackendUsingLegacyKeyArrays; - bool BackendUsingLegacyNavInputArray; - ImWchar16 InputQueueSurrogate; - ImVector_ImWchar InputQueueCharacters; -}; -struct ImGuiInputTextCallbackData { - ImGuiContext* Ctx; - ImGuiInputTextFlags EventFlag; - ImGuiInputTextFlags Flags; - void* UserData; - ImWchar EventChar; - ImGuiKey EventKey; - char* Buf; - int BufTextLen; - int BufSize; - bool BufDirty; - int CursorPos; - int SelectionStart; - int SelectionEnd; -}; -struct ImGuiSizeCallbackData { - void* UserData; - ImVec2 Pos; - ImVec2 CurrentSize; - ImVec2 DesiredSize; -}; -struct ImGuiWindowClass { - ImGuiID ClassId; - ImGuiID ParentViewportId; - ImGuiID FocusRouteParentWindowId; - ImGuiViewportFlags ViewportFlagsOverrideSet; - ImGuiViewportFlags ViewportFlagsOverrideClear; - ImGuiTabItemFlags TabItemFlagsOverrideSet; - ImGuiDockNodeFlags DockNodeFlagsOverrideSet; - bool DockingAlwaysTabBar; - bool DockingAllowUnclassed; -}; -struct ImGuiPayload { - void* Data; - int DataSize; - ImGuiID SourceId; - ImGuiID SourceParentId; - int DataFrameCount; - char DataType[32 + 1]; - bool Preview; - bool Delivery; -}; -struct ImGuiOnceUponAFrame { - int RefFrame; -}; -struct ImGuiTextRange { - const char* b; - const char* e; -}; -typedef struct ImGuiTextRange ImGuiTextRange; - -typedef struct ImVector_ImGuiTextRange { - int Size; - int Capacity; - ImGuiTextRange* Data; -} ImVector_ImGuiTextRange; - -struct ImGuiTextFilter { - char InputBuf[256]; - ImVector_ImGuiTextRange Filters; - int CountGrep; -}; -typedef struct ImGuiTextRange ImGuiTextRange; -typedef struct ImVector_char { - int Size; - int Capacity; - char* Data; -} ImVector_char; - -struct ImGuiTextBuffer { - ImVector_char Buf; -}; -struct ImGuiStoragePair { - ImGuiID key; - union { - int val_i; - float val_f; - void* val_p; - }; -}; -typedef struct ImGuiStoragePair ImGuiStoragePair; - -typedef struct ImVector_ImGuiStoragePair { - int Size; - int Capacity; - ImGuiStoragePair* Data; -} ImVector_ImGuiStoragePair; - -struct ImGuiStorage { - ImVector_ImGuiStoragePair Data; -}; -typedef struct ImGuiStoragePair ImGuiStoragePair; -struct ImGuiListClipper { - ImGuiContext* Ctx; - int DisplayStart; - int DisplayEnd; - int ItemsCount; - float ItemsHeight; - float StartPosY; - void* TempData; -}; -struct ImColor { - ImVec4 Value; -}; -typedef void (*ImDrawCallback)(const ImDrawList* parent_list, - const ImDrawCmd* cmd); -struct ImDrawCmd { - ImVec4 ClipRect; - ImTextureID TextureId; - unsigned int VtxOffset; - unsigned int IdxOffset; - unsigned int ElemCount; - ImDrawCallback UserCallback; - void* UserCallbackData; -}; -struct ImDrawVert { - ImVec2 pos; - ImVec2 uv; - ImU32 col; -}; -typedef struct ImDrawCmdHeader ImDrawCmdHeader; -struct ImDrawCmdHeader { - ImVec4 ClipRect; - ImTextureID TextureId; - unsigned int VtxOffset; -}; -typedef struct ImVector_ImDrawCmd { - int Size; - int Capacity; - ImDrawCmd* Data; -} ImVector_ImDrawCmd; - -typedef struct ImVector_ImDrawIdx { - int Size; - int Capacity; - ImDrawIdx* Data; -} ImVector_ImDrawIdx; - -struct ImDrawChannel { - ImVector_ImDrawCmd _CmdBuffer; - ImVector_ImDrawIdx _IdxBuffer; -}; -typedef struct ImVector_ImDrawChannel { - int Size; - int Capacity; - ImDrawChannel* Data; -} ImVector_ImDrawChannel; - -struct ImDrawListSplitter { - int _Current; - int _Count; - ImVector_ImDrawChannel _Channels; -}; -typedef enum { - ImDrawFlags_None = 0, - ImDrawFlags_Closed = 1 << 0, - ImDrawFlags_RoundCornersTopLeft = 1 << 4, - ImDrawFlags_RoundCornersTopRight = 1 << 5, - ImDrawFlags_RoundCornersBottomLeft = 1 << 6, - ImDrawFlags_RoundCornersBottomRight = 1 << 7, - ImDrawFlags_RoundCornersNone = 1 << 8, - ImDrawFlags_RoundCornersTop = - ImDrawFlags_RoundCornersTopLeft | ImDrawFlags_RoundCornersTopRight, - ImDrawFlags_RoundCornersBottom = - ImDrawFlags_RoundCornersBottomLeft | ImDrawFlags_RoundCornersBottomRight, - ImDrawFlags_RoundCornersLeft = - ImDrawFlags_RoundCornersBottomLeft | ImDrawFlags_RoundCornersTopLeft, - ImDrawFlags_RoundCornersRight = - ImDrawFlags_RoundCornersBottomRight | ImDrawFlags_RoundCornersTopRight, - ImDrawFlags_RoundCornersAll = - ImDrawFlags_RoundCornersTopLeft | ImDrawFlags_RoundCornersTopRight | - ImDrawFlags_RoundCornersBottomLeft | ImDrawFlags_RoundCornersBottomRight, - ImDrawFlags_RoundCornersDefault_ = ImDrawFlags_RoundCornersAll, - ImDrawFlags_RoundCornersMask_ = - ImDrawFlags_RoundCornersAll | ImDrawFlags_RoundCornersNone, -} ImDrawFlags_; -typedef enum { - ImDrawListFlags_None = 0, - ImDrawListFlags_AntiAliasedLines = 1 << 0, - ImDrawListFlags_AntiAliasedLinesUseTex = 1 << 1, - ImDrawListFlags_AntiAliasedFill = 1 << 2, - ImDrawListFlags_AllowVtxOffset = 1 << 3, -} ImDrawListFlags_; -typedef struct ImVector_ImDrawVert { - int Size; - int Capacity; - ImDrawVert* Data; -} ImVector_ImDrawVert; - -typedef struct ImVector_ImVec2 { - int Size; - int Capacity; - ImVec2* Data; -} ImVector_ImVec2; - -typedef struct ImVector_ImVec4 { - int Size; - int Capacity; - ImVec4* Data; -} ImVector_ImVec4; - -typedef struct ImVector_ImTextureID { - int Size; - int Capacity; - ImTextureID* Data; -} ImVector_ImTextureID; - -struct ImDrawList { - ImVector_ImDrawCmd CmdBuffer; - ImVector_ImDrawIdx IdxBuffer; - ImVector_ImDrawVert VtxBuffer; - ImDrawListFlags Flags; - unsigned int _VtxCurrentIdx; - ImDrawListSharedData* _Data; - ImDrawVert* _VtxWritePtr; - ImDrawIdx* _IdxWritePtr; - ImVector_ImVec2 _Path; - ImDrawCmdHeader _CmdHeader; - ImDrawListSplitter _Splitter; - ImVector_ImVec4 _ClipRectStack; - ImVector_ImTextureID _TextureIdStack; - float _FringeScale; - const char* _OwnerName; -}; -typedef struct ImVector_ImDrawListPtr { - int Size; - int Capacity; - ImDrawList** Data; -} ImVector_ImDrawListPtr; - -struct ImDrawData { - bool Valid; - int CmdListsCount; - int TotalIdxCount; - int TotalVtxCount; - ImVector_ImDrawListPtr CmdLists; - ImVec2 DisplayPos; - ImVec2 DisplaySize; - ImVec2 FramebufferScale; - ImGuiViewport* OwnerViewport; -}; -struct ImFontConfig { - void* FontData; - int FontDataSize; - bool FontDataOwnedByAtlas; - int FontNo; - float SizePixels; - int OversampleH; - int OversampleV; - bool PixelSnapH; - ImVec2 GlyphExtraSpacing; - ImVec2 GlyphOffset; - const ImWchar* GlyphRanges; - float GlyphMinAdvanceX; - float GlyphMaxAdvanceX; - bool MergeMode; - unsigned int FontBuilderFlags; - float RasterizerMultiply; - float RasterizerDensity; - ImWchar EllipsisChar; - char Name[40]; - ImFont* DstFont; -}; -struct ImFontGlyph { - unsigned int Colored : 1; - unsigned int Visible : 1; - unsigned int Codepoint : 30; - float AdvanceX; - float X0, Y0, X1, Y1; - float U0, V0, U1, V1; -}; -typedef struct ImVector_ImU32 { - int Size; - int Capacity; - ImU32* Data; -} ImVector_ImU32; - -struct ImFontGlyphRangesBuilder { - ImVector_ImU32 UsedChars; -}; -typedef struct ImFontAtlasCustomRect ImFontAtlasCustomRect; -struct ImFontAtlasCustomRect { - unsigned short Width, Height; - unsigned short X, Y; - unsigned int GlyphID; - float GlyphAdvanceX; - ImVec2 GlyphOffset; - ImFont* Font; -}; -typedef enum { - ImFontAtlasFlags_None = 0, - ImFontAtlasFlags_NoPowerOfTwoHeight = 1 << 0, - ImFontAtlasFlags_NoMouseCursors = 1 << 1, - ImFontAtlasFlags_NoBakedLines = 1 << 2, -} ImFontAtlasFlags_; -typedef struct ImVector_ImFontPtr { - int Size; - int Capacity; - ImFont** Data; -} ImVector_ImFontPtr; - -typedef struct ImVector_ImFontAtlasCustomRect { - int Size; - int Capacity; - ImFontAtlasCustomRect* Data; -} ImVector_ImFontAtlasCustomRect; - -typedef struct ImVector_ImFontConfig { - int Size; - int Capacity; - ImFontConfig* Data; -} ImVector_ImFontConfig; - -struct ImFontAtlas { - ImFontAtlasFlags Flags; - ImTextureID TexID; - int TexDesiredWidth; - int TexGlyphPadding; - bool Locked; - void* UserData; - bool TexReady; - bool TexPixelsUseColors; - unsigned char* TexPixelsAlpha8; - unsigned int* TexPixelsRGBA32; - int TexWidth; - int TexHeight; - ImVec2 TexUvScale; - ImVec2 TexUvWhitePixel; - ImVector_ImFontPtr Fonts; - ImVector_ImFontAtlasCustomRect CustomRects; - ImVector_ImFontConfig ConfigData; - ImVec4 TexUvLines[(63) + 1]; - const ImFontBuilderIO* FontBuilderIO; - unsigned int FontBuilderFlags; - int PackIdMouseCursors; - int PackIdLines; -}; -typedef struct ImVector_float { - int Size; - int Capacity; - float* Data; -} ImVector_float; - -typedef struct ImVector_ImFontGlyph { - int Size; - int Capacity; - ImFontGlyph* Data; -} ImVector_ImFontGlyph; - -struct ImFont { - ImVector_float IndexAdvanceX; - float FallbackAdvanceX; - float FontSize; - ImVector_ImWchar IndexLookup; - ImVector_ImFontGlyph Glyphs; - const ImFontGlyph* FallbackGlyph; - ImFontAtlas* ContainerAtlas; - const ImFontConfig* ConfigData; - short ConfigDataCount; - ImWchar FallbackChar; - ImWchar EllipsisChar; - short EllipsisCharCount; - float EllipsisWidth; - float EllipsisCharStep; - bool DirtyLookupTables; - float Scale; - float Ascent, Descent; - int MetricsTotalSurface; - ImU8 Used4kPagesMap[(0xFFFF + 1) / 4096 / 8]; -}; -typedef enum { - ImGuiViewportFlags_None = 0, - ImGuiViewportFlags_IsPlatformWindow = 1 << 0, - ImGuiViewportFlags_IsPlatformMonitor = 1 << 1, - ImGuiViewportFlags_OwnedByApp = 1 << 2, - ImGuiViewportFlags_NoDecoration = 1 << 3, - ImGuiViewportFlags_NoTaskBarIcon = 1 << 4, - ImGuiViewportFlags_NoFocusOnAppearing = 1 << 5, - ImGuiViewportFlags_NoFocusOnClick = 1 << 6, - ImGuiViewportFlags_NoInputs = 1 << 7, - ImGuiViewportFlags_NoRendererClear = 1 << 8, - ImGuiViewportFlags_NoAutoMerge = 1 << 9, - ImGuiViewportFlags_TopMost = 1 << 10, - ImGuiViewportFlags_CanHostOtherWindows = 1 << 11, - ImGuiViewportFlags_IsMinimized = 1 << 12, - ImGuiViewportFlags_IsFocused = 1 << 13, -} ImGuiViewportFlags_; -struct ImGuiViewport { - ImGuiID ID; - ImGuiViewportFlags Flags; - ImVec2 Pos; - ImVec2 Size; - ImVec2 WorkPos; - ImVec2 WorkSize; - float DpiScale; - ImGuiID ParentViewportId; - ImDrawData* DrawData; - void* RendererUserData; - void* PlatformUserData; - void* PlatformHandle; - void* PlatformHandleRaw; - bool PlatformWindowCreated; - bool PlatformRequestMove; - bool PlatformRequestResize; - bool PlatformRequestClose; -}; -typedef struct ImVector_ImGuiPlatformMonitor { - int Size; - int Capacity; - ImGuiPlatformMonitor* Data; -} ImVector_ImGuiPlatformMonitor; - -typedef struct ImVector_ImGuiViewportPtr { - int Size; - int Capacity; - ImGuiViewport** Data; -} ImVector_ImGuiViewportPtr; - -struct ImGuiPlatformIO { - void (*Platform_CreateWindow)(ImGuiViewport* vp); - void (*Platform_DestroyWindow)(ImGuiViewport* vp); - void (*Platform_ShowWindow)(ImGuiViewport* vp); - void (*Platform_SetWindowPos)(ImGuiViewport* vp, ImVec2 pos); - ImVec2 (*Platform_GetWindowPos)(ImGuiViewport* vp); - void (*Platform_SetWindowSize)(ImGuiViewport* vp, ImVec2 size); - ImVec2 (*Platform_GetWindowSize)(ImGuiViewport* vp); - void (*Platform_SetWindowFocus)(ImGuiViewport* vp); - bool (*Platform_GetWindowFocus)(ImGuiViewport* vp); - bool (*Platform_GetWindowMinimized)(ImGuiViewport* vp); - void (*Platform_SetWindowTitle)(ImGuiViewport* vp, const char* str); - void (*Platform_SetWindowAlpha)(ImGuiViewport* vp, float alpha); - void (*Platform_UpdateWindow)(ImGuiViewport* vp); - void (*Platform_RenderWindow)(ImGuiViewport* vp, void* render_arg); - void (*Platform_SwapBuffers)(ImGuiViewport* vp, void* render_arg); - float (*Platform_GetWindowDpiScale)(ImGuiViewport* vp); - void (*Platform_OnChangedViewport)(ImGuiViewport* vp); - int (*Platform_CreateVkSurface)(ImGuiViewport* vp, - ImU64 vk_inst, - const void* vk_allocators, - ImU64* out_vk_surface); - void (*Renderer_CreateWindow)(ImGuiViewport* vp); - void (*Renderer_DestroyWindow)(ImGuiViewport* vp); - void (*Renderer_SetWindowSize)(ImGuiViewport* vp, ImVec2 size); - void (*Renderer_RenderWindow)(ImGuiViewport* vp, void* render_arg); - void (*Renderer_SwapBuffers)(ImGuiViewport* vp, void* render_arg); - ImVector_ImGuiPlatformMonitor Monitors; - ImVector_ImGuiViewportPtr Viewports; -}; -struct ImGuiPlatformMonitor { - ImVec2 MainPos, MainSize; - ImVec2 WorkPos, WorkSize; - float DpiScale; - void* PlatformHandle; -}; -struct ImGuiPlatformImeData { - bool WantVisible; - ImVec2 InputPos; - float InputLineHeight; -}; -struct ImBitVector; -struct ImRect; -struct ImDrawDataBuilder; -struct ImDrawListSharedData; -struct ImGuiColorMod; -struct ImGuiContext; -struct ImGuiContextHook; -struct ImGuiDataVarInfo; -struct ImGuiDataTypeInfo; -struct ImGuiDockContext; -struct ImGuiDockRequest; -struct ImGuiDockNode; -struct ImGuiDockNodeSettings; -struct ImGuiGroupData; -struct ImGuiInputTextState; -struct ImGuiInputTextDeactivateData; -struct ImGuiLastItemData; -struct ImGuiLocEntry; -struct ImGuiMenuColumns; -struct ImGuiNavItemData; -struct ImGuiNavTreeNodeData; -struct ImGuiMetricsConfig; -struct ImGuiNextWindowData; -struct ImGuiNextItemData; -struct ImGuiOldColumnData; -struct ImGuiOldColumns; -struct ImGuiPopupData; -struct ImGuiSettingsHandler; -struct ImGuiStackSizes; -struct ImGuiStyleMod; -struct ImGuiTabBar; -struct ImGuiTabItem; -struct ImGuiTable; -struct ImGuiTableHeaderData; -struct ImGuiTableColumn; -struct ImGuiTableInstanceData; -struct ImGuiTableTempData; -struct ImGuiTableSettings; -struct ImGuiTableColumnsSettings; -struct ImGuiTypingSelectState; -struct ImGuiTypingSelectRequest; -struct ImGuiWindow; -struct ImGuiWindowDockStyle; -struct ImGuiWindowTempData; -struct ImGuiWindowSettings; -typedef int ImGuiDataAuthority; -typedef int ImGuiLayoutType; -typedef int ImGuiActivateFlags; -typedef int ImGuiDebugLogFlags; -typedef int ImGuiFocusRequestFlags; -typedef int ImGuiInputFlags; -typedef int ImGuiItemFlags; -typedef int ImGuiItemStatusFlags; -typedef int ImGuiOldColumnFlags; -typedef int ImGuiNavHighlightFlags; -typedef int ImGuiNavMoveFlags; -typedef int ImGuiNextItemDataFlags; -typedef int ImGuiNextWindowDataFlags; -typedef int ImGuiScrollFlags; -typedef int ImGuiSeparatorFlags; -typedef int ImGuiTextFlags; -typedef int ImGuiTooltipFlags; -typedef int ImGuiTypingSelectFlags; -typedef int ImGuiWindowRefreshFlags; -typedef void (*ImGuiErrorLogCallback)(void* user_data, const char* fmt, ...); -extern ImGuiContext* GImGui; -typedef struct StbUndoRecord StbUndoRecord; -struct StbUndoRecord { - int where; - int insert_length; - int delete_length; - int char_storage; -}; -typedef struct StbUndoState StbUndoState; -struct StbUndoState { - StbUndoRecord undo_rec[99]; - ImWchar undo_char[999]; - short undo_point, redo_point; - int undo_char_point, redo_char_point; -}; -typedef struct STB_TexteditState STB_TexteditState; -struct STB_TexteditState { - int cursor; - int select_start; - int select_end; - unsigned char insert_mode; - int row_count_per_page; - unsigned char cursor_at_end_of_line; - unsigned char initialized; - unsigned char has_preferred_x; - unsigned char single_line; - unsigned char padding1, padding2, padding3; - float preferred_x; - StbUndoState undostate; -}; -typedef struct StbTexteditRow StbTexteditRow; -struct StbTexteditRow { - float x0, x1; - float baseline_y_delta; - float ymin, ymax; - int num_chars; -}; -typedef FILE* ImFileHandle; -typedef struct ImVec1 ImVec1; -struct ImVec1 { - float x; -}; -typedef struct ImVec2ih ImVec2ih; -struct ImVec2ih { - short x, y; -}; -struct ImRect { - ImVec2 Min; - ImVec2 Max; -}; -typedef ImU32* ImBitArrayPtr; -struct ImBitVector { - ImVector_ImU32 Storage; -}; -typedef int ImPoolIdx; -typedef struct ImGuiTextIndex ImGuiTextIndex; -typedef struct ImVector_int { - int Size; - int Capacity; - int* Data; -} ImVector_int; - -struct ImGuiTextIndex { - ImVector_int LineOffsets; - int EndOffset; -}; -struct ImDrawListSharedData { - ImVec2 TexUvWhitePixel; - ImFont* Font; - float FontSize; - float CurveTessellationTol; - float CircleSegmentMaxError; - ImVec4 ClipRectFullscreen; - ImDrawListFlags InitialFlags; - ImVector_ImVec2 TempBuffer; - ImVec2 ArcFastVtx[48]; - float ArcFastRadiusCutoff; - ImU8 CircleSegmentCounts[64]; - const ImVec4* TexUvLines; -}; -struct ImDrawDataBuilder { - ImVector_ImDrawListPtr* Layers[2]; - ImVector_ImDrawListPtr LayerData1; -}; -typedef enum { - ImGuiItemFlags_None = 0, - ImGuiItemFlags_NoTabStop = 1 << 0, - ImGuiItemFlags_ButtonRepeat = 1 << 1, - ImGuiItemFlags_Disabled = 1 << 2, - ImGuiItemFlags_NoNav = 1 << 3, - ImGuiItemFlags_NoNavDefaultFocus = 1 << 4, - ImGuiItemFlags_SelectableDontClosePopup = 1 << 5, - ImGuiItemFlags_MixedValue = 1 << 6, - ImGuiItemFlags_ReadOnly = 1 << 7, - ImGuiItemFlags_NoWindowHoverableCheck = 1 << 8, - ImGuiItemFlags_AllowOverlap = 1 << 9, - ImGuiItemFlags_Inputable = 1 << 10, - ImGuiItemFlags_HasSelectionUserData = 1 << 11, -} ImGuiItemFlags_; -typedef enum { - ImGuiItemStatusFlags_None = 0, - ImGuiItemStatusFlags_HoveredRect = 1 << 0, - ImGuiItemStatusFlags_HasDisplayRect = 1 << 1, - ImGuiItemStatusFlags_Edited = 1 << 2, - ImGuiItemStatusFlags_ToggledSelection = 1 << 3, - ImGuiItemStatusFlags_ToggledOpen = 1 << 4, - ImGuiItemStatusFlags_HasDeactivated = 1 << 5, - ImGuiItemStatusFlags_Deactivated = 1 << 6, - ImGuiItemStatusFlags_HoveredWindow = 1 << 7, - ImGuiItemStatusFlags_Visible = 1 << 8, - ImGuiItemStatusFlags_HasClipRect = 1 << 9, -} ImGuiItemStatusFlags_; -typedef enum { - ImGuiHoveredFlags_DelayMask_ = - ImGuiHoveredFlags_DelayNone | ImGuiHoveredFlags_DelayShort | - ImGuiHoveredFlags_DelayNormal | ImGuiHoveredFlags_NoSharedDelay, - ImGuiHoveredFlags_AllowedMaskForIsWindowHovered = - ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_RootWindow | - ImGuiHoveredFlags_AnyWindow | ImGuiHoveredFlags_NoPopupHierarchy | - ImGuiHoveredFlags_DockHierarchy | - ImGuiHoveredFlags_AllowWhenBlockedByPopup | - ImGuiHoveredFlags_AllowWhenBlockedByActiveItem | - ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_Stationary, - ImGuiHoveredFlags_AllowedMaskForIsItemHovered = - ImGuiHoveredFlags_AllowWhenBlockedByPopup | - ImGuiHoveredFlags_AllowWhenBlockedByActiveItem | - ImGuiHoveredFlags_AllowWhenOverlapped | - ImGuiHoveredFlags_AllowWhenDisabled | ImGuiHoveredFlags_NoNavOverride | - ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_Stationary | - ImGuiHoveredFlags_DelayMask_, -} ImGuiHoveredFlagsPrivate_; -typedef enum { - ImGuiInputTextFlags_Multiline = 1 << 26, - ImGuiInputTextFlags_NoMarkEdited = 1 << 27, - ImGuiInputTextFlags_MergedItem = 1 << 28, - ImGuiInputTextFlags_LocalizeDecimalPoint = 1 << 29, -} ImGuiInputTextFlagsPrivate_; -typedef enum { - ImGuiButtonFlags_PressedOnClick = 1 << 4, - ImGuiButtonFlags_PressedOnClickRelease = 1 << 5, - ImGuiButtonFlags_PressedOnClickReleaseAnywhere = 1 << 6, - ImGuiButtonFlags_PressedOnRelease = 1 << 7, - ImGuiButtonFlags_PressedOnDoubleClick = 1 << 8, - ImGuiButtonFlags_PressedOnDragDropHold = 1 << 9, - ImGuiButtonFlags_Repeat = 1 << 10, - ImGuiButtonFlags_FlattenChildren = 1 << 11, - ImGuiButtonFlags_AllowOverlap = 1 << 12, - ImGuiButtonFlags_DontClosePopups = 1 << 13, - ImGuiButtonFlags_AlignTextBaseLine = 1 << 15, - ImGuiButtonFlags_NoKeyModifiers = 1 << 16, - ImGuiButtonFlags_NoHoldingActiveId = 1 << 17, - ImGuiButtonFlags_NoNavFocus = 1 << 18, - ImGuiButtonFlags_NoHoveredOnFocus = 1 << 19, - ImGuiButtonFlags_NoSetKeyOwner = 1 << 20, - ImGuiButtonFlags_NoTestKeyOwner = 1 << 21, - ImGuiButtonFlags_PressedOnMask_ = - ImGuiButtonFlags_PressedOnClick | ImGuiButtonFlags_PressedOnClickRelease | - ImGuiButtonFlags_PressedOnClickReleaseAnywhere | - ImGuiButtonFlags_PressedOnRelease | - ImGuiButtonFlags_PressedOnDoubleClick | - ImGuiButtonFlags_PressedOnDragDropHold, - ImGuiButtonFlags_PressedOnDefault_ = ImGuiButtonFlags_PressedOnClickRelease, -} ImGuiButtonFlagsPrivate_; -typedef enum { - ImGuiComboFlags_CustomPreview = 1 << 20, -} ImGuiComboFlagsPrivate_; -typedef enum { - ImGuiSliderFlags_Vertical = 1 << 20, - ImGuiSliderFlags_ReadOnly = 1 << 21, -} ImGuiSliderFlagsPrivate_; -typedef enum { - ImGuiSelectableFlags_NoHoldingActiveID = 1 << 20, - ImGuiSelectableFlags_SelectOnNav = 1 << 21, - ImGuiSelectableFlags_SelectOnClick = 1 << 22, - ImGuiSelectableFlags_SelectOnRelease = 1 << 23, - ImGuiSelectableFlags_SpanAvailWidth = 1 << 24, - ImGuiSelectableFlags_SetNavIdOnHover = 1 << 25, - ImGuiSelectableFlags_NoPadWithHalfSpacing = 1 << 26, - ImGuiSelectableFlags_NoSetKeyOwner = 1 << 27, -} ImGuiSelectableFlagsPrivate_; -typedef enum { - ImGuiTreeNodeFlags_ClipLabelForTrailingButton = 1 << 20, - ImGuiTreeNodeFlags_UpsideDownArrow = 1 << 21, -} ImGuiTreeNodeFlagsPrivate_; -typedef enum { - ImGuiSeparatorFlags_None = 0, - ImGuiSeparatorFlags_Horizontal = 1 << 0, - ImGuiSeparatorFlags_Vertical = 1 << 1, - ImGuiSeparatorFlags_SpanAllColumns = 1 << 2, -} ImGuiSeparatorFlags_; -typedef enum { - ImGuiFocusRequestFlags_None = 0, - ImGuiFocusRequestFlags_RestoreFocusedChild = 1 << 0, - ImGuiFocusRequestFlags_UnlessBelowModal = 1 << 1, -} ImGuiFocusRequestFlags_; -typedef enum { - ImGuiTextFlags_None = 0, - ImGuiTextFlags_NoWidthForLargeClippedText = 1 << 0, -} ImGuiTextFlags_; -typedef enum { - ImGuiTooltipFlags_None = 0, - ImGuiTooltipFlags_OverridePrevious = 1 << 1, -} ImGuiTooltipFlags_; -typedef enum { - ImGuiLayoutType_Horizontal = 0, - ImGuiLayoutType_Vertical = 1 -} ImGuiLayoutType_; -typedef enum { - ImGuiLogType_None = 0, - ImGuiLogType_TTY, - ImGuiLogType_File, - ImGuiLogType_Buffer, - ImGuiLogType_Clipboard, -} ImGuiLogType; -typedef enum { - ImGuiAxis_None = -1, - ImGuiAxis_X = 0, - ImGuiAxis_Y = 1 -} ImGuiAxis; -typedef enum { - ImGuiPlotType_Lines, - ImGuiPlotType_Histogram, -} ImGuiPlotType; -struct ImGuiColorMod { - ImGuiCol Col; - ImVec4 BackupValue; -}; -struct ImGuiStyleMod { - ImGuiStyleVar VarIdx; - union { - int BackupInt[2]; - float BackupFloat[2]; - }; -}; -typedef struct ImGuiComboPreviewData ImGuiComboPreviewData; -struct ImGuiComboPreviewData { - ImRect PreviewRect; - ImVec2 BackupCursorPos; - ImVec2 BackupCursorMaxPos; - ImVec2 BackupCursorPosPrevLine; - float BackupPrevLineTextBaseOffset; - ImGuiLayoutType BackupLayout; -}; -struct ImGuiGroupData { - ImGuiID WindowID; - ImVec2 BackupCursorPos; - ImVec2 BackupCursorMaxPos; - ImVec2 BackupCursorPosPrevLine; - ImVec1 BackupIndent; - ImVec1 BackupGroupOffset; - ImVec2 BackupCurrLineSize; - float BackupCurrLineTextBaseOffset; - ImGuiID BackupActiveIdIsAlive; - bool BackupActiveIdPreviousFrameIsAlive; - bool BackupHoveredIdIsAlive; - bool BackupIsSameLine; - bool EmitItem; -}; -struct ImGuiMenuColumns { - ImU32 TotalWidth; - ImU32 NextTotalWidth; - ImU16 Spacing; - ImU16 OffsetIcon; - ImU16 OffsetLabel; - ImU16 OffsetShortcut; - ImU16 OffsetMark; - ImU16 Widths[4]; -}; -typedef struct ImGuiInputTextDeactivatedState ImGuiInputTextDeactivatedState; -struct ImGuiInputTextDeactivatedState { - ImGuiID ID; - ImVector_char TextA; -}; -struct ImGuiInputTextState { - ImGuiContext* Ctx; - ImGuiID ID; - int CurLenW, CurLenA; - ImVector_ImWchar TextW; - ImVector_char TextA; - ImVector_char InitialTextA; - bool TextAIsValid; - int BufCapacityA; - float ScrollX; - STB_TexteditState Stb; - float CursorAnim; - bool CursorFollow; - bool SelectedAllMouseLock; - bool Edited; - ImGuiInputTextFlags Flags; - bool ReloadUserBuf; - int ReloadSelectionStart; - int ReloadSelectionEnd; -}; -typedef enum { - ImGuiWindowRefreshFlags_None = 0, - ImGuiWindowRefreshFlags_TryToAvoidRefresh = 1 << 0, - ImGuiWindowRefreshFlags_RefreshOnHover = 1 << 1, - ImGuiWindowRefreshFlags_RefreshOnFocus = 1 << 2, -} ImGuiWindowRefreshFlags_; -typedef enum { - ImGuiNextWindowDataFlags_None = 0, - ImGuiNextWindowDataFlags_HasPos = 1 << 0, - ImGuiNextWindowDataFlags_HasSize = 1 << 1, - ImGuiNextWindowDataFlags_HasContentSize = 1 << 2, - ImGuiNextWindowDataFlags_HasCollapsed = 1 << 3, - ImGuiNextWindowDataFlags_HasSizeConstraint = 1 << 4, - ImGuiNextWindowDataFlags_HasFocus = 1 << 5, - ImGuiNextWindowDataFlags_HasBgAlpha = 1 << 6, - ImGuiNextWindowDataFlags_HasScroll = 1 << 7, - ImGuiNextWindowDataFlags_HasChildFlags = 1 << 8, - ImGuiNextWindowDataFlags_HasRefreshPolicy = 1 << 9, - ImGuiNextWindowDataFlags_HasViewport = 1 << 10, - ImGuiNextWindowDataFlags_HasDock = 1 << 11, - ImGuiNextWindowDataFlags_HasWindowClass = 1 << 12, -} ImGuiNextWindowDataFlags_; -struct ImGuiNextWindowData { - ImGuiNextWindowDataFlags Flags; - ImGuiCond PosCond; - ImGuiCond SizeCond; - ImGuiCond CollapsedCond; - ImGuiCond DockCond; - ImVec2 PosVal; - ImVec2 PosPivotVal; - ImVec2 SizeVal; - ImVec2 ContentSizeVal; - ImVec2 ScrollVal; - ImGuiChildFlags ChildFlags; - bool PosUndock; - bool CollapsedVal; - ImRect SizeConstraintRect; - ImGuiSizeCallback SizeCallback; - void* SizeCallbackUserData; - float BgAlphaVal; - ImGuiID ViewportId; - ImGuiID DockId; - ImGuiWindowClass WindowClass; - ImVec2 MenuBarOffsetMinVal; - ImGuiWindowRefreshFlags RefreshFlagsVal; -}; -typedef ImS64 ImGuiSelectionUserData; -typedef enum { - ImGuiNextItemDataFlags_None = 0, - ImGuiNextItemDataFlags_HasWidth = 1 << 0, - ImGuiNextItemDataFlags_HasOpen = 1 << 1, - ImGuiNextItemDataFlags_HasShortcut = 1 << 2, -} ImGuiNextItemDataFlags_; -struct ImGuiNextItemData { - ImGuiNextItemDataFlags Flags; - ImGuiItemFlags ItemFlags; - ImGuiSelectionUserData SelectionUserData; - float Width; - ImGuiKeyChord Shortcut; - bool OpenVal; - ImGuiCond OpenCond : 8; -}; -struct ImGuiLastItemData { - ImGuiID ID; - ImGuiItemFlags InFlags; - ImGuiItemStatusFlags StatusFlags; - ImRect Rect; - ImRect NavRect; - ImRect DisplayRect; - ImRect ClipRect; -}; -struct ImGuiNavTreeNodeData { - ImGuiID ID; - ImGuiItemFlags InFlags; - ImRect NavRect; -}; -struct ImGuiStackSizes { - short SizeOfIDStack; - short SizeOfColorStack; - short SizeOfStyleVarStack; - short SizeOfFontStack; - short SizeOfFocusScopeStack; - short SizeOfGroupStack; - short SizeOfItemFlagsStack; - short SizeOfBeginPopupStack; - short SizeOfDisabledStack; -}; -typedef struct ImGuiWindowStackData ImGuiWindowStackData; -struct ImGuiWindowStackData { - ImGuiWindow* Window; - ImGuiLastItemData ParentLastItemDataBackup; - ImGuiStackSizes StackSizesOnBegin; -}; -typedef struct ImGuiShrinkWidthItem ImGuiShrinkWidthItem; -struct ImGuiShrinkWidthItem { - int Index; - float Width; - float InitialWidth; -}; -typedef struct ImGuiPtrOrIndex ImGuiPtrOrIndex; -struct ImGuiPtrOrIndex { - void* Ptr; - int Index; -}; -struct ImGuiDataVarInfo { - ImGuiDataType Type; - ImU32 Count; - ImU32 Offset; -}; -typedef struct ImGuiDataTypeTempStorage ImGuiDataTypeTempStorage; -struct ImGuiDataTypeTempStorage { - ImU8 Data[8]; -}; -struct ImGuiDataTypeInfo { - size_t Size; - const char* Name; - const char* PrintFmt; - const char* ScanFmt; -}; -typedef enum { - ImGuiDataType_String = ImGuiDataType_COUNT + 1, - ImGuiDataType_Pointer, - ImGuiDataType_ID, -} ImGuiDataTypePrivate_; -typedef enum { - ImGuiPopupPositionPolicy_Default, - ImGuiPopupPositionPolicy_ComboBox, - ImGuiPopupPositionPolicy_Tooltip, -} ImGuiPopupPositionPolicy; -struct ImGuiPopupData { - ImGuiID PopupId; - ImGuiWindow* Window; - ImGuiWindow* RestoreNavWindow; - int ParentNavLayer; - int OpenFrameCount; - ImGuiID OpenParentId; - ImVec2 OpenPopupPos; - ImVec2 OpenMousePos; -}; -typedef struct ImBitArray_ImGuiKey_NamedKey_COUNT__lessImGuiKey_NamedKey_BEGIN { - ImU32 Storage[(ImGuiKey_NamedKey_COUNT + 31) >> 5]; -} ImBitArray_ImGuiKey_NamedKey_COUNT__lessImGuiKey_NamedKey_BEGIN; - -typedef ImBitArray_ImGuiKey_NamedKey_COUNT__lessImGuiKey_NamedKey_BEGIN - ImBitArrayForNamedKeys; -typedef enum { - ImGuiInputEventType_None = 0, - ImGuiInputEventType_MousePos, - ImGuiInputEventType_MouseWheel, - ImGuiInputEventType_MouseButton, - ImGuiInputEventType_MouseViewport, - ImGuiInputEventType_Key, - ImGuiInputEventType_Text, - ImGuiInputEventType_Focus, - ImGuiInputEventType_COUNT -} ImGuiInputEventType; -typedef enum { - ImGuiInputSource_None = 0, - ImGuiInputSource_Mouse, - ImGuiInputSource_Keyboard, - ImGuiInputSource_Gamepad, - ImGuiInputSource_COUNT -} ImGuiInputSource; -typedef struct ImGuiInputEventMousePos ImGuiInputEventMousePos; -struct ImGuiInputEventMousePos { - float PosX, PosY; - ImGuiMouseSource MouseSource; -}; -typedef struct ImGuiInputEventMouseWheel ImGuiInputEventMouseWheel; -struct ImGuiInputEventMouseWheel { - float WheelX, WheelY; - ImGuiMouseSource MouseSource; -}; -typedef struct ImGuiInputEventMouseButton ImGuiInputEventMouseButton; -struct ImGuiInputEventMouseButton { - int Button; - bool Down; - ImGuiMouseSource MouseSource; -}; -typedef struct ImGuiInputEventMouseViewport ImGuiInputEventMouseViewport; -struct ImGuiInputEventMouseViewport { - ImGuiID HoveredViewportID; -}; -typedef struct ImGuiInputEventKey ImGuiInputEventKey; -struct ImGuiInputEventKey { - ImGuiKey Key; - bool Down; - float AnalogValue; -}; -typedef struct ImGuiInputEventText ImGuiInputEventText; -struct ImGuiInputEventText { - unsigned int Char; -}; -typedef struct ImGuiInputEventAppFocused ImGuiInputEventAppFocused; -struct ImGuiInputEventAppFocused { - bool Focused; -}; -typedef struct ImGuiInputEvent ImGuiInputEvent; -struct ImGuiInputEvent { - ImGuiInputEventType Type; - ImGuiInputSource Source; - ImU32 EventId; - union { - ImGuiInputEventMousePos MousePos; - ImGuiInputEventMouseWheel MouseWheel; - ImGuiInputEventMouseButton MouseButton; - ImGuiInputEventMouseViewport MouseViewport; - ImGuiInputEventKey Key; - ImGuiInputEventText Text; - ImGuiInputEventAppFocused AppFocused; - }; - bool AddedByTestEngine; -}; -typedef ImS16 ImGuiKeyRoutingIndex; -typedef struct ImGuiKeyRoutingData ImGuiKeyRoutingData; -struct ImGuiKeyRoutingData { - ImGuiKeyRoutingIndex NextEntryIndex; - ImU16 Mods; - ImU8 RoutingCurrScore; - ImU8 RoutingNextScore; - ImGuiID RoutingCurr; - ImGuiID RoutingNext; -}; -typedef struct ImGuiKeyRoutingTable ImGuiKeyRoutingTable; -typedef struct ImVector_ImGuiKeyRoutingData { - int Size; - int Capacity; - ImGuiKeyRoutingData* Data; -} ImVector_ImGuiKeyRoutingData; - -struct ImGuiKeyRoutingTable { - ImGuiKeyRoutingIndex Index[ImGuiKey_NamedKey_COUNT]; - ImVector_ImGuiKeyRoutingData Entries; - ImVector_ImGuiKeyRoutingData EntriesNext; -}; -typedef struct ImGuiKeyOwnerData ImGuiKeyOwnerData; -struct ImGuiKeyOwnerData { - ImGuiID OwnerCurr; - ImGuiID OwnerNext; - bool LockThisFrame; - bool LockUntilRelease; -}; -typedef enum { - ImGuiInputFlags_None = 0, - ImGuiInputFlags_Repeat = 1 << 0, - ImGuiInputFlags_RepeatRateDefault = 1 << 1, - ImGuiInputFlags_RepeatRateNavMove = 1 << 2, - ImGuiInputFlags_RepeatRateNavTweak = 1 << 3, - ImGuiInputFlags_RepeatUntilRelease = 1 << 4, - ImGuiInputFlags_RepeatUntilKeyModsChange = 1 << 5, - ImGuiInputFlags_RepeatUntilKeyModsChangeFromNone = 1 << 6, - ImGuiInputFlags_RepeatUntilOtherKeyPress = 1 << 7, - ImGuiInputFlags_CondHovered = 1 << 8, - ImGuiInputFlags_CondActive = 1 << 9, - ImGuiInputFlags_CondDefault_ = - ImGuiInputFlags_CondHovered | ImGuiInputFlags_CondActive, - ImGuiInputFlags_LockThisFrame = 1 << 10, - ImGuiInputFlags_LockUntilRelease = 1 << 11, - ImGuiInputFlags_RouteFocused = 1 << 12, - ImGuiInputFlags_RouteGlobalLow = 1 << 13, - ImGuiInputFlags_RouteGlobal = 1 << 14, - ImGuiInputFlags_RouteGlobalHigh = 1 << 15, - ImGuiInputFlags_RouteAlways = 1 << 16, - ImGuiInputFlags_RouteUnlessBgFocused = 1 << 17, - ImGuiInputFlags_RepeatRateMask_ = ImGuiInputFlags_RepeatRateDefault | - ImGuiInputFlags_RepeatRateNavMove | - ImGuiInputFlags_RepeatRateNavTweak, - ImGuiInputFlags_RepeatUntilMask_ = - ImGuiInputFlags_RepeatUntilRelease | - ImGuiInputFlags_RepeatUntilKeyModsChange | - ImGuiInputFlags_RepeatUntilKeyModsChangeFromNone | - ImGuiInputFlags_RepeatUntilOtherKeyPress, - ImGuiInputFlags_RepeatMask_ = ImGuiInputFlags_Repeat | - ImGuiInputFlags_RepeatRateMask_ | - ImGuiInputFlags_RepeatUntilMask_, - ImGuiInputFlags_CondMask_ = - ImGuiInputFlags_CondHovered | ImGuiInputFlags_CondActive, - ImGuiInputFlags_RouteMask_ = - ImGuiInputFlags_RouteFocused | ImGuiInputFlags_RouteGlobal | - ImGuiInputFlags_RouteGlobalLow | ImGuiInputFlags_RouteGlobalHigh, - ImGuiInputFlags_SupportedByIsKeyPressed = ImGuiInputFlags_RepeatMask_, - ImGuiInputFlags_SupportedByIsMouseClicked = ImGuiInputFlags_Repeat, - ImGuiInputFlags_SupportedByShortcut = - ImGuiInputFlags_RepeatMask_ | ImGuiInputFlags_RouteMask_ | - ImGuiInputFlags_RouteAlways | ImGuiInputFlags_RouteUnlessBgFocused, - ImGuiInputFlags_SupportedBySetKeyOwner = - ImGuiInputFlags_LockThisFrame | ImGuiInputFlags_LockUntilRelease, - ImGuiInputFlags_SupportedBySetItemKeyOwner = - ImGuiInputFlags_SupportedBySetKeyOwner | ImGuiInputFlags_CondMask_, -} ImGuiInputFlags_; -typedef struct ImGuiListClipperRange ImGuiListClipperRange; -struct ImGuiListClipperRange { - int Min; - int Max; - bool PosToIndexConvert; - ImS8 PosToIndexOffsetMin; - ImS8 PosToIndexOffsetMax; -}; -typedef struct ImGuiListClipperData ImGuiListClipperData; -typedef struct ImVector_ImGuiListClipperRange { - int Size; - int Capacity; - ImGuiListClipperRange* Data; -} ImVector_ImGuiListClipperRange; - -struct ImGuiListClipperData { - ImGuiListClipper* ListClipper; - float LossynessOffset; - int StepNo; - int ItemsFrozen; - ImVector_ImGuiListClipperRange Ranges; -}; -typedef enum { - ImGuiActivateFlags_None = 0, - ImGuiActivateFlags_PreferInput = 1 << 0, - ImGuiActivateFlags_PreferTweak = 1 << 1, - ImGuiActivateFlags_TryToPreserveState = 1 << 2, - ImGuiActivateFlags_FromTabbing = 1 << 3, - ImGuiActivateFlags_FromShortcut = 1 << 4, -} ImGuiActivateFlags_; -typedef enum { - ImGuiScrollFlags_None = 0, - ImGuiScrollFlags_KeepVisibleEdgeX = 1 << 0, - ImGuiScrollFlags_KeepVisibleEdgeY = 1 << 1, - ImGuiScrollFlags_KeepVisibleCenterX = 1 << 2, - ImGuiScrollFlags_KeepVisibleCenterY = 1 << 3, - ImGuiScrollFlags_AlwaysCenterX = 1 << 4, - ImGuiScrollFlags_AlwaysCenterY = 1 << 5, - ImGuiScrollFlags_NoScrollParent = 1 << 6, - ImGuiScrollFlags_MaskX_ = ImGuiScrollFlags_KeepVisibleEdgeX | - ImGuiScrollFlags_KeepVisibleCenterX | - ImGuiScrollFlags_AlwaysCenterX, - ImGuiScrollFlags_MaskY_ = ImGuiScrollFlags_KeepVisibleEdgeY | - ImGuiScrollFlags_KeepVisibleCenterY | - ImGuiScrollFlags_AlwaysCenterY, -} ImGuiScrollFlags_; -typedef enum { - ImGuiNavHighlightFlags_None = 0, - ImGuiNavHighlightFlags_Compact = 1 << 1, - ImGuiNavHighlightFlags_AlwaysDraw = 1 << 2, - ImGuiNavHighlightFlags_NoRounding = 1 << 3, -} ImGuiNavHighlightFlags_; -typedef enum { - ImGuiNavMoveFlags_None = 0, - ImGuiNavMoveFlags_LoopX = 1 << 0, - ImGuiNavMoveFlags_LoopY = 1 << 1, - ImGuiNavMoveFlags_WrapX = 1 << 2, - ImGuiNavMoveFlags_WrapY = 1 << 3, - ImGuiNavMoveFlags_WrapMask_ = - ImGuiNavMoveFlags_LoopX | ImGuiNavMoveFlags_LoopY | - ImGuiNavMoveFlags_WrapX | ImGuiNavMoveFlags_WrapY, - ImGuiNavMoveFlags_AllowCurrentNavId = 1 << 4, - ImGuiNavMoveFlags_AlsoScoreVisibleSet = 1 << 5, - ImGuiNavMoveFlags_ScrollToEdgeY = 1 << 6, - ImGuiNavMoveFlags_Forwarded = 1 << 7, - ImGuiNavMoveFlags_DebugNoResult = 1 << 8, - ImGuiNavMoveFlags_FocusApi = 1 << 9, - ImGuiNavMoveFlags_IsTabbing = 1 << 10, - ImGuiNavMoveFlags_IsPageMove = 1 << 11, - ImGuiNavMoveFlags_Activate = 1 << 12, - ImGuiNavMoveFlags_NoSelect = 1 << 13, - ImGuiNavMoveFlags_NoSetNavHighlight = 1 << 14, - ImGuiNavMoveFlags_NoClearActiveId = 1 << 15, -} ImGuiNavMoveFlags_; -typedef enum { - ImGuiNavLayer_Main = 0, - ImGuiNavLayer_Menu = 1, - ImGuiNavLayer_COUNT -} ImGuiNavLayer; -struct ImGuiNavItemData { - ImGuiWindow* Window; - ImGuiID ID; - ImGuiID FocusScopeId; - ImRect RectRel; - ImGuiItemFlags InFlags; - float DistBox; - float DistCenter; - float DistAxial; - ImGuiSelectionUserData SelectionUserData; -}; -typedef struct ImGuiFocusScopeData ImGuiFocusScopeData; -struct ImGuiFocusScopeData { - ImGuiID ID; - ImGuiID WindowID; -}; -typedef enum { - ImGuiTypingSelectFlags_None = 0, - ImGuiTypingSelectFlags_AllowBackspace = 1 << 0, - ImGuiTypingSelectFlags_AllowSingleCharMode = 1 << 1, -} ImGuiTypingSelectFlags_; -struct ImGuiTypingSelectRequest { - ImGuiTypingSelectFlags Flags; - int SearchBufferLen; - const char* SearchBuffer; - bool SelectRequest; - bool SingleCharMode; - ImS8 SingleCharSize; -}; -struct ImGuiTypingSelectState { - ImGuiTypingSelectRequest Request; - char SearchBuffer[64]; - ImGuiID FocusScope; - int LastRequestFrame; - float LastRequestTime; - bool SingleCharModeLock; -}; -typedef enum { - ImGuiOldColumnFlags_None = 0, - ImGuiOldColumnFlags_NoBorder = 1 << 0, - ImGuiOldColumnFlags_NoResize = 1 << 1, - ImGuiOldColumnFlags_NoPreserveWidths = 1 << 2, - ImGuiOldColumnFlags_NoForceWithinWindow = 1 << 3, - ImGuiOldColumnFlags_GrowParentContentsSize = 1 << 4, -} ImGuiOldColumnFlags_; -struct ImGuiOldColumnData { - float OffsetNorm; - float OffsetNormBeforeResize; - ImGuiOldColumnFlags Flags; - ImRect ClipRect; -}; -typedef struct ImVector_ImGuiOldColumnData { - int Size; - int Capacity; - ImGuiOldColumnData* Data; -} ImVector_ImGuiOldColumnData; - -struct ImGuiOldColumns { - ImGuiID ID; - ImGuiOldColumnFlags Flags; - bool IsFirstFrame; - bool IsBeingResized; - int Current; - int Count; - float OffMinX, OffMaxX; - float LineMinY, LineMaxY; - float HostCursorPosY; - float HostCursorMaxPosX; - ImRect HostInitialClipRect; - ImRect HostBackupClipRect; - ImRect HostBackupParentWorkRect; - ImVector_ImGuiOldColumnData Columns; - ImDrawListSplitter Splitter; -}; -typedef enum { - ImGuiDockNodeFlags_DockSpace = 1 << 10, - ImGuiDockNodeFlags_CentralNode = 1 << 11, - ImGuiDockNodeFlags_NoTabBar = 1 << 12, - ImGuiDockNodeFlags_HiddenTabBar = 1 << 13, - ImGuiDockNodeFlags_NoWindowMenuButton = 1 << 14, - ImGuiDockNodeFlags_NoCloseButton = 1 << 15, - ImGuiDockNodeFlags_NoResizeX = 1 << 16, - ImGuiDockNodeFlags_NoResizeY = 1 << 17, - ImGuiDockNodeFlags_DockedWindowsInFocusRoute = 1 << 18, - ImGuiDockNodeFlags_NoDockingSplitOther = 1 << 19, - ImGuiDockNodeFlags_NoDockingOverMe = 1 << 20, - ImGuiDockNodeFlags_NoDockingOverOther = 1 << 21, - ImGuiDockNodeFlags_NoDockingOverEmpty = 1 << 22, - ImGuiDockNodeFlags_NoDocking = ImGuiDockNodeFlags_NoDockingOverMe | - ImGuiDockNodeFlags_NoDockingOverOther | - ImGuiDockNodeFlags_NoDockingOverEmpty | - ImGuiDockNodeFlags_NoDockingSplit | - ImGuiDockNodeFlags_NoDockingSplitOther, - ImGuiDockNodeFlags_SharedFlagsInheritMask_ = ~0, - ImGuiDockNodeFlags_NoResizeFlagsMask_ = ImGuiDockNodeFlags_NoResize | - ImGuiDockNodeFlags_NoResizeX | - ImGuiDockNodeFlags_NoResizeY, - ImGuiDockNodeFlags_LocalFlagsTransferMask_ = - ImGuiDockNodeFlags_NoDockingSplit | - ImGuiDockNodeFlags_NoResizeFlagsMask_ | - ImGuiDockNodeFlags_AutoHideTabBar | ImGuiDockNodeFlags_CentralNode | - ImGuiDockNodeFlags_NoTabBar | ImGuiDockNodeFlags_HiddenTabBar | - ImGuiDockNodeFlags_NoWindowMenuButton | ImGuiDockNodeFlags_NoCloseButton, - ImGuiDockNodeFlags_SavedFlagsMask_ = - ImGuiDockNodeFlags_NoResizeFlagsMask_ | ImGuiDockNodeFlags_DockSpace | - ImGuiDockNodeFlags_CentralNode | ImGuiDockNodeFlags_NoTabBar | - ImGuiDockNodeFlags_HiddenTabBar | ImGuiDockNodeFlags_NoWindowMenuButton | - ImGuiDockNodeFlags_NoCloseButton, -} ImGuiDockNodeFlagsPrivate_; -typedef enum { - ImGuiDataAuthority_Auto, - ImGuiDataAuthority_DockNode, - ImGuiDataAuthority_Window, -} ImGuiDataAuthority_; -typedef enum { - ImGuiDockNodeState_Unknown, - ImGuiDockNodeState_HostWindowHiddenBecauseSingleWindow, - ImGuiDockNodeState_HostWindowHiddenBecauseWindowsAreResizing, - ImGuiDockNodeState_HostWindowVisible, -} ImGuiDockNodeState; -typedef struct ImVector_ImGuiWindowPtr { - int Size; - int Capacity; - ImGuiWindow** Data; -} ImVector_ImGuiWindowPtr; - -struct ImGuiDockNode { - ImGuiID ID; - ImGuiDockNodeFlags SharedFlags; - ImGuiDockNodeFlags LocalFlags; - ImGuiDockNodeFlags LocalFlagsInWindows; - ImGuiDockNodeFlags MergedFlags; - ImGuiDockNodeState State; - ImGuiDockNode* ParentNode; - ImGuiDockNode* ChildNodes[2]; - ImVector_ImGuiWindowPtr Windows; - ImGuiTabBar* TabBar; - ImVec2 Pos; - ImVec2 Size; - ImVec2 SizeRef; - ImGuiAxis SplitAxis; - ImGuiWindowClass WindowClass; - ImU32 LastBgColor; - ImGuiWindow* HostWindow; - ImGuiWindow* VisibleWindow; - ImGuiDockNode* CentralNode; - ImGuiDockNode* OnlyNodeWithWindows; - int CountNodeWithWindows; - int LastFrameAlive; - int LastFrameActive; - int LastFrameFocused; - ImGuiID LastFocusedNodeId; - ImGuiID SelectedTabId; - ImGuiID WantCloseTabId; - ImGuiID RefViewportId; - ImGuiDataAuthority AuthorityForPos : 3; - ImGuiDataAuthority AuthorityForSize : 3; - ImGuiDataAuthority AuthorityForViewport : 3; - bool IsVisible : 1; - bool IsFocused : 1; - bool IsBgDrawnThisFrame : 1; - bool HasCloseButton : 1; - bool HasWindowMenuButton : 1; - bool HasCentralNodeChild : 1; - bool WantCloseAll : 1; - bool WantLockSizeOnce : 1; - bool WantMouseMove : 1; - bool WantHiddenTabBarUpdate : 1; - bool WantHiddenTabBarToggle : 1; -}; -typedef enum { - ImGuiWindowDockStyleCol_Text, - ImGuiWindowDockStyleCol_Tab, - ImGuiWindowDockStyleCol_TabHovered, - ImGuiWindowDockStyleCol_TabActive, - ImGuiWindowDockStyleCol_TabUnfocused, - ImGuiWindowDockStyleCol_TabUnfocusedActive, - ImGuiWindowDockStyleCol_COUNT -} ImGuiWindowDockStyleCol; -struct ImGuiWindowDockStyle { - ImU32 Colors[ImGuiWindowDockStyleCol_COUNT]; -}; -typedef struct ImVector_ImGuiDockRequest { - int Size; - int Capacity; - ImGuiDockRequest* Data; -} ImVector_ImGuiDockRequest; - -typedef struct ImVector_ImGuiDockNodeSettings { - int Size; - int Capacity; - ImGuiDockNodeSettings* Data; -} ImVector_ImGuiDockNodeSettings; - -struct ImGuiDockContext { - ImGuiStorage Nodes; - ImVector_ImGuiDockRequest Requests; - ImVector_ImGuiDockNodeSettings NodesSettings; - bool WantFullRebuild; -}; -typedef struct ImGuiViewportP ImGuiViewportP; -struct ImGuiViewportP { - ImGuiViewport _ImGuiViewport; - ImGuiWindow* Window; - int Idx; - int LastFrameActive; - int LastFocusedStampCount; - ImGuiID LastNameHash; - ImVec2 LastPos; - float Alpha; - float LastAlpha; - bool LastFocusedHadNavWindow; - short PlatformMonitor; - int BgFgDrawListsLastFrame[2]; - ImDrawList* BgFgDrawLists[2]; - ImDrawData DrawDataP; - ImDrawDataBuilder DrawDataBuilder; - ImVec2 LastPlatformPos; - ImVec2 LastPlatformSize; - ImVec2 LastRendererSize; - ImVec2 WorkOffsetMin; - ImVec2 WorkOffsetMax; - ImVec2 BuildWorkOffsetMin; - ImVec2 BuildWorkOffsetMax; -}; -struct ImGuiWindowSettings { - ImGuiID ID; - ImVec2ih Pos; - ImVec2ih Size; - ImVec2ih ViewportPos; - ImGuiID ViewportId; - ImGuiID DockId; - ImGuiID ClassId; - short DockOrder; - bool Collapsed; - bool IsChild; - bool WantApply; - bool WantDelete; -}; -struct ImGuiSettingsHandler { - const char* TypeName; - ImGuiID TypeHash; - void (*ClearAllFn)(ImGuiContext* ctx, ImGuiSettingsHandler* handler); - void (*ReadInitFn)(ImGuiContext* ctx, ImGuiSettingsHandler* handler); - void* (*ReadOpenFn)(ImGuiContext* ctx, - ImGuiSettingsHandler* handler, - const char* name); - void (*ReadLineFn)(ImGuiContext* ctx, - ImGuiSettingsHandler* handler, - void* entry, - const char* line); - void (*ApplyAllFn)(ImGuiContext* ctx, ImGuiSettingsHandler* handler); - void (*WriteAllFn)(ImGuiContext* ctx, - ImGuiSettingsHandler* handler, - ImGuiTextBuffer* out_buf); - void* UserData; -}; -typedef enum { - ImGuiLocKey_VersionStr = 0, - ImGuiLocKey_TableSizeOne = 1, - ImGuiLocKey_TableSizeAllFit = 2, - ImGuiLocKey_TableSizeAllDefault = 3, - ImGuiLocKey_TableResetOrder = 4, - ImGuiLocKey_WindowingMainMenuBar = 5, - ImGuiLocKey_WindowingPopup = 6, - ImGuiLocKey_WindowingUntitled = 7, - ImGuiLocKey_DockingHideTabBar = 8, - ImGuiLocKey_DockingHoldShiftToDock = 9, - ImGuiLocKey_DockingDragToUndockOrMoveNode = 10, - ImGuiLocKey_COUNT = 11, -} ImGuiLocKey; -struct ImGuiLocEntry { - ImGuiLocKey Key; - const char* Text; -}; -typedef enum { - ImGuiDebugLogFlags_None = 0, - ImGuiDebugLogFlags_EventActiveId = 1 << 0, - ImGuiDebugLogFlags_EventFocus = 1 << 1, - ImGuiDebugLogFlags_EventPopup = 1 << 2, - ImGuiDebugLogFlags_EventNav = 1 << 3, - ImGuiDebugLogFlags_EventClipper = 1 << 4, - ImGuiDebugLogFlags_EventSelection = 1 << 5, - ImGuiDebugLogFlags_EventIO = 1 << 6, - ImGuiDebugLogFlags_EventInputRouting = 1 << 7, - ImGuiDebugLogFlags_EventDocking = 1 << 8, - ImGuiDebugLogFlags_EventViewport = 1 << 9, - ImGuiDebugLogFlags_EventMask_ = - ImGuiDebugLogFlags_EventActiveId | ImGuiDebugLogFlags_EventFocus | - ImGuiDebugLogFlags_EventPopup | ImGuiDebugLogFlags_EventNav | - ImGuiDebugLogFlags_EventClipper | ImGuiDebugLogFlags_EventSelection | - ImGuiDebugLogFlags_EventIO | ImGuiDebugLogFlags_EventInputRouting | - ImGuiDebugLogFlags_EventDocking | ImGuiDebugLogFlags_EventViewport, - ImGuiDebugLogFlags_OutputToTTY = 1 << 20, - ImGuiDebugLogFlags_OutputToTestEngine = 1 << 21, -} ImGuiDebugLogFlags_; -typedef struct ImGuiDebugAllocEntry ImGuiDebugAllocEntry; -struct ImGuiDebugAllocEntry { - int FrameCount; - ImS16 AllocCount; - ImS16 FreeCount; -}; -typedef struct ImGuiDebugAllocInfo ImGuiDebugAllocInfo; -struct ImGuiDebugAllocInfo { - int TotalAllocCount; - int TotalFreeCount; - ImS16 LastEntriesIdx; - ImGuiDebugAllocEntry LastEntriesBuf[6]; -}; -struct ImGuiMetricsConfig { - bool ShowDebugLog; - bool ShowIDStackTool; - bool ShowWindowsRects; - bool ShowWindowsBeginOrder; - bool ShowTablesRects; - bool ShowDrawCmdMesh; - bool ShowDrawCmdBoundingBoxes; - bool ShowTextEncodingViewer; - bool ShowAtlasTintedWithTextColor; - bool ShowDockingNodes; - int ShowWindowsRectsType; - int ShowTablesRectsType; - int HighlightMonitorIdx; - ImGuiID HighlightViewportID; -}; -typedef struct ImGuiStackLevelInfo ImGuiStackLevelInfo; -struct ImGuiStackLevelInfo { - ImGuiID ID; - ImS8 QueryFrameCount; - bool QuerySuccess; - ImGuiDataType DataType : 8; - char Desc[57]; -}; -typedef struct ImGuiIDStackTool ImGuiIDStackTool; -typedef struct ImVector_ImGuiStackLevelInfo { - int Size; - int Capacity; - ImGuiStackLevelInfo* Data; -} ImVector_ImGuiStackLevelInfo; - -struct ImGuiIDStackTool { - int LastActiveFrame; - int StackLevel; - ImGuiID QueryId; - ImVector_ImGuiStackLevelInfo Results; - bool CopyToClipboardOnCtrlC; - float CopyToClipboardLastTime; -}; -typedef void (*ImGuiContextHookCallback)(ImGuiContext* ctx, - ImGuiContextHook* hook); -typedef enum { - ImGuiContextHookType_NewFramePre, - ImGuiContextHookType_NewFramePost, - ImGuiContextHookType_EndFramePre, - ImGuiContextHookType_EndFramePost, - ImGuiContextHookType_RenderPre, - ImGuiContextHookType_RenderPost, - ImGuiContextHookType_Shutdown, - ImGuiContextHookType_PendingRemoval_ -} ImGuiContextHookType; -struct ImGuiContextHook { - ImGuiID HookId; - ImGuiContextHookType Type; - ImGuiID Owner; - ImGuiContextHookCallback Callback; - void* UserData; -}; -typedef struct ImVector_ImGuiInputEvent { - int Size; - int Capacity; - ImGuiInputEvent* Data; -} ImVector_ImGuiInputEvent; - -typedef struct ImVector_ImGuiWindowStackData { - int Size; - int Capacity; - ImGuiWindowStackData* Data; -} ImVector_ImGuiWindowStackData; - -typedef struct ImVector_ImGuiColorMod { - int Size; - int Capacity; - ImGuiColorMod* Data; -} ImVector_ImGuiColorMod; - -typedef struct ImVector_ImGuiStyleMod { - int Size; - int Capacity; - ImGuiStyleMod* Data; -} ImVector_ImGuiStyleMod; - -typedef struct ImVector_ImGuiFocusScopeData { - int Size; - int Capacity; - ImGuiFocusScopeData* Data; -} ImVector_ImGuiFocusScopeData; - -typedef struct ImVector_ImGuiItemFlags { - int Size; - int Capacity; - ImGuiItemFlags* Data; -} ImVector_ImGuiItemFlags; - -typedef struct ImVector_ImGuiGroupData { - int Size; - int Capacity; - ImGuiGroupData* Data; -} ImVector_ImGuiGroupData; - -typedef struct ImVector_ImGuiPopupData { - int Size; - int Capacity; - ImGuiPopupData* Data; -} ImVector_ImGuiPopupData; - -typedef struct ImVector_ImGuiNavTreeNodeData { - int Size; - int Capacity; - ImGuiNavTreeNodeData* Data; -} ImVector_ImGuiNavTreeNodeData; - -typedef struct ImVector_ImGuiViewportPPtr { - int Size; - int Capacity; - ImGuiViewportP** Data; -} ImVector_ImGuiViewportPPtr; - -typedef struct ImVector_unsigned_char { - int Size; - int Capacity; - unsigned char* Data; -} ImVector_unsigned_char; - -typedef struct ImVector_ImGuiListClipperData { - int Size; - int Capacity; - ImGuiListClipperData* Data; -} ImVector_ImGuiListClipperData; - -typedef struct ImVector_ImGuiTableTempData { - int Size; - int Capacity; - ImGuiTableTempData* Data; -} ImVector_ImGuiTableTempData; - -typedef struct ImVector_ImGuiTable { - int Size; - int Capacity; - ImGuiTable* Data; -} ImVector_ImGuiTable; - -typedef struct ImPool_ImGuiTable { - ImVector_ImGuiTable Buf; - ImGuiStorage Map; - ImPoolIdx FreeIdx; - ImPoolIdx AliveCount; -} ImPool_ImGuiTable; - -typedef struct ImVector_ImGuiTabBar { - int Size; - int Capacity; - ImGuiTabBar* Data; -} ImVector_ImGuiTabBar; - -typedef struct ImPool_ImGuiTabBar { - ImVector_ImGuiTabBar Buf; - ImGuiStorage Map; - ImPoolIdx FreeIdx; - ImPoolIdx AliveCount; -} ImPool_ImGuiTabBar; - -typedef struct ImVector_ImGuiPtrOrIndex { - int Size; - int Capacity; - ImGuiPtrOrIndex* Data; -} ImVector_ImGuiPtrOrIndex; - -typedef struct ImVector_ImGuiShrinkWidthItem { - int Size; - int Capacity; - ImGuiShrinkWidthItem* Data; -} ImVector_ImGuiShrinkWidthItem; - -typedef struct ImVector_ImGuiID { - int Size; - int Capacity; - ImGuiID* Data; -} ImVector_ImGuiID; - -typedef struct ImVector_ImGuiSettingsHandler { - int Size; - int Capacity; - ImGuiSettingsHandler* Data; -} ImVector_ImGuiSettingsHandler; - -typedef struct ImChunkStream_ImGuiWindowSettings { - ImVector_char Buf; -} ImChunkStream_ImGuiWindowSettings; - -typedef struct ImChunkStream_ImGuiTableSettings { - ImVector_char Buf; -} ImChunkStream_ImGuiTableSettings; - -typedef struct ImVector_ImGuiContextHook { - int Size; - int Capacity; - ImGuiContextHook* Data; -} ImVector_ImGuiContextHook; - -struct ImGuiContext { - bool Initialized; - bool FontAtlasOwnedByContext; - ImGuiIO IO; - ImGuiPlatformIO PlatformIO; - ImGuiStyle Style; - ImGuiConfigFlags ConfigFlagsCurrFrame; - ImGuiConfigFlags ConfigFlagsLastFrame; - ImFont* Font; - float FontSize; - float FontBaseSize; - ImDrawListSharedData DrawListSharedData; - double Time; - int FrameCount; - int FrameCountEnded; - int FrameCountPlatformEnded; - int FrameCountRendered; - bool WithinFrameScope; - bool WithinFrameScopeWithImplicitWindow; - bool WithinEndChild; - bool GcCompactAll; - bool TestEngineHookItems; - void* TestEngine; - ImVector_ImGuiInputEvent InputEventsQueue; - ImVector_ImGuiInputEvent InputEventsTrail; - ImGuiMouseSource InputEventsNextMouseSource; - ImU32 InputEventsNextEventId; - ImVector_ImGuiWindowPtr Windows; - ImVector_ImGuiWindowPtr WindowsFocusOrder; - ImVector_ImGuiWindowPtr WindowsTempSortBuffer; - ImVector_ImGuiWindowStackData CurrentWindowStack; - ImGuiStorage WindowsById; - int WindowsActiveCount; - ImVec2 WindowsHoverPadding; - ImGuiID DebugBreakInWindow; - ImGuiWindow* CurrentWindow; - ImGuiWindow* HoveredWindow; - ImGuiWindow* HoveredWindowUnderMovingWindow; - ImGuiWindow* MovingWindow; - ImGuiWindow* WheelingWindow; - ImVec2 WheelingWindowRefMousePos; - int WheelingWindowStartFrame; - int WheelingWindowScrolledFrame; - float WheelingWindowReleaseTimer; - ImVec2 WheelingWindowWheelRemainder; - ImVec2 WheelingAxisAvg; - ImGuiID DebugHookIdInfo; - ImGuiID HoveredId; - ImGuiID HoveredIdPreviousFrame; - bool HoveredIdAllowOverlap; - bool HoveredIdDisabled; - float HoveredIdTimer; - float HoveredIdNotActiveTimer; - ImGuiID ActiveId; - ImGuiID ActiveIdIsAlive; - float ActiveIdTimer; - bool ActiveIdIsJustActivated; - bool ActiveIdAllowOverlap; - bool ActiveIdNoClearOnFocusLoss; - bool ActiveIdHasBeenPressedBefore; - bool ActiveIdHasBeenEditedBefore; - bool ActiveIdHasBeenEditedThisFrame; - bool ActiveIdFromShortcut; - int ActiveIdMouseButton : 8; - ImVec2 ActiveIdClickOffset; - ImGuiWindow* ActiveIdWindow; - ImGuiInputSource ActiveIdSource; - ImGuiID ActiveIdPreviousFrame; - bool ActiveIdPreviousFrameIsAlive; - bool ActiveIdPreviousFrameHasBeenEditedBefore; - ImGuiWindow* ActiveIdPreviousFrameWindow; - ImGuiID LastActiveId; - float LastActiveIdTimer; - double LastKeyModsChangeTime; - double LastKeyModsChangeFromNoneTime; - double LastKeyboardKeyPressTime; - ImBitArrayForNamedKeys KeysMayBeCharInput; - ImGuiKeyOwnerData KeysOwnerData[ImGuiKey_NamedKey_COUNT]; - ImGuiKeyRoutingTable KeysRoutingTable; - ImU32 ActiveIdUsingNavDirMask; - bool ActiveIdUsingAllKeyboardKeys; - ImGuiKeyChord DebugBreakInShortcutRouting; - ImGuiID CurrentFocusScopeId; - ImGuiItemFlags CurrentItemFlags; - ImGuiID DebugLocateId; - ImGuiNextItemData NextItemData; - ImGuiLastItemData LastItemData; - ImGuiNextWindowData NextWindowData; - bool DebugShowGroupRects; - ImGuiCol DebugFlashStyleColorIdx; - ImVector_ImGuiColorMod ColorStack; - ImVector_ImGuiStyleMod StyleVarStack; - ImVector_ImFontPtr FontStack; - ImVector_ImGuiFocusScopeData FocusScopeStack; - ImVector_ImGuiItemFlags ItemFlagsStack; - ImVector_ImGuiGroupData GroupStack; - ImVector_ImGuiPopupData OpenPopupStack; - ImVector_ImGuiPopupData BeginPopupStack; - ImVector_ImGuiNavTreeNodeData NavTreeNodeStack; - ImVector_ImGuiViewportPPtr Viewports; - float CurrentDpiScale; - ImGuiViewportP* CurrentViewport; - ImGuiViewportP* MouseViewport; - ImGuiViewportP* MouseLastHoveredViewport; - ImGuiID PlatformLastFocusedViewportId; - ImGuiPlatformMonitor FallbackMonitor; - ImRect PlatformMonitorsFullWorkRect; - int ViewportCreatedCount; - int PlatformWindowsCreatedCount; - int ViewportFocusedStampCount; - ImGuiWindow* NavWindow; - ImGuiID NavId; - ImGuiID NavFocusScopeId; - ImVector_ImGuiFocusScopeData NavFocusRoute; - ImGuiID NavActivateId; - ImGuiID NavActivateDownId; - ImGuiID NavActivatePressedId; - ImGuiActivateFlags NavActivateFlags; - ImGuiID NavHighlightActivatedId; - float NavHighlightActivatedTimer; - ImGuiID NavJustMovedToId; - ImGuiID NavJustMovedToFocusScopeId; - ImGuiKeyChord NavJustMovedToKeyMods; - ImGuiID NavNextActivateId; - ImGuiActivateFlags NavNextActivateFlags; - ImGuiInputSource NavInputSource; - ImGuiNavLayer NavLayer; - ImGuiSelectionUserData NavLastValidSelectionUserData; - bool NavIdIsAlive; - bool NavMousePosDirty; - bool NavDisableHighlight; - bool NavDisableMouseHover; - bool NavAnyRequest; - bool NavInitRequest; - bool NavInitRequestFromMove; - ImGuiNavItemData NavInitResult; - bool NavMoveSubmitted; - bool NavMoveScoringItems; - bool NavMoveForwardToNextFrame; - ImGuiNavMoveFlags NavMoveFlags; - ImGuiScrollFlags NavMoveScrollFlags; - ImGuiKeyChord NavMoveKeyMods; - ImGuiDir NavMoveDir; - ImGuiDir NavMoveDirForDebug; - ImGuiDir NavMoveClipDir; - ImRect NavScoringRect; - ImRect NavScoringNoClipRect; - int NavScoringDebugCount; - int NavTabbingDir; - int NavTabbingCounter; - ImGuiNavItemData NavMoveResultLocal; - ImGuiNavItemData NavMoveResultLocalVisible; - ImGuiNavItemData NavMoveResultOther; - ImGuiNavItemData NavTabbingResultFirst; - ImGuiKeyChord ConfigNavWindowingKeyNext; - ImGuiKeyChord ConfigNavWindowingKeyPrev; - ImGuiWindow* NavWindowingTarget; - ImGuiWindow* NavWindowingTargetAnim; - ImGuiWindow* NavWindowingListWindow; - float NavWindowingTimer; - float NavWindowingHighlightAlpha; - bool NavWindowingToggleLayer; - ImGuiKey NavWindowingToggleKey; - ImVec2 NavWindowingAccumDeltaPos; - ImVec2 NavWindowingAccumDeltaSize; - float DimBgRatio; - bool DragDropActive; - bool DragDropWithinSource; - bool DragDropWithinTarget; - ImGuiDragDropFlags DragDropSourceFlags; - int DragDropSourceFrameCount; - int DragDropMouseButton; - ImGuiPayload DragDropPayload; - ImRect DragDropTargetRect; - ImRect DragDropTargetClipRect; - ImGuiID DragDropTargetId; - ImGuiDragDropFlags DragDropAcceptFlags; - float DragDropAcceptIdCurrRectSurface; - ImGuiID DragDropAcceptIdCurr; - ImGuiID DragDropAcceptIdPrev; - int DragDropAcceptFrameCount; - ImGuiID DragDropHoldJustPressedId; - ImVector_unsigned_char DragDropPayloadBufHeap; - unsigned char DragDropPayloadBufLocal[16]; - int ClipperTempDataStacked; - ImVector_ImGuiListClipperData ClipperTempData; - ImGuiTable* CurrentTable; - ImGuiID DebugBreakInTable; - int TablesTempDataStacked; - ImVector_ImGuiTableTempData TablesTempData; - ImPool_ImGuiTable Tables; - ImVector_float TablesLastTimeActive; - ImVector_ImDrawChannel DrawChannelsTempMergeBuffer; - ImGuiTabBar* CurrentTabBar; - ImPool_ImGuiTabBar TabBars; - ImVector_ImGuiPtrOrIndex CurrentTabBarStack; - ImVector_ImGuiShrinkWidthItem ShrinkWidthBuffer; - ImGuiID HoverItemDelayId; - ImGuiID HoverItemDelayIdPreviousFrame; - float HoverItemDelayTimer; - float HoverItemDelayClearTimer; - ImGuiID HoverItemUnlockedStationaryId; - ImGuiID HoverWindowUnlockedStationaryId; - ImGuiMouseCursor MouseCursor; - float MouseStationaryTimer; - ImVec2 MouseLastValidPos; - ImGuiInputTextState InputTextState; - ImGuiInputTextDeactivatedState InputTextDeactivatedState; - ImFont InputTextPasswordFont; - ImGuiID TempInputId; - int BeginMenuDepth; - int BeginComboDepth; - ImGuiColorEditFlags ColorEditOptions; - ImGuiID ColorEditCurrentID; - ImGuiID ColorEditSavedID; - float ColorEditSavedHue; - float ColorEditSavedSat; - ImU32 ColorEditSavedColor; - ImVec4 ColorPickerRef; - ImGuiComboPreviewData ComboPreviewData; - ImRect WindowResizeBorderExpectedRect; - bool WindowResizeRelativeMode; - float SliderGrabClickOffset; - float SliderCurrentAccum; - bool SliderCurrentAccumDirty; - bool DragCurrentAccumDirty; - float DragCurrentAccum; - float DragSpeedDefaultRatio; - float ScrollbarClickDeltaToGrabCenter; - float DisabledAlphaBackup; - short DisabledStackSize; - short LockMarkEdited; - short TooltipOverrideCount; - ImVector_char ClipboardHandlerData; - ImVector_ImGuiID MenusIdSubmittedThisFrame; - ImGuiTypingSelectState TypingSelectState; - ImGuiPlatformImeData PlatformImeData; - ImGuiPlatformImeData PlatformImeDataPrev; - ImGuiID PlatformImeViewport; - ImGuiDockContext DockContext; - void (*DockNodeWindowMenuHandler)(ImGuiContext* ctx, - ImGuiDockNode* node, - ImGuiTabBar* tab_bar); - bool SettingsLoaded; - float SettingsDirtyTimer; - ImGuiTextBuffer SettingsIniData; - ImVector_ImGuiSettingsHandler SettingsHandlers; - ImChunkStream_ImGuiWindowSettings SettingsWindows; - ImChunkStream_ImGuiTableSettings SettingsTables; - ImVector_ImGuiContextHook Hooks; - ImGuiID HookIdNext; - const char* LocalizationTable[ImGuiLocKey_COUNT]; - bool LogEnabled; - ImGuiLogType LogType; - ImFileHandle LogFile; - ImGuiTextBuffer LogBuffer; - const char* LogNextPrefix; - const char* LogNextSuffix; - float LogLinePosY; - bool LogLineFirstItem; - int LogDepthRef; - int LogDepthToExpand; - int LogDepthToExpandDefault; - ImGuiDebugLogFlags DebugLogFlags; - ImGuiTextBuffer DebugLogBuf; - ImGuiTextIndex DebugLogIndex; - ImGuiDebugLogFlags DebugLogAutoDisableFlags; - ImU8 DebugLogAutoDisableFrames; - ImU8 DebugLocateFrames; - bool DebugBreakInLocateId; - ImGuiKeyChord DebugBreakKeyChord; - ImS8 DebugBeginReturnValueCullDepth; - bool DebugItemPickerActive; - ImU8 DebugItemPickerMouseButton; - ImGuiID DebugItemPickerBreakId; - float DebugFlashStyleColorTime; - ImVec4 DebugFlashStyleColorBackup; - ImGuiMetricsConfig DebugMetricsConfig; - ImGuiIDStackTool DebugIDStackTool; - ImGuiDebugAllocInfo DebugAllocInfo; - ImGuiDockNode* DebugHoveredDockNode; - float FramerateSecPerFrame[60]; - int FramerateSecPerFrameIdx; - int FramerateSecPerFrameCount; - float FramerateSecPerFrameAccum; - int WantCaptureMouseNextFrame; - int WantCaptureKeyboardNextFrame; - int WantTextInputNextFrame; - ImVector_char TempBuffer; - char TempKeychordName[64]; -}; -struct ImGuiWindowTempData { - ImVec2 CursorPos; - ImVec2 CursorPosPrevLine; - ImVec2 CursorStartPos; - ImVec2 CursorMaxPos; - ImVec2 IdealMaxPos; - ImVec2 CurrLineSize; - ImVec2 PrevLineSize; - float CurrLineTextBaseOffset; - float PrevLineTextBaseOffset; - bool IsSameLine; - bool IsSetPos; - ImVec1 Indent; - ImVec1 ColumnsOffset; - ImVec1 GroupOffset; - ImVec2 CursorStartPosLossyness; - ImGuiNavLayer NavLayerCurrent; - short NavLayersActiveMask; - short NavLayersActiveMaskNext; - bool NavIsScrollPushableX; - bool NavHideHighlightOneFrame; - bool NavWindowHasScrollY; - bool MenuBarAppending; - ImVec2 MenuBarOffset; - ImGuiMenuColumns MenuColumns; - int TreeDepth; - ImU32 TreeJumpToParentOnPopMask; - ImVector_ImGuiWindowPtr ChildWindows; - ImGuiStorage* StateStorage; - ImGuiOldColumns* CurrentColumns; - int CurrentTableIdx; - ImGuiLayoutType LayoutType; - ImGuiLayoutType ParentLayoutType; - ImU32 ModalDimBgColor; - float ItemWidth; - float TextWrapPos; - ImVector_float ItemWidthStack; - ImVector_float TextWrapPosStack; -}; -typedef struct ImVector_ImGuiOldColumns { - int Size; - int Capacity; - ImGuiOldColumns* Data; -} ImVector_ImGuiOldColumns; - -struct ImGuiWindow { - ImGuiContext* Ctx; - char* Name; - ImGuiID ID; - ImGuiWindowFlags Flags, FlagsPreviousFrame; - ImGuiChildFlags ChildFlags; - ImGuiWindowClass WindowClass; - ImGuiViewportP* Viewport; - ImGuiID ViewportId; - ImVec2 ViewportPos; - int ViewportAllowPlatformMonitorExtend; - ImVec2 Pos; - ImVec2 Size; - ImVec2 SizeFull; - ImVec2 ContentSize; - ImVec2 ContentSizeIdeal; - ImVec2 ContentSizeExplicit; - ImVec2 WindowPadding; - float WindowRounding; - float WindowBorderSize; - float DecoOuterSizeX1, DecoOuterSizeY1; - float DecoOuterSizeX2, DecoOuterSizeY2; - float DecoInnerSizeX1, DecoInnerSizeY1; - int NameBufLen; - ImGuiID MoveId; - ImGuiID TabId; - ImGuiID ChildId; - ImVec2 Scroll; - ImVec2 ScrollMax; - ImVec2 ScrollTarget; - ImVec2 ScrollTargetCenterRatio; - ImVec2 ScrollTargetEdgeSnapDist; - ImVec2 ScrollbarSizes; - bool ScrollbarX, ScrollbarY; - bool ViewportOwned; - bool Active; - bool WasActive; - bool WriteAccessed; - bool Collapsed; - bool WantCollapseToggle; - bool SkipItems; - bool SkipRefresh; - bool Appearing; - bool Hidden; - bool IsFallbackWindow; - bool IsExplicitChild; - bool HasCloseButton; - signed char ResizeBorderHovered; - signed char ResizeBorderHeld; - short BeginCount; - short BeginCountPreviousFrame; - short BeginOrderWithinParent; - short BeginOrderWithinContext; - short FocusOrder; - ImGuiID PopupId; - ImS8 AutoFitFramesX, AutoFitFramesY; - bool AutoFitOnlyGrows; - ImGuiDir AutoPosLastDirection; - ImS8 HiddenFramesCanSkipItems; - ImS8 HiddenFramesCannotSkipItems; - ImS8 HiddenFramesForRenderOnly; - ImS8 DisableInputsFrames; - ImGuiCond SetWindowPosAllowFlags : 8; - ImGuiCond SetWindowSizeAllowFlags : 8; - ImGuiCond SetWindowCollapsedAllowFlags : 8; - ImGuiCond SetWindowDockAllowFlags : 8; - ImVec2 SetWindowPosVal; - ImVec2 SetWindowPosPivot; - ImVector_ImGuiID IDStack; - ImGuiWindowTempData DC; - ImRect OuterRectClipped; - ImRect InnerRect; - ImRect InnerClipRect; - ImRect WorkRect; - ImRect ParentWorkRect; - ImRect ClipRect; - ImRect ContentRegionRect; - ImVec2ih HitTestHoleSize; - ImVec2ih HitTestHoleOffset; - int LastFrameActive; - int LastFrameJustFocused; - float LastTimeActive; - float ItemWidthDefault; - ImGuiStorage StateStorage; - ImVector_ImGuiOldColumns ColumnsStorage; - float FontWindowScale; - float FontDpiScale; - int SettingsOffset; - ImDrawList* DrawList; - ImDrawList DrawListInst; - ImGuiWindow* ParentWindow; - ImGuiWindow* ParentWindowInBeginStack; - ImGuiWindow* RootWindow; - ImGuiWindow* RootWindowPopupTree; - ImGuiWindow* RootWindowDockTree; - ImGuiWindow* RootWindowForTitleBarHighlight; - ImGuiWindow* RootWindowForNav; - ImGuiWindow* ParentWindowForFocusRoute; - ImGuiWindow* NavLastChildNavWindow; - ImGuiID NavLastIds[ImGuiNavLayer_COUNT]; - ImRect NavRectRel[ImGuiNavLayer_COUNT]; - ImVec2 NavPreferredScoringPosRel[ImGuiNavLayer_COUNT]; - ImGuiID NavRootFocusScopeId; - int MemoryDrawListIdxCapacity; - int MemoryDrawListVtxCapacity; - bool MemoryCompacted; - bool DockIsActive : 1; - bool DockNodeIsVisible : 1; - bool DockTabIsVisible : 1; - bool DockTabWantClose : 1; - short DockOrder; - ImGuiWindowDockStyle DockStyle; - ImGuiDockNode* DockNode; - ImGuiDockNode* DockNodeAsHost; - ImGuiID DockId; - ImGuiItemStatusFlags DockTabItemStatusFlags; - ImRect DockTabItemRect; -}; -typedef enum { - ImGuiTabBarFlags_DockNode = 1 << 20, - ImGuiTabBarFlags_IsFocused = 1 << 21, - ImGuiTabBarFlags_SaveSettings = 1 << 22, -} ImGuiTabBarFlagsPrivate_; -typedef enum { - ImGuiTabItemFlags_SectionMask_ = - ImGuiTabItemFlags_Leading | ImGuiTabItemFlags_Trailing, - ImGuiTabItemFlags_NoCloseButton = 1 << 20, - ImGuiTabItemFlags_Button = 1 << 21, - ImGuiTabItemFlags_Unsorted = 1 << 22, -} ImGuiTabItemFlagsPrivate_; -struct ImGuiTabItem { - ImGuiID ID; - ImGuiTabItemFlags Flags; - ImGuiWindow* Window; - int LastFrameVisible; - int LastFrameSelected; - float Offset; - float Width; - float ContentWidth; - float RequestedWidth; - ImS32 NameOffset; - ImS16 BeginOrder; - ImS16 IndexDuringLayout; - bool WantClose; -}; -typedef struct ImVector_ImGuiTabItem { - int Size; - int Capacity; - ImGuiTabItem* Data; -} ImVector_ImGuiTabItem; - -struct ImGuiTabBar { - ImVector_ImGuiTabItem Tabs; - ImGuiTabBarFlags Flags; - ImGuiID ID; - ImGuiID SelectedTabId; - ImGuiID NextSelectedTabId; - ImGuiID VisibleTabId; - int CurrFrameVisible; - int PrevFrameVisible; - ImRect BarRect; - float CurrTabsContentsHeight; - float PrevTabsContentsHeight; - float WidthAllTabs; - float WidthAllTabsIdeal; - float ScrollingAnim; - float ScrollingTarget; - float ScrollingTargetDistToVisibility; - float ScrollingSpeed; - float ScrollingRectMinX; - float ScrollingRectMaxX; - float SeparatorMinX; - float SeparatorMaxX; - ImGuiID ReorderRequestTabId; - ImS16 ReorderRequestOffset; - ImS8 BeginCount; - bool WantLayout; - bool VisibleTabWasSubmitted; - bool TabsAddedNew; - ImS16 TabsActiveCount; - ImS16 LastTabItemIdx; - float ItemSpacingY; - ImVec2 FramePadding; - ImVec2 BackupCursorPos; - ImGuiTextBuffer TabsNames; -}; -typedef ImS16 ImGuiTableColumnIdx; -typedef ImU16 ImGuiTableDrawChannelIdx; -struct ImGuiTableColumn { - ImGuiTableColumnFlags Flags; - float WidthGiven; - float MinX; - float MaxX; - float WidthRequest; - float WidthAuto; - float StretchWeight; - float InitStretchWeightOrWidth; - ImRect ClipRect; - ImGuiID UserID; - float WorkMinX; - float WorkMaxX; - float ItemWidth; - float ContentMaxXFrozen; - float ContentMaxXUnfrozen; - float ContentMaxXHeadersUsed; - float ContentMaxXHeadersIdeal; - ImS16 NameOffset; - ImGuiTableColumnIdx DisplayOrder; - ImGuiTableColumnIdx IndexWithinEnabledSet; - ImGuiTableColumnIdx PrevEnabledColumn; - ImGuiTableColumnIdx NextEnabledColumn; - ImGuiTableColumnIdx SortOrder; - ImGuiTableDrawChannelIdx DrawChannelCurrent; - ImGuiTableDrawChannelIdx DrawChannelFrozen; - ImGuiTableDrawChannelIdx DrawChannelUnfrozen; - bool IsEnabled; - bool IsUserEnabled; - bool IsUserEnabledNextFrame; - bool IsVisibleX; - bool IsVisibleY; - bool IsRequestOutput; - bool IsSkipItems; - bool IsPreserveWidthAuto; - ImS8 NavLayerCurrent; - ImU8 AutoFitQueue; - ImU8 CannotSkipItemsQueue; - ImU8 SortDirection : 2; - ImU8 SortDirectionsAvailCount : 2; - ImU8 SortDirectionsAvailMask : 4; - ImU8 SortDirectionsAvailList; -}; -typedef struct ImGuiTableCellData ImGuiTableCellData; -struct ImGuiTableCellData { - ImU32 BgColor; - ImGuiTableColumnIdx Column; -}; -struct ImGuiTableHeaderData { - ImGuiTableColumnIdx Index; - ImU32 TextColor; - ImU32 BgColor0; - ImU32 BgColor1; -}; -struct ImGuiTableInstanceData { - ImGuiID TableInstanceID; - float LastOuterHeight; - float LastTopHeadersRowHeight; - float LastFrozenHeight; - int HoveredRowLast; - int HoveredRowNext; -}; -typedef struct ImSpan_ImGuiTableColumn { - ImGuiTableColumn* Data; - ImGuiTableColumn* DataEnd; -} ImSpan_ImGuiTableColumn; - -typedef struct ImSpan_ImGuiTableColumnIdx { - ImGuiTableColumnIdx* Data; - ImGuiTableColumnIdx* DataEnd; -} ImSpan_ImGuiTableColumnIdx; - -typedef struct ImSpan_ImGuiTableCellData { - ImGuiTableCellData* Data; - ImGuiTableCellData* DataEnd; -} ImSpan_ImGuiTableCellData; - -typedef struct ImVector_ImGuiTableInstanceData { - int Size; - int Capacity; - ImGuiTableInstanceData* Data; -} ImVector_ImGuiTableInstanceData; - -typedef struct ImVector_ImGuiTableColumnSortSpecs { - int Size; - int Capacity; - ImGuiTableColumnSortSpecs* Data; -} ImVector_ImGuiTableColumnSortSpecs; - -struct ImGuiTable { - ImGuiID ID; - ImGuiTableFlags Flags; - void* RawData; - ImGuiTableTempData* TempData; - ImSpan_ImGuiTableColumn Columns; - ImSpan_ImGuiTableColumnIdx DisplayOrderToIndex; - ImSpan_ImGuiTableCellData RowCellData; - ImBitArrayPtr EnabledMaskByDisplayOrder; - ImBitArrayPtr EnabledMaskByIndex; - ImBitArrayPtr VisibleMaskByIndex; - ImGuiTableFlags SettingsLoadedFlags; - int SettingsOffset; - int LastFrameActive; - int ColumnsCount; - int CurrentRow; - int CurrentColumn; - ImS16 InstanceCurrent; - ImS16 InstanceInteracted; - float RowPosY1; - float RowPosY2; - float RowMinHeight; - float RowCellPaddingY; - float RowTextBaseline; - float RowIndentOffsetX; - ImGuiTableRowFlags RowFlags : 16; - ImGuiTableRowFlags LastRowFlags : 16; - int RowBgColorCounter; - ImU32 RowBgColor[2]; - ImU32 BorderColorStrong; - ImU32 BorderColorLight; - float BorderX1; - float BorderX2; - float HostIndentX; - float MinColumnWidth; - float OuterPaddingX; - float CellPaddingX; - float CellSpacingX1; - float CellSpacingX2; - float InnerWidth; - float ColumnsGivenWidth; - float ColumnsAutoFitWidth; - float ColumnsStretchSumWeights; - float ResizedColumnNextWidth; - float ResizeLockMinContentsX2; - float RefScale; - float AngledHeadersHeight; - float AngledHeadersSlope; - ImRect OuterRect; - ImRect InnerRect; - ImRect WorkRect; - ImRect InnerClipRect; - ImRect BgClipRect; - ImRect Bg0ClipRectForDrawCmd; - ImRect Bg2ClipRectForDrawCmd; - ImRect HostClipRect; - ImRect HostBackupInnerClipRect; - ImGuiWindow* OuterWindow; - ImGuiWindow* InnerWindow; - ImGuiTextBuffer ColumnsNames; - ImDrawListSplitter* DrawSplitter; - ImGuiTableInstanceData InstanceDataFirst; - ImVector_ImGuiTableInstanceData InstanceDataExtra; - ImGuiTableColumnSortSpecs SortSpecsSingle; - ImVector_ImGuiTableColumnSortSpecs SortSpecsMulti; - ImGuiTableSortSpecs SortSpecs; - ImGuiTableColumnIdx SortSpecsCount; - ImGuiTableColumnIdx ColumnsEnabledCount; - ImGuiTableColumnIdx ColumnsEnabledFixedCount; - ImGuiTableColumnIdx DeclColumnsCount; - ImGuiTableColumnIdx AngledHeadersCount; - ImGuiTableColumnIdx HoveredColumnBody; - ImGuiTableColumnIdx HoveredColumnBorder; - ImGuiTableColumnIdx HighlightColumnHeader; - ImGuiTableColumnIdx AutoFitSingleColumn; - ImGuiTableColumnIdx ResizedColumn; - ImGuiTableColumnIdx LastResizedColumn; - ImGuiTableColumnIdx HeldHeaderColumn; - ImGuiTableColumnIdx ReorderColumn; - ImGuiTableColumnIdx ReorderColumnDir; - ImGuiTableColumnIdx LeftMostEnabledColumn; - ImGuiTableColumnIdx RightMostEnabledColumn; - ImGuiTableColumnIdx LeftMostStretchedColumn; - ImGuiTableColumnIdx RightMostStretchedColumn; - ImGuiTableColumnIdx ContextPopupColumn; - ImGuiTableColumnIdx FreezeRowsRequest; - ImGuiTableColumnIdx FreezeRowsCount; - ImGuiTableColumnIdx FreezeColumnsRequest; - ImGuiTableColumnIdx FreezeColumnsCount; - ImGuiTableColumnIdx RowCellDataCurrent; - ImGuiTableDrawChannelIdx DummyDrawChannel; - ImGuiTableDrawChannelIdx Bg2DrawChannelCurrent; - ImGuiTableDrawChannelIdx Bg2DrawChannelUnfrozen; - bool IsLayoutLocked; - bool IsInsideRow; - bool IsInitializing; - bool IsSortSpecsDirty; - bool IsUsingHeaders; - bool IsContextPopupOpen; - bool DisableDefaultContextMenu; - bool IsSettingsRequestLoad; - bool IsSettingsDirty; - bool IsDefaultDisplayOrder; - bool IsResetAllRequest; - bool IsResetDisplayOrderRequest; - bool IsUnfrozenRows; - bool IsDefaultSizingPolicy; - bool IsActiveIdAliveBeforeTable; - bool IsActiveIdInTable; - bool HasScrollbarYCurr; - bool HasScrollbarYPrev; - bool MemoryCompacted; - bool HostSkipItems; -}; -typedef struct ImVector_ImGuiTableHeaderData { - int Size; - int Capacity; - ImGuiTableHeaderData* Data; -} ImVector_ImGuiTableHeaderData; - -struct ImGuiTableTempData { - int TableIndex; - float LastTimeActive; - float AngledHeadersExtraWidth; - ImVector_ImGuiTableHeaderData AngledHeadersRequests; - ImVec2 UserOuterSize; - ImDrawListSplitter DrawSplitter; - ImRect HostBackupWorkRect; - ImRect HostBackupParentWorkRect; - ImVec2 HostBackupPrevLineSize; - ImVec2 HostBackupCurrLineSize; - ImVec2 HostBackupCursorMaxPos; - ImVec1 HostBackupColumnsOffset; - float HostBackupItemWidth; - int HostBackupItemWidthStackSize; -}; -typedef struct ImGuiTableColumnSettings ImGuiTableColumnSettings; -struct ImGuiTableColumnSettings { - float WidthOrWeight; - ImGuiID UserID; - ImGuiTableColumnIdx Index; - ImGuiTableColumnIdx DisplayOrder; - ImGuiTableColumnIdx SortOrder; - ImU8 SortDirection : 2; - ImU8 IsEnabled : 1; - ImU8 IsStretch : 1; -}; -struct ImGuiTableSettings { - ImGuiID ID; - ImGuiTableFlags SaveFlags; - float RefScale; - ImGuiTableColumnIdx ColumnsCount; - ImGuiTableColumnIdx ColumnsCountMax; - bool WantApply; -}; -struct ImFontBuilderIO { - bool (*FontBuilder_Build)(ImFontAtlas* atlas); -}; -#define IMGUI_HAS_DOCK 1 - -#define ImDrawCallback_ResetRenderState (ImDrawCallback)(-8) - -#else -struct GLFWwindow; -struct SDL_Window; -typedef union SDL_Event SDL_Event; -#endif // CIMGUI_DEFINE_ENUMS_AND_STRUCTS - -#ifndef CIMGUI_DEFINE_ENUMS_AND_STRUCTS -typedef struct ImGuiStorage::ImGuiStoragePair ImGuiStoragePair; -typedef struct ImGuiTextFilter::ImGuiTextRange ImGuiTextRange; -typedef ImStb::STB_TexteditState STB_TexteditState; -typedef ImStb::StbTexteditRow StbTexteditRow; -typedef ImStb::StbUndoRecord StbUndoRecord; -typedef ImStb::StbUndoState StbUndoState; -typedef ImChunkStream ImChunkStream_ImGuiTableSettings; -typedef ImChunkStream ImChunkStream_ImGuiWindowSettings; -typedef ImPool ImPool_ImGuiTabBar; -typedef ImPool ImPool_ImGuiTable; -typedef ImSpan ImSpan_ImGuiTableCellData; -typedef ImSpan ImSpan_ImGuiTableColumn; -typedef ImSpan ImSpan_ImGuiTableColumnIdx; -typedef ImVector ImVector_ImDrawChannel; -typedef ImVector ImVector_ImDrawCmd; -typedef ImVector ImVector_ImDrawIdx; -typedef ImVector ImVector_ImDrawListPtr; -typedef ImVector ImVector_ImDrawVert; -typedef ImVector ImVector_ImFontPtr; -typedef ImVector ImVector_ImFontAtlasCustomRect; -typedef ImVector ImVector_ImFontConfig; -typedef ImVector ImVector_ImFontGlyph; -typedef ImVector ImVector_ImGuiColorMod; -typedef ImVector ImVector_ImGuiContextHook; -typedef ImVector ImVector_ImGuiDockNodeSettings; -typedef ImVector ImVector_ImGuiDockRequest; -typedef ImVector ImVector_ImGuiFocusScopeData; -typedef ImVector ImVector_ImGuiGroupData; -typedef ImVector ImVector_ImGuiID; -typedef ImVector ImVector_ImGuiInputEvent; -typedef ImVector ImVector_ImGuiItemFlags; -typedef ImVector ImVector_ImGuiKeyRoutingData; -typedef ImVector ImVector_ImGuiListClipperData; -typedef ImVector ImVector_ImGuiListClipperRange; -typedef ImVector ImVector_ImGuiNavTreeNodeData; -typedef ImVector ImVector_ImGuiOldColumnData; -typedef ImVector ImVector_ImGuiOldColumns; -typedef ImVector ImVector_ImGuiPlatformMonitor; -typedef ImVector ImVector_ImGuiPopupData; -typedef ImVector ImVector_ImGuiPtrOrIndex; -typedef ImVector ImVector_ImGuiSettingsHandler; -typedef ImVector ImVector_ImGuiShrinkWidthItem; -typedef ImVector ImVector_ImGuiStackLevelInfo; -typedef ImVector ImVector_ImGuiStoragePair; -typedef ImVector ImVector_ImGuiStyleMod; -typedef ImVector ImVector_ImGuiTabItem; -typedef ImVector ImVector_ImGuiTableColumnSortSpecs; -typedef ImVector ImVector_ImGuiTableHeaderData; -typedef ImVector ImVector_ImGuiTableInstanceData; -typedef ImVector ImVector_ImGuiTableTempData; -typedef ImVector ImVector_ImGuiTextRange; -typedef ImVector ImVector_ImGuiViewportPtr; -typedef ImVector ImVector_ImGuiViewportPPtr; -typedef ImVector ImVector_ImGuiWindowPtr; -typedef ImVector ImVector_ImGuiWindowStackData; -typedef ImVector ImVector_ImTextureID; -typedef ImVector ImVector_ImU32; -typedef ImVector ImVector_ImVec2; -typedef ImVector ImVector_ImVec4; -typedef ImVector ImVector_ImWchar; -typedef ImVector ImVector_char; -typedef ImVector ImVector_const_charPtr; -typedef ImVector ImVector_float; -typedef ImVector ImVector_int; -typedef ImVector ImVector_unsigned_char; -#endif // CIMGUI_DEFINE_ENUMS_AND_STRUCTS -CIMGUI_API ImVec2* ImVec2_ImVec2_Nil(void); -CIMGUI_API void ImVec2_destroy(ImVec2* self); -CIMGUI_API ImVec2* ImVec2_ImVec2_Float(float _x, float _y); -CIMGUI_API ImVec4* ImVec4_ImVec4_Nil(void); -CIMGUI_API void ImVec4_destroy(ImVec4* self); -CIMGUI_API ImVec4* ImVec4_ImVec4_Float(float _x, float _y, float _z, float _w); -CIMGUI_API ImGuiContext* igCreateContext(ImFontAtlas* shared_font_atlas); -CIMGUI_API void igDestroyContext(ImGuiContext* ctx); -CIMGUI_API ImGuiContext* igGetCurrentContext(void); -CIMGUI_API void igSetCurrentContext(ImGuiContext* ctx); -CIMGUI_API ImGuiIO* igGetIO(void); -CIMGUI_API ImGuiStyle* igGetStyle(void); -CIMGUI_API void igNewFrame(void); -CIMGUI_API void igEndFrame(void); -CIMGUI_API void igRender(void); -CIMGUI_API ImDrawData* igGetDrawData(void); -CIMGUI_API void igShowDemoWindow(bool* p_open); -CIMGUI_API void igShowMetricsWindow(bool* p_open); -CIMGUI_API void igShowDebugLogWindow(bool* p_open); -CIMGUI_API void igShowIDStackToolWindow(bool* p_open); -CIMGUI_API void igShowAboutWindow(bool* p_open); -CIMGUI_API void igShowStyleEditor(ImGuiStyle* ref); -CIMGUI_API bool igShowStyleSelector(const char* label); -CIMGUI_API void igShowFontSelector(const char* label); -CIMGUI_API void igShowUserGuide(void); -CIMGUI_API const char* igGetVersion(void); -CIMGUI_API void igStyleColorsDark(ImGuiStyle* dst); -CIMGUI_API void igStyleColorsLight(ImGuiStyle* dst); -CIMGUI_API void igStyleColorsClassic(ImGuiStyle* dst); -CIMGUI_API bool igBegin(const char* name, bool* p_open, ImGuiWindowFlags flags); -CIMGUI_API void igEnd(void); -CIMGUI_API bool igBeginChild_Str(const char* str_id, - const ImVec2 size, - ImGuiChildFlags child_flags, - ImGuiWindowFlags window_flags); -CIMGUI_API bool igBeginChild_ID(ImGuiID id, - const ImVec2 size, - ImGuiChildFlags child_flags, - ImGuiWindowFlags window_flags); -CIMGUI_API void igEndChild(void); -CIMGUI_API bool igIsWindowAppearing(void); -CIMGUI_API bool igIsWindowCollapsed(void); -CIMGUI_API bool igIsWindowFocused(ImGuiFocusedFlags flags); -CIMGUI_API bool igIsWindowHovered(ImGuiHoveredFlags flags); -CIMGUI_API ImDrawList* igGetWindowDrawList(void); -CIMGUI_API float igGetWindowDpiScale(void); -CIMGUI_API void igGetWindowPos(ImVec2* pOut); -CIMGUI_API void igGetWindowSize(ImVec2* pOut); -CIMGUI_API float igGetWindowWidth(void); -CIMGUI_API float igGetWindowHeight(void); -CIMGUI_API ImGuiViewport* igGetWindowViewport(void); -CIMGUI_API void igSetNextWindowPos(const ImVec2 pos, - ImGuiCond cond, - const ImVec2 pivot); -CIMGUI_API void igSetNextWindowSize(const ImVec2 size, ImGuiCond cond); -CIMGUI_API void igSetNextWindowSizeConstraints( - const ImVec2 size_min, - const ImVec2 size_max, - ImGuiSizeCallback custom_callback, - void* custom_callback_data); -CIMGUI_API void igSetNextWindowContentSize(const ImVec2 size); -CIMGUI_API void igSetNextWindowCollapsed(bool collapsed, ImGuiCond cond); -CIMGUI_API void igSetNextWindowFocus(void); -CIMGUI_API void igSetNextWindowScroll(const ImVec2 scroll); -CIMGUI_API void igSetNextWindowBgAlpha(float alpha); -CIMGUI_API void igSetNextWindowViewport(ImGuiID viewport_id); -CIMGUI_API void igSetWindowPos_Vec2(const ImVec2 pos, ImGuiCond cond); -CIMGUI_API void igSetWindowSize_Vec2(const ImVec2 size, ImGuiCond cond); -CIMGUI_API void igSetWindowCollapsed_Bool(bool collapsed, ImGuiCond cond); -CIMGUI_API void igSetWindowFocus_Nil(void); -CIMGUI_API void igSetWindowFontScale(float scale); -CIMGUI_API void igSetWindowPos_Str(const char* name, - const ImVec2 pos, - ImGuiCond cond); -CIMGUI_API void igSetWindowSize_Str(const char* name, - const ImVec2 size, - ImGuiCond cond); -CIMGUI_API void igSetWindowCollapsed_Str(const char* name, - bool collapsed, - ImGuiCond cond); -CIMGUI_API void igSetWindowFocus_Str(const char* name); -CIMGUI_API void igGetContentRegionAvail(ImVec2* pOut); -CIMGUI_API void igGetContentRegionMax(ImVec2* pOut); -CIMGUI_API void igGetWindowContentRegionMin(ImVec2* pOut); -CIMGUI_API void igGetWindowContentRegionMax(ImVec2* pOut); -CIMGUI_API float igGetScrollX(void); -CIMGUI_API float igGetScrollY(void); -CIMGUI_API void igSetScrollX_Float(float scroll_x); -CIMGUI_API void igSetScrollY_Float(float scroll_y); -CIMGUI_API float igGetScrollMaxX(void); -CIMGUI_API float igGetScrollMaxY(void); -CIMGUI_API void igSetScrollHereX(float center_x_ratio); -CIMGUI_API void igSetScrollHereY(float center_y_ratio); -CIMGUI_API void igSetScrollFromPosX_Float(float local_x, float center_x_ratio); -CIMGUI_API void igSetScrollFromPosY_Float(float local_y, float center_y_ratio); -CIMGUI_API void igPushFont(ImFont* font); -CIMGUI_API void igPopFont(void); -CIMGUI_API void igPushStyleColor_U32(ImGuiCol idx, ImU32 col); -CIMGUI_API void igPushStyleColor_Vec4(ImGuiCol idx, const ImVec4 col); -CIMGUI_API void igPopStyleColor(int count); -CIMGUI_API void igPushStyleVar_Float(ImGuiStyleVar idx, float val); -CIMGUI_API void igPushStyleVar_Vec2(ImGuiStyleVar idx, const ImVec2 val); -CIMGUI_API void igPopStyleVar(int count); -CIMGUI_API void igPushTabStop(bool tab_stop); -CIMGUI_API void igPopTabStop(void); -CIMGUI_API void igPushButtonRepeat(bool repeat); -CIMGUI_API void igPopButtonRepeat(void); -CIMGUI_API void igPushItemWidth(float item_width); -CIMGUI_API void igPopItemWidth(void); -CIMGUI_API void igSetNextItemWidth(float item_width); -CIMGUI_API float igCalcItemWidth(void); -CIMGUI_API void igPushTextWrapPos(float wrap_local_pos_x); -CIMGUI_API void igPopTextWrapPos(void); -CIMGUI_API ImFont* igGetFont(void); -CIMGUI_API float igGetFontSize(void); -CIMGUI_API void igGetFontTexUvWhitePixel(ImVec2* pOut); -CIMGUI_API ImU32 igGetColorU32_Col(ImGuiCol idx, float alpha_mul); -CIMGUI_API ImU32 igGetColorU32_Vec4(const ImVec4 col); -CIMGUI_API ImU32 igGetColorU32_U32(ImU32 col, float alpha_mul); -CIMGUI_API const ImVec4* igGetStyleColorVec4(ImGuiCol idx); -CIMGUI_API void igGetCursorScreenPos(ImVec2* pOut); -CIMGUI_API void igSetCursorScreenPos(const ImVec2 pos); -CIMGUI_API void igGetCursorPos(ImVec2* pOut); -CIMGUI_API float igGetCursorPosX(void); -CIMGUI_API float igGetCursorPosY(void); -CIMGUI_API void igSetCursorPos(const ImVec2 local_pos); -CIMGUI_API void igSetCursorPosX(float local_x); -CIMGUI_API void igSetCursorPosY(float local_y); -CIMGUI_API void igGetCursorStartPos(ImVec2* pOut); -CIMGUI_API void igSeparator(void); -CIMGUI_API void igSameLine(float offset_from_start_x, float spacing); -CIMGUI_API void igNewLine(void); -CIMGUI_API void igSpacing(void); -CIMGUI_API void igDummy(const ImVec2 size); -CIMGUI_API void igIndent(float indent_w); -CIMGUI_API void igUnindent(float indent_w); -CIMGUI_API void igBeginGroup(void); -CIMGUI_API void igEndGroup(void); -CIMGUI_API void igAlignTextToFramePadding(void); -CIMGUI_API float igGetTextLineHeight(void); -CIMGUI_API float igGetTextLineHeightWithSpacing(void); -CIMGUI_API float igGetFrameHeight(void); -CIMGUI_API float igGetFrameHeightWithSpacing(void); -CIMGUI_API void igPushID_Str(const char* str_id); -CIMGUI_API void igPushID_StrStr(const char* str_id_begin, - const char* str_id_end); -CIMGUI_API void igPushID_Ptr(const void* ptr_id); -CIMGUI_API void igPushID_Int(int int_id); -CIMGUI_API void igPopID(void); -CIMGUI_API ImGuiID igGetID_Str(const char* str_id); -CIMGUI_API ImGuiID igGetID_StrStr(const char* str_id_begin, - const char* str_id_end); -CIMGUI_API ImGuiID igGetID_Ptr(const void* ptr_id); -CIMGUI_API void igTextUnformatted(const char* text, const char* text_end); -CIMGUI_API void igText(const char* fmt, ...); -CIMGUI_API void igTextV(const char* fmt, va_list args); -CIMGUI_API void igTextColored(const ImVec4 col, const char* fmt, ...); -CIMGUI_API void igTextColoredV(const ImVec4 col, const char* fmt, va_list args); -CIMGUI_API void igTextDisabled(const char* fmt, ...); -CIMGUI_API void igTextDisabledV(const char* fmt, va_list args); -CIMGUI_API void igTextWrapped(const char* fmt, ...); -CIMGUI_API void igTextWrappedV(const char* fmt, va_list args); -CIMGUI_API void igLabelText(const char* label, const char* fmt, ...); -CIMGUI_API void igLabelTextV(const char* label, const char* fmt, va_list args); -CIMGUI_API void igBulletText(const char* fmt, ...); -CIMGUI_API void igBulletTextV(const char* fmt, va_list args); -CIMGUI_API void igSeparatorText(const char* label); -CIMGUI_API bool igButton(const char* label, const ImVec2 size); -CIMGUI_API bool igSmallButton(const char* label); -CIMGUI_API bool igInvisibleButton(const char* str_id, - const ImVec2 size, - ImGuiButtonFlags flags); -CIMGUI_API bool igArrowButton(const char* str_id, ImGuiDir dir); -CIMGUI_API bool igCheckbox(const char* label, bool* v); -CIMGUI_API bool igCheckboxFlags_IntPtr(const char* label, - int* flags, - int flags_value); -CIMGUI_API bool igCheckboxFlags_UintPtr(const char* label, - unsigned int* flags, - unsigned int flags_value); -CIMGUI_API bool igRadioButton_Bool(const char* label, bool active); -CIMGUI_API bool igRadioButton_IntPtr(const char* label, int* v, int v_button); -CIMGUI_API void igProgressBar(float fraction, - const ImVec2 size_arg, - const char* overlay); -CIMGUI_API void igBullet(void); -CIMGUI_API void igImage(ImTextureID user_texture_id, - const ImVec2 image_size, - const ImVec2 uv0, - const ImVec2 uv1, - const ImVec4 tint_col, - const ImVec4 border_col); -CIMGUI_API bool igImageButton(const char* str_id, - ImTextureID user_texture_id, - const ImVec2 image_size, - const ImVec2 uv0, - const ImVec2 uv1, - const ImVec4 bg_col, - const ImVec4 tint_col); -CIMGUI_API bool igBeginCombo(const char* label, - const char* preview_value, - ImGuiComboFlags flags); -CIMGUI_API void igEndCombo(void); -CIMGUI_API bool igCombo_Str_arr(const char* label, - int* current_item, - const char* const items[], - int items_count, - int popup_max_height_in_items); -CIMGUI_API bool igCombo_Str(const char* label, - int* current_item, - const char* items_separated_by_zeros, - int popup_max_height_in_items); -CIMGUI_API bool igCombo_FnStrPtr(const char* label, - int* current_item, - const char* (*getter)(void* user_data, - int idx), - void* user_data, - int items_count, - int popup_max_height_in_items); -CIMGUI_API bool igDragFloat(const char* label, - float* v, - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragFloat2(const char* label, - float v[2], - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragFloat3(const char* label, - float v[3], - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragFloat4(const char* label, - float v[4], - float v_speed, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragFloatRange2(const char* label, - float* v_current_min, - float* v_current_max, - float v_speed, - float v_min, - float v_max, - const char* format, - const char* format_max, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragInt(const char* label, - int* v, - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragInt2(const char* label, - int v[2], - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragInt3(const char* label, - int v[3], - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragInt4(const char* label, - int v[4], - float v_speed, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragIntRange2(const char* label, - int* v_current_min, - int* v_current_max, - float v_speed, - int v_min, - int v_max, - const char* format, - const char* format_max, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragScalar(const char* label, - ImGuiDataType data_type, - void* p_data, - float v_speed, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igDragScalarN(const char* label, - ImGuiDataType data_type, - void* p_data, - int components, - float v_speed, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderFloat(const char* label, - float* v, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderFloat2(const char* label, - float v[2], - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderFloat3(const char* label, - float v[3], - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderFloat4(const char* label, - float v[4], - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderAngle(const char* label, - float* v_rad, - float v_degrees_min, - float v_degrees_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderInt(const char* label, - int* v, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderInt2(const char* label, - int v[2], - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderInt3(const char* label, - int v[3], - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderInt4(const char* label, - int v[4], - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderScalar(const char* label, - ImGuiDataType data_type, - void* p_data, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderScalarN(const char* label, - ImGuiDataType data_type, - void* p_data, - int components, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igVSliderFloat(const char* label, - const ImVec2 size, - float* v, - float v_min, - float v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igVSliderInt(const char* label, - const ImVec2 size, - int* v, - int v_min, - int v_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igVSliderScalar(const char* label, - const ImVec2 size, - ImGuiDataType data_type, - void* p_data, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igInputText(const char* label, - char* buf, - size_t buf_size, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data); -CIMGUI_API bool igInputTextMultiline(const char* label, - char* buf, - size_t buf_size, - const ImVec2 size, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data); -CIMGUI_API bool igInputTextWithHint(const char* label, - const char* hint, - char* buf, - size_t buf_size, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data); -CIMGUI_API bool igInputFloat(const char* label, - float* v, - float step, - float step_fast, - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputFloat2(const char* label, - float v[2], - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputFloat3(const char* label, - float v[3], - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputFloat4(const char* label, - float v[4], - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputInt(const char* label, - int* v, - int step, - int step_fast, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputInt2(const char* label, - int v[2], - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputInt3(const char* label, - int v[3], - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputInt4(const char* label, - int v[4], - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputDouble(const char* label, - double* v, - double step, - double step_fast, - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputScalar(const char* label, - ImGuiDataType data_type, - void* p_data, - const void* p_step, - const void* p_step_fast, - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igInputScalarN(const char* label, - ImGuiDataType data_type, - void* p_data, - int components, - const void* p_step, - const void* p_step_fast, - const char* format, - ImGuiInputTextFlags flags); -CIMGUI_API bool igColorEdit3(const char* label, - float col[3], - ImGuiColorEditFlags flags); -CIMGUI_API bool igColorEdit4(const char* label, - float col[4], - ImGuiColorEditFlags flags); -CIMGUI_API bool igColorPicker3(const char* label, - float col[3], - ImGuiColorEditFlags flags); -CIMGUI_API bool igColorPicker4(const char* label, - float col[4], - ImGuiColorEditFlags flags, - const float* ref_col); -CIMGUI_API bool igColorButton(const char* desc_id, - const ImVec4 col, - ImGuiColorEditFlags flags, - const ImVec2 size); -CIMGUI_API void igSetColorEditOptions(ImGuiColorEditFlags flags); -CIMGUI_API bool igTreeNode_Str(const char* label); -CIMGUI_API bool igTreeNode_StrStr(const char* str_id, const char* fmt, ...); -CIMGUI_API bool igTreeNode_Ptr(const void* ptr_id, const char* fmt, ...); -CIMGUI_API bool igTreeNodeV_Str(const char* str_id, - const char* fmt, - va_list args); -CIMGUI_API bool igTreeNodeV_Ptr(const void* ptr_id, - const char* fmt, - va_list args); -CIMGUI_API bool igTreeNodeEx_Str(const char* label, ImGuiTreeNodeFlags flags); -CIMGUI_API bool igTreeNodeEx_StrStr(const char* str_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - ...); -CIMGUI_API bool igTreeNodeEx_Ptr(const void* ptr_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - ...); -CIMGUI_API bool igTreeNodeExV_Str(const char* str_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - va_list args); -CIMGUI_API bool igTreeNodeExV_Ptr(const void* ptr_id, - ImGuiTreeNodeFlags flags, - const char* fmt, - va_list args); -CIMGUI_API void igTreePush_Str(const char* str_id); -CIMGUI_API void igTreePush_Ptr(const void* ptr_id); -CIMGUI_API void igTreePop(void); -CIMGUI_API float igGetTreeNodeToLabelSpacing(void); -CIMGUI_API bool igCollapsingHeader_TreeNodeFlags(const char* label, - ImGuiTreeNodeFlags flags); -CIMGUI_API bool igCollapsingHeader_BoolPtr(const char* label, - bool* p_visible, - ImGuiTreeNodeFlags flags); -CIMGUI_API void igSetNextItemOpen(bool is_open, ImGuiCond cond); -CIMGUI_API bool igSelectable_Bool(const char* label, - bool selected, - ImGuiSelectableFlags flags, - const ImVec2 size); -CIMGUI_API bool igSelectable_BoolPtr(const char* label, - bool* p_selected, - ImGuiSelectableFlags flags, - const ImVec2 size); -CIMGUI_API bool igBeginListBox(const char* label, const ImVec2 size); -CIMGUI_API void igEndListBox(void); -CIMGUI_API bool igListBox_Str_arr(const char* label, - int* current_item, - const char* const items[], - int items_count, - int height_in_items); -CIMGUI_API bool igListBox_FnStrPtr(const char* label, - int* current_item, - const char* (*getter)(void* user_data, - int idx), - void* user_data, - int items_count, - int height_in_items); -CIMGUI_API void igPlotLines_FloatPtr(const char* label, - const float* values, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size, - int stride); -CIMGUI_API void igPlotLines_FnFloatPtr(const char* label, - float (*values_getter)(void* data, - int idx), - void* data, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size); -CIMGUI_API void igPlotHistogram_FloatPtr(const char* label, - const float* values, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size, - int stride); -CIMGUI_API void igPlotHistogram_FnFloatPtr(const char* label, - float (*values_getter)(void* data, - int idx), - void* data, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - ImVec2 graph_size); -CIMGUI_API void igValue_Bool(const char* prefix, bool b); -CIMGUI_API void igValue_Int(const char* prefix, int v); -CIMGUI_API void igValue_Uint(const char* prefix, unsigned int v); -CIMGUI_API void igValue_Float(const char* prefix, - float v, - const char* float_format); -CIMGUI_API bool igBeginMenuBar(void); -CIMGUI_API void igEndMenuBar(void); -CIMGUI_API bool igBeginMainMenuBar(void); -CIMGUI_API void igEndMainMenuBar(void); -CIMGUI_API bool igBeginMenu(const char* label, bool enabled); -CIMGUI_API void igEndMenu(void); -CIMGUI_API bool igMenuItem_Bool(const char* label, - const char* shortcut, - bool selected, - bool enabled); -CIMGUI_API bool igMenuItem_BoolPtr(const char* label, - const char* shortcut, - bool* p_selected, - bool enabled); -CIMGUI_API bool igBeginTooltip(void); -CIMGUI_API void igEndTooltip(void); -CIMGUI_API void igSetTooltip(const char* fmt, ...); -CIMGUI_API void igSetTooltipV(const char* fmt, va_list args); -CIMGUI_API bool igBeginItemTooltip(void); -CIMGUI_API void igSetItemTooltip(const char* fmt, ...); -CIMGUI_API void igSetItemTooltipV(const char* fmt, va_list args); -CIMGUI_API bool igBeginPopup(const char* str_id, ImGuiWindowFlags flags); -CIMGUI_API bool igBeginPopupModal(const char* name, - bool* p_open, - ImGuiWindowFlags flags); -CIMGUI_API void igEndPopup(void); -CIMGUI_API void igOpenPopup_Str(const char* str_id, - ImGuiPopupFlags popup_flags); -CIMGUI_API void igOpenPopup_ID(ImGuiID id, ImGuiPopupFlags popup_flags); -CIMGUI_API void igOpenPopupOnItemClick(const char* str_id, - ImGuiPopupFlags popup_flags); -CIMGUI_API void igCloseCurrentPopup(void); -CIMGUI_API bool igBeginPopupContextItem(const char* str_id, - ImGuiPopupFlags popup_flags); -CIMGUI_API bool igBeginPopupContextWindow(const char* str_id, - ImGuiPopupFlags popup_flags); -CIMGUI_API bool igBeginPopupContextVoid(const char* str_id, - ImGuiPopupFlags popup_flags); -CIMGUI_API bool igIsPopupOpen_Str(const char* str_id, ImGuiPopupFlags flags); -CIMGUI_API bool igBeginTable(const char* str_id, - int column, - ImGuiTableFlags flags, - const ImVec2 outer_size, - float inner_width); -CIMGUI_API void igEndTable(void); -CIMGUI_API void igTableNextRow(ImGuiTableRowFlags row_flags, - float min_row_height); -CIMGUI_API bool igTableNextColumn(void); -CIMGUI_API bool igTableSetColumnIndex(int column_n); -CIMGUI_API void igTableSetupColumn(const char* label, - ImGuiTableColumnFlags flags, - float init_width_or_weight, - ImGuiID user_id); -CIMGUI_API void igTableSetupScrollFreeze(int cols, int rows); -CIMGUI_API void igTableHeader(const char* label); -CIMGUI_API void igTableHeadersRow(void); -CIMGUI_API void igTableAngledHeadersRow(void); -CIMGUI_API ImGuiTableSortSpecs* igTableGetSortSpecs(void); -CIMGUI_API int igTableGetColumnCount(void); -CIMGUI_API int igTableGetColumnIndex(void); -CIMGUI_API int igTableGetRowIndex(void); -CIMGUI_API const char* igTableGetColumnName_Int(int column_n); -CIMGUI_API ImGuiTableColumnFlags igTableGetColumnFlags(int column_n); -CIMGUI_API void igTableSetColumnEnabled(int column_n, bool v); -CIMGUI_API void igTableSetBgColor(ImGuiTableBgTarget target, - ImU32 color, - int column_n); -CIMGUI_API void igColumns(int count, const char* id, bool border); -CIMGUI_API void igNextColumn(void); -CIMGUI_API int igGetColumnIndex(void); -CIMGUI_API float igGetColumnWidth(int column_index); -CIMGUI_API void igSetColumnWidth(int column_index, float width); -CIMGUI_API float igGetColumnOffset(int column_index); -CIMGUI_API void igSetColumnOffset(int column_index, float offset_x); -CIMGUI_API int igGetColumnsCount(void); -CIMGUI_API bool igBeginTabBar(const char* str_id, ImGuiTabBarFlags flags); -CIMGUI_API void igEndTabBar(void); -CIMGUI_API bool igBeginTabItem(const char* label, - bool* p_open, - ImGuiTabItemFlags flags); -CIMGUI_API void igEndTabItem(void); -CIMGUI_API bool igTabItemButton(const char* label, ImGuiTabItemFlags flags); -CIMGUI_API void igSetTabItemClosed(const char* tab_or_docked_window_label); -CIMGUI_API ImGuiID igDockSpace(ImGuiID id, - const ImVec2 size, - ImGuiDockNodeFlags flags, - const ImGuiWindowClass* window_class); -CIMGUI_API ImGuiID -igDockSpaceOverViewport(const ImGuiViewport* viewport, - ImGuiDockNodeFlags flags, - const ImGuiWindowClass* window_class); -CIMGUI_API void igSetNextWindowDockID(ImGuiID dock_id, ImGuiCond cond); -CIMGUI_API void igSetNextWindowClass(const ImGuiWindowClass* window_class); -CIMGUI_API ImGuiID igGetWindowDockID(void); -CIMGUI_API bool igIsWindowDocked(void); -CIMGUI_API void igLogToTTY(int auto_open_depth); -CIMGUI_API void igLogToFile(int auto_open_depth, const char* filename); -CIMGUI_API void igLogToClipboard(int auto_open_depth); -CIMGUI_API void igLogFinish(void); -CIMGUI_API void igLogButtons(void); -CIMGUI_API void igLogTextV(const char* fmt, va_list args); -CIMGUI_API bool igBeginDragDropSource(ImGuiDragDropFlags flags); -CIMGUI_API bool igSetDragDropPayload(const char* type, - const void* data, - size_t sz, - ImGuiCond cond); -CIMGUI_API void igEndDragDropSource(void); -CIMGUI_API bool igBeginDragDropTarget(void); -CIMGUI_API const ImGuiPayload* igAcceptDragDropPayload( - const char* type, - ImGuiDragDropFlags flags); -CIMGUI_API void igEndDragDropTarget(void); -CIMGUI_API const ImGuiPayload* igGetDragDropPayload(void); -CIMGUI_API void igBeginDisabled(bool disabled); -CIMGUI_API void igEndDisabled(void); -CIMGUI_API void igPushClipRect(const ImVec2 clip_rect_min, - const ImVec2 clip_rect_max, - bool intersect_with_current_clip_rect); -CIMGUI_API void igPopClipRect(void); -CIMGUI_API void igSetItemDefaultFocus(void); -CIMGUI_API void igSetKeyboardFocusHere(int offset); -CIMGUI_API void igSetNextItemAllowOverlap(void); -CIMGUI_API bool igIsItemHovered(ImGuiHoveredFlags flags); -CIMGUI_API bool igIsItemActive(void); -CIMGUI_API bool igIsItemFocused(void); -CIMGUI_API bool igIsItemClicked(ImGuiMouseButton mouse_button); -CIMGUI_API bool igIsItemVisible(void); -CIMGUI_API bool igIsItemEdited(void); -CIMGUI_API bool igIsItemActivated(void); -CIMGUI_API bool igIsItemDeactivated(void); -CIMGUI_API bool igIsItemDeactivatedAfterEdit(void); -CIMGUI_API bool igIsItemToggledOpen(void); -CIMGUI_API bool igIsAnyItemHovered(void); -CIMGUI_API bool igIsAnyItemActive(void); -CIMGUI_API bool igIsAnyItemFocused(void); -CIMGUI_API ImGuiID igGetItemID(void); -CIMGUI_API void igGetItemRectMin(ImVec2* pOut); -CIMGUI_API void igGetItemRectMax(ImVec2* pOut); -CIMGUI_API void igGetItemRectSize(ImVec2* pOut); -CIMGUI_API ImGuiViewport* igGetMainViewport(void); -CIMGUI_API ImDrawList* igGetBackgroundDrawList_Nil(void); -CIMGUI_API ImDrawList* igGetForegroundDrawList_Nil(void); -CIMGUI_API ImDrawList* igGetBackgroundDrawList_ViewportPtr( - ImGuiViewport* viewport); -CIMGUI_API ImDrawList* igGetForegroundDrawList_ViewportPtr( - ImGuiViewport* viewport); -CIMGUI_API bool igIsRectVisible_Nil(const ImVec2 size); -CIMGUI_API bool igIsRectVisible_Vec2(const ImVec2 rect_min, - const ImVec2 rect_max); -CIMGUI_API double igGetTime(void); -CIMGUI_API int igGetFrameCount(void); -CIMGUI_API ImDrawListSharedData* igGetDrawListSharedData(void); -CIMGUI_API const char* igGetStyleColorName(ImGuiCol idx); -CIMGUI_API void igSetStateStorage(ImGuiStorage* storage); -CIMGUI_API ImGuiStorage* igGetStateStorage(void); -CIMGUI_API void igCalcTextSize(ImVec2* pOut, - const char* text, - const char* text_end, - bool hide_text_after_double_hash, - float wrap_width); -CIMGUI_API void igColorConvertU32ToFloat4(ImVec4* pOut, ImU32 in); -CIMGUI_API ImU32 igColorConvertFloat4ToU32(const ImVec4 in); -CIMGUI_API void igColorConvertRGBtoHSV(float r, - float g, - float b, - float* out_h, - float* out_s, - float* out_v); -CIMGUI_API void igColorConvertHSVtoRGB(float h, - float s, - float v, - float* out_r, - float* out_g, - float* out_b); -CIMGUI_API bool igIsKeyDown_Nil(ImGuiKey key); -CIMGUI_API bool igIsKeyPressed_Bool(ImGuiKey key, bool repeat); -CIMGUI_API bool igIsKeyReleased_Nil(ImGuiKey key); -CIMGUI_API bool igIsKeyChordPressed_Nil(ImGuiKeyChord key_chord); -CIMGUI_API int igGetKeyPressedAmount(ImGuiKey key, - float repeat_delay, - float rate); -CIMGUI_API const char* igGetKeyName(ImGuiKey key); -CIMGUI_API void igSetNextFrameWantCaptureKeyboard(bool want_capture_keyboard); -CIMGUI_API bool igIsMouseDown_Nil(ImGuiMouseButton button); -CIMGUI_API bool igIsMouseClicked_Bool(ImGuiMouseButton button, bool repeat); -CIMGUI_API bool igIsMouseReleased_Nil(ImGuiMouseButton button); -CIMGUI_API bool igIsMouseDoubleClicked_Nil(ImGuiMouseButton button); -CIMGUI_API int igGetMouseClickedCount(ImGuiMouseButton button); -CIMGUI_API bool igIsMouseHoveringRect(const ImVec2 r_min, - const ImVec2 r_max, - bool clip); -CIMGUI_API bool igIsMousePosValid(const ImVec2* mouse_pos); -CIMGUI_API bool igIsAnyMouseDown(void); -CIMGUI_API void igGetMousePos(ImVec2* pOut); -CIMGUI_API void igGetMousePosOnOpeningCurrentPopup(ImVec2* pOut); -CIMGUI_API bool igIsMouseDragging(ImGuiMouseButton button, - float lock_threshold); -CIMGUI_API void igGetMouseDragDelta(ImVec2* pOut, - ImGuiMouseButton button, - float lock_threshold); -CIMGUI_API void igResetMouseDragDelta(ImGuiMouseButton button); -CIMGUI_API ImGuiMouseCursor igGetMouseCursor(void); -CIMGUI_API void igSetMouseCursor(ImGuiMouseCursor cursor_type); -CIMGUI_API void igSetNextFrameWantCaptureMouse(bool want_capture_mouse); -CIMGUI_API const char* igGetClipboardText(void); -CIMGUI_API void igSetClipboardText(const char* text); -CIMGUI_API void igLoadIniSettingsFromDisk(const char* ini_filename); -CIMGUI_API void igLoadIniSettingsFromMemory(const char* ini_data, - size_t ini_size); -CIMGUI_API void igSaveIniSettingsToDisk(const char* ini_filename); -CIMGUI_API const char* igSaveIniSettingsToMemory(size_t* out_ini_size); -CIMGUI_API void igDebugTextEncoding(const char* text); -CIMGUI_API void igDebugFlashStyleColor(ImGuiCol idx); -CIMGUI_API void igDebugStartItemPicker(void); -CIMGUI_API bool igDebugCheckVersionAndDataLayout(const char* version_str, - size_t sz_io, - size_t sz_style, - size_t sz_vec2, - size_t sz_vec4, - size_t sz_drawvert, - size_t sz_drawidx); -CIMGUI_API void igSetAllocatorFunctions(ImGuiMemAllocFunc alloc_func, - ImGuiMemFreeFunc free_func, - void* user_data); -CIMGUI_API void igGetAllocatorFunctions(ImGuiMemAllocFunc* p_alloc_func, - ImGuiMemFreeFunc* p_free_func, - void** p_user_data); -CIMGUI_API void* igMemAlloc(size_t size); -CIMGUI_API void igMemFree(void* ptr); -CIMGUI_API ImGuiPlatformIO* igGetPlatformIO(void); -CIMGUI_API void igUpdatePlatformWindows(void); -CIMGUI_API void igRenderPlatformWindowsDefault(void* platform_render_arg, - void* renderer_render_arg); -CIMGUI_API void igDestroyPlatformWindows(void); -CIMGUI_API ImGuiViewport* igFindViewportByID(ImGuiID id); -CIMGUI_API ImGuiViewport* igFindViewportByPlatformHandle(void* platform_handle); -CIMGUI_API ImGuiTableSortSpecs* ImGuiTableSortSpecs_ImGuiTableSortSpecs(void); -CIMGUI_API void ImGuiTableSortSpecs_destroy(ImGuiTableSortSpecs* self); -CIMGUI_API ImGuiTableColumnSortSpecs* -ImGuiTableColumnSortSpecs_ImGuiTableColumnSortSpecs(void); -CIMGUI_API void ImGuiTableColumnSortSpecs_destroy( - ImGuiTableColumnSortSpecs* self); -CIMGUI_API ImGuiStyle* ImGuiStyle_ImGuiStyle(void); -CIMGUI_API void ImGuiStyle_destroy(ImGuiStyle* self); -CIMGUI_API void ImGuiStyle_ScaleAllSizes(ImGuiStyle* self, float scale_factor); -CIMGUI_API void ImGuiIO_AddKeyEvent(ImGuiIO* self, ImGuiKey key, bool down); -CIMGUI_API void ImGuiIO_AddKeyAnalogEvent(ImGuiIO* self, - ImGuiKey key, - bool down, - float v); -CIMGUI_API void ImGuiIO_AddMousePosEvent(ImGuiIO* self, float x, float y); -CIMGUI_API void ImGuiIO_AddMouseButtonEvent(ImGuiIO* self, - int button, - bool down); -CIMGUI_API void ImGuiIO_AddMouseWheelEvent(ImGuiIO* self, - float wheel_x, - float wheel_y); -CIMGUI_API void ImGuiIO_AddMouseSourceEvent(ImGuiIO* self, - ImGuiMouseSource source); -CIMGUI_API void ImGuiIO_AddMouseViewportEvent(ImGuiIO* self, ImGuiID id); -CIMGUI_API void ImGuiIO_AddFocusEvent(ImGuiIO* self, bool focused); -CIMGUI_API void ImGuiIO_AddInputCharacter(ImGuiIO* self, unsigned int c); -CIMGUI_API void ImGuiIO_AddInputCharacterUTF16(ImGuiIO* self, ImWchar16 c); -CIMGUI_API void ImGuiIO_AddInputCharactersUTF8(ImGuiIO* self, const char* str); -CIMGUI_API void ImGuiIO_SetKeyEventNativeData(ImGuiIO* self, - ImGuiKey key, - int native_keycode, - int native_scancode, - int native_legacy_index); -CIMGUI_API void ImGuiIO_SetAppAcceptingEvents(ImGuiIO* self, - bool accepting_events); -CIMGUI_API void ImGuiIO_ClearEventsQueue(ImGuiIO* self); -CIMGUI_API void ImGuiIO_ClearInputKeys(ImGuiIO* self); -CIMGUI_API ImGuiIO* ImGuiIO_ImGuiIO(void); -CIMGUI_API void ImGuiIO_destroy(ImGuiIO* self); -CIMGUI_API ImGuiInputTextCallbackData* -ImGuiInputTextCallbackData_ImGuiInputTextCallbackData(void); -CIMGUI_API void ImGuiInputTextCallbackData_destroy( - ImGuiInputTextCallbackData* self); -CIMGUI_API void ImGuiInputTextCallbackData_DeleteChars( - ImGuiInputTextCallbackData* self, - int pos, - int bytes_count); -CIMGUI_API void ImGuiInputTextCallbackData_InsertChars( - ImGuiInputTextCallbackData* self, - int pos, - const char* text, - const char* text_end); -CIMGUI_API void ImGuiInputTextCallbackData_SelectAll( - ImGuiInputTextCallbackData* self); -CIMGUI_API void ImGuiInputTextCallbackData_ClearSelection( - ImGuiInputTextCallbackData* self); -CIMGUI_API bool ImGuiInputTextCallbackData_HasSelection( - ImGuiInputTextCallbackData* self); -CIMGUI_API ImGuiWindowClass* ImGuiWindowClass_ImGuiWindowClass(void); -CIMGUI_API void ImGuiWindowClass_destroy(ImGuiWindowClass* self); -CIMGUI_API ImGuiPayload* ImGuiPayload_ImGuiPayload(void); -CIMGUI_API void ImGuiPayload_destroy(ImGuiPayload* self); -CIMGUI_API void ImGuiPayload_Clear(ImGuiPayload* self); -CIMGUI_API bool ImGuiPayload_IsDataType(ImGuiPayload* self, const char* type); -CIMGUI_API bool ImGuiPayload_IsPreview(ImGuiPayload* self); -CIMGUI_API bool ImGuiPayload_IsDelivery(ImGuiPayload* self); -CIMGUI_API ImGuiOnceUponAFrame* ImGuiOnceUponAFrame_ImGuiOnceUponAFrame(void); -CIMGUI_API void ImGuiOnceUponAFrame_destroy(ImGuiOnceUponAFrame* self); -CIMGUI_API ImGuiTextFilter* ImGuiTextFilter_ImGuiTextFilter( - const char* default_filter); -CIMGUI_API void ImGuiTextFilter_destroy(ImGuiTextFilter* self); -CIMGUI_API bool ImGuiTextFilter_Draw(ImGuiTextFilter* self, - const char* label, - float width); -CIMGUI_API bool ImGuiTextFilter_PassFilter(ImGuiTextFilter* self, - const char* text, - const char* text_end); -CIMGUI_API void ImGuiTextFilter_Build(ImGuiTextFilter* self); -CIMGUI_API void ImGuiTextFilter_Clear(ImGuiTextFilter* self); -CIMGUI_API bool ImGuiTextFilter_IsActive(ImGuiTextFilter* self); -CIMGUI_API ImGuiTextRange* ImGuiTextRange_ImGuiTextRange_Nil(void); -CIMGUI_API void ImGuiTextRange_destroy(ImGuiTextRange* self); -CIMGUI_API ImGuiTextRange* ImGuiTextRange_ImGuiTextRange_Str(const char* _b, - const char* _e); -CIMGUI_API bool ImGuiTextRange_empty(ImGuiTextRange* self); -CIMGUI_API void ImGuiTextRange_split(ImGuiTextRange* self, - char separator, - ImVector_ImGuiTextRange* out); -CIMGUI_API ImGuiTextBuffer* ImGuiTextBuffer_ImGuiTextBuffer(void); -CIMGUI_API void ImGuiTextBuffer_destroy(ImGuiTextBuffer* self); -CIMGUI_API const char* ImGuiTextBuffer_begin(ImGuiTextBuffer* self); -CIMGUI_API const char* ImGuiTextBuffer_end(ImGuiTextBuffer* self); -CIMGUI_API int ImGuiTextBuffer_size(ImGuiTextBuffer* self); -CIMGUI_API bool ImGuiTextBuffer_empty(ImGuiTextBuffer* self); -CIMGUI_API void ImGuiTextBuffer_clear(ImGuiTextBuffer* self); -CIMGUI_API void ImGuiTextBuffer_reserve(ImGuiTextBuffer* self, int capacity); -CIMGUI_API const char* ImGuiTextBuffer_c_str(ImGuiTextBuffer* self); -CIMGUI_API void ImGuiTextBuffer_append(ImGuiTextBuffer* self, - const char* str, - const char* str_end); -CIMGUI_API void ImGuiTextBuffer_appendfv(ImGuiTextBuffer* self, - const char* fmt, - va_list args); -CIMGUI_API ImGuiStoragePair* ImGuiStoragePair_ImGuiStoragePair_Int(ImGuiID _key, - int _val); -CIMGUI_API void ImGuiStoragePair_destroy(ImGuiStoragePair* self); -CIMGUI_API ImGuiStoragePair* ImGuiStoragePair_ImGuiStoragePair_Float( - ImGuiID _key, - float _val); -CIMGUI_API ImGuiStoragePair* ImGuiStoragePair_ImGuiStoragePair_Ptr(ImGuiID _key, - void* _val); -CIMGUI_API void ImGuiStorage_Clear(ImGuiStorage* self); -CIMGUI_API int ImGuiStorage_GetInt(ImGuiStorage* self, - ImGuiID key, - int default_val); -CIMGUI_API void ImGuiStorage_SetInt(ImGuiStorage* self, ImGuiID key, int val); -CIMGUI_API bool ImGuiStorage_GetBool(ImGuiStorage* self, - ImGuiID key, - bool default_val); -CIMGUI_API void ImGuiStorage_SetBool(ImGuiStorage* self, ImGuiID key, bool val); -CIMGUI_API float ImGuiStorage_GetFloat(ImGuiStorage* self, - ImGuiID key, - float default_val); -CIMGUI_API void ImGuiStorage_SetFloat(ImGuiStorage* self, - ImGuiID key, - float val); -CIMGUI_API void* ImGuiStorage_GetVoidPtr(ImGuiStorage* self, ImGuiID key); -CIMGUI_API void ImGuiStorage_SetVoidPtr(ImGuiStorage* self, - ImGuiID key, - void* val); -CIMGUI_API int* ImGuiStorage_GetIntRef(ImGuiStorage* self, - ImGuiID key, - int default_val); -CIMGUI_API bool* ImGuiStorage_GetBoolRef(ImGuiStorage* self, - ImGuiID key, - bool default_val); -CIMGUI_API float* ImGuiStorage_GetFloatRef(ImGuiStorage* self, - ImGuiID key, - float default_val); -CIMGUI_API void** ImGuiStorage_GetVoidPtrRef(ImGuiStorage* self, - ImGuiID key, - void* default_val); -CIMGUI_API void ImGuiStorage_BuildSortByKey(ImGuiStorage* self); -CIMGUI_API void ImGuiStorage_SetAllInt(ImGuiStorage* self, int val); -CIMGUI_API ImGuiListClipper* ImGuiListClipper_ImGuiListClipper(void); -CIMGUI_API void ImGuiListClipper_destroy(ImGuiListClipper* self); -CIMGUI_API void ImGuiListClipper_Begin(ImGuiListClipper* self, - int items_count, - float items_height); -CIMGUI_API void ImGuiListClipper_End(ImGuiListClipper* self); -CIMGUI_API bool ImGuiListClipper_Step(ImGuiListClipper* self); -CIMGUI_API void ImGuiListClipper_IncludeItemByIndex(ImGuiListClipper* self, - int item_index); -CIMGUI_API void ImGuiListClipper_IncludeItemsByIndex(ImGuiListClipper* self, - int item_begin, - int item_end); -CIMGUI_API ImColor* ImColor_ImColor_Nil(void); -CIMGUI_API void ImColor_destroy(ImColor* self); -CIMGUI_API ImColor* ImColor_ImColor_Float(float r, float g, float b, float a); -CIMGUI_API ImColor* ImColor_ImColor_Vec4(const ImVec4 col); -CIMGUI_API ImColor* ImColor_ImColor_Int(int r, int g, int b, int a); -CIMGUI_API ImColor* ImColor_ImColor_U32(ImU32 rgba); -CIMGUI_API void ImColor_SetHSV(ImColor* self, - float h, - float s, - float v, - float a); -CIMGUI_API void ImColor_HSV(ImColor* pOut, float h, float s, float v, float a); -CIMGUI_API ImDrawCmd* ImDrawCmd_ImDrawCmd(void); -CIMGUI_API void ImDrawCmd_destroy(ImDrawCmd* self); -CIMGUI_API ImTextureID ImDrawCmd_GetTexID(ImDrawCmd* self); -CIMGUI_API ImDrawListSplitter* ImDrawListSplitter_ImDrawListSplitter(void); -CIMGUI_API void ImDrawListSplitter_destroy(ImDrawListSplitter* self); -CIMGUI_API void ImDrawListSplitter_Clear(ImDrawListSplitter* self); -CIMGUI_API void ImDrawListSplitter_ClearFreeMemory(ImDrawListSplitter* self); -CIMGUI_API void ImDrawListSplitter_Split(ImDrawListSplitter* self, - ImDrawList* draw_list, - int count); -CIMGUI_API void ImDrawListSplitter_Merge(ImDrawListSplitter* self, - ImDrawList* draw_list); -CIMGUI_API void ImDrawListSplitter_SetCurrentChannel(ImDrawListSplitter* self, - ImDrawList* draw_list, - int channel_idx); -CIMGUI_API ImDrawList* ImDrawList_ImDrawList(ImDrawListSharedData* shared_data); -CIMGUI_API void ImDrawList_destroy(ImDrawList* self); -CIMGUI_API void ImDrawList_PushClipRect(ImDrawList* self, - const ImVec2 clip_rect_min, - const ImVec2 clip_rect_max, - bool intersect_with_current_clip_rect); -CIMGUI_API void ImDrawList_PushClipRectFullScreen(ImDrawList* self); -CIMGUI_API void ImDrawList_PopClipRect(ImDrawList* self); -CIMGUI_API void ImDrawList_PushTextureID(ImDrawList* self, - ImTextureID texture_id); -CIMGUI_API void ImDrawList_PopTextureID(ImDrawList* self); -CIMGUI_API void ImDrawList_GetClipRectMin(ImVec2* pOut, ImDrawList* self); -CIMGUI_API void ImDrawList_GetClipRectMax(ImVec2* pOut, ImDrawList* self); -CIMGUI_API void ImDrawList_AddLine(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - ImU32 col, - float thickness); -CIMGUI_API void ImDrawList_AddRect(ImDrawList* self, - const ImVec2 p_min, - const ImVec2 p_max, - ImU32 col, - float rounding, - ImDrawFlags flags, - float thickness); -CIMGUI_API void ImDrawList_AddRectFilled(ImDrawList* self, - const ImVec2 p_min, - const ImVec2 p_max, - ImU32 col, - float rounding, - ImDrawFlags flags); -CIMGUI_API void ImDrawList_AddRectFilledMultiColor(ImDrawList* self, - const ImVec2 p_min, - const ImVec2 p_max, - ImU32 col_upr_left, - ImU32 col_upr_right, - ImU32 col_bot_right, - ImU32 col_bot_left); -CIMGUI_API void ImDrawList_AddQuad(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - ImU32 col, - float thickness); -CIMGUI_API void ImDrawList_AddQuadFilled(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - ImU32 col); -CIMGUI_API void ImDrawList_AddTriangle(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - ImU32 col, - float thickness); -CIMGUI_API void ImDrawList_AddTriangleFilled(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - ImU32 col); -CIMGUI_API void ImDrawList_AddCircle(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments, - float thickness); -CIMGUI_API void ImDrawList_AddCircleFilled(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments); -CIMGUI_API void ImDrawList_AddNgon(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments, - float thickness); -CIMGUI_API void ImDrawList_AddNgonFilled(ImDrawList* self, - const ImVec2 center, - float radius, - ImU32 col, - int num_segments); -CIMGUI_API void ImDrawList_AddEllipse(ImDrawList* self, - const ImVec2 center, - const ImVec2 radius, - ImU32 col, - float rot, - int num_segments, - float thickness); -CIMGUI_API void ImDrawList_AddEllipseFilled(ImDrawList* self, - const ImVec2 center, - const ImVec2 radius, - ImU32 col, - float rot, - int num_segments); -CIMGUI_API void ImDrawList_AddText_Vec2(ImDrawList* self, - const ImVec2 pos, - ImU32 col, - const char* text_begin, - const char* text_end); -CIMGUI_API void ImDrawList_AddText_FontPtr(ImDrawList* self, - const ImFont* font, - float font_size, - const ImVec2 pos, - ImU32 col, - const char* text_begin, - const char* text_end, - float wrap_width, - const ImVec4* cpu_fine_clip_rect); -CIMGUI_API void ImDrawList_AddBezierCubic(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - ImU32 col, - float thickness, - int num_segments); -CIMGUI_API void ImDrawList_AddBezierQuadratic(ImDrawList* self, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - ImU32 col, - float thickness, - int num_segments); -CIMGUI_API void ImDrawList_AddPolyline(ImDrawList* self, - const ImVec2* points, - int num_points, - ImU32 col, - ImDrawFlags flags, - float thickness); -CIMGUI_API void ImDrawList_AddConvexPolyFilled(ImDrawList* self, - const ImVec2* points, - int num_points, - ImU32 col); -CIMGUI_API void ImDrawList_AddConcavePolyFilled(ImDrawList* self, - const ImVec2* points, - int num_points, - ImU32 col); -CIMGUI_API void ImDrawList_AddImage(ImDrawList* self, - ImTextureID user_texture_id, - const ImVec2 p_min, - const ImVec2 p_max, - const ImVec2 uv_min, - const ImVec2 uv_max, - ImU32 col); -CIMGUI_API void ImDrawList_AddImageQuad(ImDrawList* self, - ImTextureID user_texture_id, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - const ImVec2 uv1, - const ImVec2 uv2, - const ImVec2 uv3, - const ImVec2 uv4, - ImU32 col); -CIMGUI_API void ImDrawList_AddImageRounded(ImDrawList* self, - ImTextureID user_texture_id, - const ImVec2 p_min, - const ImVec2 p_max, - const ImVec2 uv_min, - const ImVec2 uv_max, - ImU32 col, - float rounding, - ImDrawFlags flags); -CIMGUI_API void ImDrawList_PathClear(ImDrawList* self); -CIMGUI_API void ImDrawList_PathLineTo(ImDrawList* self, const ImVec2 pos); -CIMGUI_API void ImDrawList_PathLineToMergeDuplicate(ImDrawList* self, - const ImVec2 pos); -CIMGUI_API void ImDrawList_PathFillConvex(ImDrawList* self, ImU32 col); -CIMGUI_API void ImDrawList_PathFillConcave(ImDrawList* self, ImU32 col); -CIMGUI_API void ImDrawList_PathStroke(ImDrawList* self, - ImU32 col, - ImDrawFlags flags, - float thickness); -CIMGUI_API void ImDrawList_PathArcTo(ImDrawList* self, - const ImVec2 center, - float radius, - float a_min, - float a_max, - int num_segments); -CIMGUI_API void ImDrawList_PathArcToFast(ImDrawList* self, - const ImVec2 center, - float radius, - int a_min_of_12, - int a_max_of_12); -CIMGUI_API void ImDrawList_PathEllipticalArcTo(ImDrawList* self, - const ImVec2 center, - const ImVec2 radius, - float rot, - float a_min, - float a_max, - int num_segments); -CIMGUI_API void ImDrawList_PathBezierCubicCurveTo(ImDrawList* self, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - int num_segments); -CIMGUI_API void ImDrawList_PathBezierQuadraticCurveTo(ImDrawList* self, - const ImVec2 p2, - const ImVec2 p3, - int num_segments); -CIMGUI_API void ImDrawList_PathRect(ImDrawList* self, - const ImVec2 rect_min, - const ImVec2 rect_max, - float rounding, - ImDrawFlags flags); -CIMGUI_API void ImDrawList_AddCallback(ImDrawList* self, - ImDrawCallback callback, - void* callback_data); -CIMGUI_API void ImDrawList_AddDrawCmd(ImDrawList* self); -CIMGUI_API ImDrawList* ImDrawList_CloneOutput(ImDrawList* self); -CIMGUI_API void ImDrawList_ChannelsSplit(ImDrawList* self, int count); -CIMGUI_API void ImDrawList_ChannelsMerge(ImDrawList* self); -CIMGUI_API void ImDrawList_ChannelsSetCurrent(ImDrawList* self, int n); -CIMGUI_API void ImDrawList_PrimReserve(ImDrawList* self, - int idx_count, - int vtx_count); -CIMGUI_API void ImDrawList_PrimUnreserve(ImDrawList* self, - int idx_count, - int vtx_count); -CIMGUI_API void ImDrawList_PrimRect(ImDrawList* self, - const ImVec2 a, - const ImVec2 b, - ImU32 col); -CIMGUI_API void ImDrawList_PrimRectUV(ImDrawList* self, - const ImVec2 a, - const ImVec2 b, - const ImVec2 uv_a, - const ImVec2 uv_b, - ImU32 col); -CIMGUI_API void ImDrawList_PrimQuadUV(ImDrawList* self, - const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 d, - const ImVec2 uv_a, - const ImVec2 uv_b, - const ImVec2 uv_c, - const ImVec2 uv_d, - ImU32 col); -CIMGUI_API void ImDrawList_PrimWriteVtx(ImDrawList* self, - const ImVec2 pos, - const ImVec2 uv, - ImU32 col); -CIMGUI_API void ImDrawList_PrimWriteIdx(ImDrawList* self, ImDrawIdx idx); -CIMGUI_API void ImDrawList_PrimVtx(ImDrawList* self, - const ImVec2 pos, - const ImVec2 uv, - ImU32 col); -CIMGUI_API void ImDrawList__ResetForNewFrame(ImDrawList* self); -CIMGUI_API void ImDrawList__ClearFreeMemory(ImDrawList* self); -CIMGUI_API void ImDrawList__PopUnusedDrawCmd(ImDrawList* self); -CIMGUI_API void ImDrawList__TryMergeDrawCmds(ImDrawList* self); -CIMGUI_API void ImDrawList__OnChangedClipRect(ImDrawList* self); -CIMGUI_API void ImDrawList__OnChangedTextureID(ImDrawList* self); -CIMGUI_API void ImDrawList__OnChangedVtxOffset(ImDrawList* self); -CIMGUI_API int ImDrawList__CalcCircleAutoSegmentCount(ImDrawList* self, - float radius); -CIMGUI_API void ImDrawList__PathArcToFastEx(ImDrawList* self, - const ImVec2 center, - float radius, - int a_min_sample, - int a_max_sample, - int a_step); -CIMGUI_API void ImDrawList__PathArcToN(ImDrawList* self, - const ImVec2 center, - float radius, - float a_min, - float a_max, - int num_segments); -CIMGUI_API ImDrawData* ImDrawData_ImDrawData(void); -CIMGUI_API void ImDrawData_destroy(ImDrawData* self); -CIMGUI_API void ImDrawData_Clear(ImDrawData* self); -CIMGUI_API void ImDrawData_AddDrawList(ImDrawData* self, ImDrawList* draw_list); -CIMGUI_API void ImDrawData_DeIndexAllBuffers(ImDrawData* self); -CIMGUI_API void ImDrawData_ScaleClipRects(ImDrawData* self, - const ImVec2 fb_scale); -CIMGUI_API ImFontConfig* ImFontConfig_ImFontConfig(void); -CIMGUI_API void ImFontConfig_destroy(ImFontConfig* self); -CIMGUI_API ImFontGlyphRangesBuilder* -ImFontGlyphRangesBuilder_ImFontGlyphRangesBuilder(void); -CIMGUI_API void ImFontGlyphRangesBuilder_destroy( - ImFontGlyphRangesBuilder* self); -CIMGUI_API void ImFontGlyphRangesBuilder_Clear(ImFontGlyphRangesBuilder* self); -CIMGUI_API bool ImFontGlyphRangesBuilder_GetBit(ImFontGlyphRangesBuilder* self, - size_t n); -CIMGUI_API void ImFontGlyphRangesBuilder_SetBit(ImFontGlyphRangesBuilder* self, - size_t n); -CIMGUI_API void ImFontGlyphRangesBuilder_AddChar(ImFontGlyphRangesBuilder* self, - ImWchar c); -CIMGUI_API void ImFontGlyphRangesBuilder_AddText(ImFontGlyphRangesBuilder* self, - const char* text, - const char* text_end); -CIMGUI_API void ImFontGlyphRangesBuilder_AddRanges( - ImFontGlyphRangesBuilder* self, - const ImWchar* ranges); -CIMGUI_API void ImFontGlyphRangesBuilder_BuildRanges( - ImFontGlyphRangesBuilder* self, - ImVector_ImWchar* out_ranges); -CIMGUI_API ImFontAtlasCustomRect* ImFontAtlasCustomRect_ImFontAtlasCustomRect( - void); -CIMGUI_API void ImFontAtlasCustomRect_destroy(ImFontAtlasCustomRect* self); -CIMGUI_API bool ImFontAtlasCustomRect_IsPacked(ImFontAtlasCustomRect* self); -CIMGUI_API ImFontAtlas* ImFontAtlas_ImFontAtlas(void); -CIMGUI_API void ImFontAtlas_destroy(ImFontAtlas* self); -CIMGUI_API ImFont* ImFontAtlas_AddFont(ImFontAtlas* self, - const ImFontConfig* font_cfg); -CIMGUI_API ImFont* ImFontAtlas_AddFontDefault(ImFontAtlas* self, - const ImFontConfig* font_cfg); -CIMGUI_API ImFont* ImFontAtlas_AddFontFromFileTTF(ImFontAtlas* self, - const char* filename, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges); -CIMGUI_API ImFont* ImFontAtlas_AddFontFromMemoryTTF( - ImFontAtlas* self, - void* font_data, - int font_data_size, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges); -CIMGUI_API ImFont* ImFontAtlas_AddFontFromMemoryCompressedTTF( - ImFontAtlas* self, - const void* compressed_font_data, - int compressed_font_data_size, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges); -CIMGUI_API ImFont* ImFontAtlas_AddFontFromMemoryCompressedBase85TTF( - ImFontAtlas* self, - const char* compressed_font_data_base85, - float size_pixels, - const ImFontConfig* font_cfg, - const ImWchar* glyph_ranges); -CIMGUI_API void ImFontAtlas_ClearInputData(ImFontAtlas* self); -CIMGUI_API void ImFontAtlas_ClearTexData(ImFontAtlas* self); -CIMGUI_API void ImFontAtlas_ClearFonts(ImFontAtlas* self); -CIMGUI_API void ImFontAtlas_Clear(ImFontAtlas* self); -CIMGUI_API bool ImFontAtlas_Build(ImFontAtlas* self); -CIMGUI_API void ImFontAtlas_GetTexDataAsAlpha8(ImFontAtlas* self, - unsigned char** out_pixels, - int* out_width, - int* out_height, - int* out_bytes_per_pixel); -CIMGUI_API void ImFontAtlas_GetTexDataAsRGBA32(ImFontAtlas* self, - unsigned char** out_pixels, - int* out_width, - int* out_height, - int* out_bytes_per_pixel); -CIMGUI_API bool ImFontAtlas_IsBuilt(ImFontAtlas* self); -CIMGUI_API void ImFontAtlas_SetTexID(ImFontAtlas* self, ImTextureID id); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesDefault(ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesGreek(ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesKorean(ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesJapanese(ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesChineseFull( - ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesChineseSimplifiedCommon( - ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesCyrillic(ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesThai(ImFontAtlas* self); -CIMGUI_API const ImWchar* ImFontAtlas_GetGlyphRangesVietnamese( - ImFontAtlas* self); -CIMGUI_API int ImFontAtlas_AddCustomRectRegular(ImFontAtlas* self, - int width, - int height); -CIMGUI_API int ImFontAtlas_AddCustomRectFontGlyph(ImFontAtlas* self, - ImFont* font, - ImWchar id, - int width, - int height, - float advance_x, - const ImVec2 offset); -CIMGUI_API ImFontAtlasCustomRect* ImFontAtlas_GetCustomRectByIndex( - ImFontAtlas* self, - int index); -CIMGUI_API void ImFontAtlas_CalcCustomRectUV(ImFontAtlas* self, - const ImFontAtlasCustomRect* rect, - ImVec2* out_uv_min, - ImVec2* out_uv_max); -CIMGUI_API bool ImFontAtlas_GetMouseCursorTexData(ImFontAtlas* self, - ImGuiMouseCursor cursor, - ImVec2* out_offset, - ImVec2* out_size, - ImVec2 out_uv_border[2], - ImVec2 out_uv_fill[2]); -CIMGUI_API ImFont* ImFont_ImFont(void); -CIMGUI_API void ImFont_destroy(ImFont* self); -CIMGUI_API const ImFontGlyph* ImFont_FindGlyph(ImFont* self, ImWchar c); -CIMGUI_API const ImFontGlyph* ImFont_FindGlyphNoFallback(ImFont* self, - ImWchar c); -CIMGUI_API float ImFont_GetCharAdvance(ImFont* self, ImWchar c); -CIMGUI_API bool ImFont_IsLoaded(ImFont* self); -CIMGUI_API const char* ImFont_GetDebugName(ImFont* self); -CIMGUI_API void ImFont_CalcTextSizeA(ImVec2* pOut, - ImFont* self, - float size, - float max_width, - float wrap_width, - const char* text_begin, - const char* text_end, - const char** remaining); -CIMGUI_API const char* ImFont_CalcWordWrapPositionA(ImFont* self, - float scale, - const char* text, - const char* text_end, - float wrap_width); -CIMGUI_API void ImFont_RenderChar(ImFont* self, - ImDrawList* draw_list, - float size, - const ImVec2 pos, - ImU32 col, - ImWchar c); -CIMGUI_API void ImFont_RenderText(ImFont* self, - ImDrawList* draw_list, - float size, - const ImVec2 pos, - ImU32 col, - const ImVec4 clip_rect, - const char* text_begin, - const char* text_end, - float wrap_width, - bool cpu_fine_clip); -CIMGUI_API void ImFont_BuildLookupTable(ImFont* self); -CIMGUI_API void ImFont_ClearOutputData(ImFont* self); -CIMGUI_API void ImFont_GrowIndex(ImFont* self, int new_size); -CIMGUI_API void ImFont_AddGlyph(ImFont* self, - const ImFontConfig* src_cfg, - ImWchar c, - float x0, - float y0, - float x1, - float y1, - float u0, - float v0, - float u1, - float v1, - float advance_x); -CIMGUI_API void ImFont_AddRemapChar(ImFont* self, - ImWchar dst, - ImWchar src, - bool overwrite_dst); -CIMGUI_API void ImFont_SetGlyphVisible(ImFont* self, ImWchar c, bool visible); -CIMGUI_API bool ImFont_IsGlyphRangeUnused(ImFont* self, - unsigned int c_begin, - unsigned int c_last); -CIMGUI_API ImGuiViewport* ImGuiViewport_ImGuiViewport(void); -CIMGUI_API void ImGuiViewport_destroy(ImGuiViewport* self); -CIMGUI_API void ImGuiViewport_GetCenter(ImVec2* pOut, ImGuiViewport* self); -CIMGUI_API void ImGuiViewport_GetWorkCenter(ImVec2* pOut, ImGuiViewport* self); -CIMGUI_API ImGuiPlatformIO* ImGuiPlatformIO_ImGuiPlatformIO(void); -CIMGUI_API void ImGuiPlatformIO_destroy(ImGuiPlatformIO* self); -CIMGUI_API ImGuiPlatformMonitor* ImGuiPlatformMonitor_ImGuiPlatformMonitor( - void); -CIMGUI_API void ImGuiPlatformMonitor_destroy(ImGuiPlatformMonitor* self); -CIMGUI_API ImGuiPlatformImeData* ImGuiPlatformImeData_ImGuiPlatformImeData( - void); -CIMGUI_API void ImGuiPlatformImeData_destroy(ImGuiPlatformImeData* self); -CIMGUI_API ImGuiID igImHashData(const void* data, - size_t data_size, - ImGuiID seed); -CIMGUI_API ImGuiID igImHashStr(const char* data, - size_t data_size, - ImGuiID seed); -CIMGUI_API void igImQsort(void* base, - size_t count, - size_t size_of_element, - int (*compare_func)(void const*, void const*)); -CIMGUI_API ImU32 igImAlphaBlendColors(ImU32 col_a, ImU32 col_b); -CIMGUI_API bool igImIsPowerOfTwo_Int(int v); -CIMGUI_API bool igImIsPowerOfTwo_U64(ImU64 v); -CIMGUI_API int igImUpperPowerOfTwo(int v); -CIMGUI_API int igImStricmp(const char* str1, const char* str2); -CIMGUI_API int igImStrnicmp(const char* str1, const char* str2, size_t count); -CIMGUI_API void igImStrncpy(char* dst, const char* src, size_t count); -CIMGUI_API char* igImStrdup(const char* str); -CIMGUI_API char* igImStrdupcpy(char* dst, size_t* p_dst_size, const char* str); -CIMGUI_API const char* igImStrchrRange(const char* str_begin, - const char* str_end, - char c); -CIMGUI_API const char* igImStreolRange(const char* str, const char* str_end); -CIMGUI_API const char* igImStristr(const char* haystack, - const char* haystack_end, - const char* needle, - const char* needle_end); -CIMGUI_API void igImStrTrimBlanks(char* str); -CIMGUI_API const char* igImStrSkipBlank(const char* str); -CIMGUI_API int igImStrlenW(const ImWchar* str); -CIMGUI_API const ImWchar* igImStrbolW(const ImWchar* buf_mid_line, - const ImWchar* buf_begin); -CIMGUI_API char igImToUpper(char c); -CIMGUI_API bool igImCharIsBlankA(char c); -CIMGUI_API bool igImCharIsBlankW(unsigned int c); -CIMGUI_API int igImFormatString(char* buf, - size_t buf_size, - const char* fmt, - ...); -CIMGUI_API int igImFormatStringV(char* buf, - size_t buf_size, - const char* fmt, - va_list args); -CIMGUI_API void igImFormatStringToTempBuffer(const char** out_buf, - const char** out_buf_end, - const char* fmt, - ...); -CIMGUI_API void igImFormatStringToTempBufferV(const char** out_buf, - const char** out_buf_end, - const char* fmt, - va_list args); -CIMGUI_API const char* igImParseFormatFindStart(const char* format); -CIMGUI_API const char* igImParseFormatFindEnd(const char* format); -CIMGUI_API const char* igImParseFormatTrimDecorations(const char* format, - char* buf, - size_t buf_size); -CIMGUI_API void igImParseFormatSanitizeForPrinting(const char* fmt_in, - char* fmt_out, - size_t fmt_out_size); -CIMGUI_API const char* igImParseFormatSanitizeForScanning(const char* fmt_in, - char* fmt_out, - size_t fmt_out_size); -CIMGUI_API int igImParseFormatPrecision(const char* format, int default_value); -CIMGUI_API const char* igImTextCharToUtf8(char out_buf[5], unsigned int c); -CIMGUI_API int igImTextStrToUtf8(char* out_buf, - int out_buf_size, - const ImWchar* in_text, - const ImWchar* in_text_end); -CIMGUI_API int igImTextCharFromUtf8(unsigned int* out_char, - const char* in_text, - const char* in_text_end); -CIMGUI_API int igImTextStrFromUtf8(ImWchar* out_buf, - int out_buf_size, - const char* in_text, - const char* in_text_end, - const char** in_remaining); -CIMGUI_API int igImTextCountCharsFromUtf8(const char* in_text, - const char* in_text_end); -CIMGUI_API int igImTextCountUtf8BytesFromChar(const char* in_text, - const char* in_text_end); -CIMGUI_API int igImTextCountUtf8BytesFromStr(const ImWchar* in_text, - const ImWchar* in_text_end); -CIMGUI_API const char* igImTextFindPreviousUtf8Codepoint( - const char* in_text_start, - const char* in_text_curr); -CIMGUI_API int igImTextCountLines(const char* in_text, const char* in_text_end); -CIMGUI_API ImFileHandle igImFileOpen(const char* filename, const char* mode); -CIMGUI_API bool igImFileClose(ImFileHandle file); -CIMGUI_API ImU64 igImFileGetSize(ImFileHandle file); -CIMGUI_API ImU64 igImFileRead(void* data, - ImU64 size, - ImU64 count, - ImFileHandle file); -CIMGUI_API ImU64 igImFileWrite(const void* data, - ImU64 size, - ImU64 count, - ImFileHandle file); -CIMGUI_API void* igImFileLoadToMemory(const char* filename, - const char* mode, - size_t* out_file_size, - int padding_bytes); -CIMGUI_API float igImPow_Float(float x, float y); -CIMGUI_API double igImPow_double(double x, double y); -CIMGUI_API float igImLog_Float(float x); -CIMGUI_API double igImLog_double(double x); -CIMGUI_API int igImAbs_Int(int x); -CIMGUI_API float igImAbs_Float(float x); -CIMGUI_API double igImAbs_double(double x); -CIMGUI_API float igImSign_Float(float x); -CIMGUI_API double igImSign_double(double x); -CIMGUI_API float igImRsqrt_Float(float x); -CIMGUI_API double igImRsqrt_double(double x); -CIMGUI_API void igImMin(ImVec2* pOut, const ImVec2 lhs, const ImVec2 rhs); -CIMGUI_API void igImMax(ImVec2* pOut, const ImVec2 lhs, const ImVec2 rhs); -CIMGUI_API void igImClamp(ImVec2* pOut, - const ImVec2 v, - const ImVec2 mn, - ImVec2 mx); -CIMGUI_API void igImLerp_Vec2Float(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - float t); -CIMGUI_API void igImLerp_Vec2Vec2(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - const ImVec2 t); -CIMGUI_API void igImLerp_Vec4(ImVec4* pOut, - const ImVec4 a, - const ImVec4 b, - float t); -CIMGUI_API float igImSaturate(float f); -CIMGUI_API float igImLengthSqr_Vec2(const ImVec2 lhs); -CIMGUI_API float igImLengthSqr_Vec4(const ImVec4 lhs); -CIMGUI_API float igImInvLength(const ImVec2 lhs, float fail_value); -CIMGUI_API float igImTrunc_Float(float f); -CIMGUI_API void igImTrunc_Vec2(ImVec2* pOut, const ImVec2 v); -CIMGUI_API float igImFloor_Float(float f); -CIMGUI_API void igImFloor_Vec2(ImVec2* pOut, const ImVec2 v); -CIMGUI_API int igImModPositive(int a, int b); -CIMGUI_API float igImDot(const ImVec2 a, const ImVec2 b); -CIMGUI_API void igImRotate(ImVec2* pOut, - const ImVec2 v, - float cos_a, - float sin_a); -CIMGUI_API float igImLinearSweep(float current, float target, float speed); -CIMGUI_API void igImMul(ImVec2* pOut, const ImVec2 lhs, const ImVec2 rhs); -CIMGUI_API bool igImIsFloatAboveGuaranteedIntegerPrecision(float f); -CIMGUI_API float igImExponentialMovingAverage(float avg, float sample, int n); -CIMGUI_API void igImBezierCubicCalc(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - float t); -CIMGUI_API void igImBezierCubicClosestPoint(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - const ImVec2 p, - int num_segments); -CIMGUI_API void igImBezierCubicClosestPointCasteljau(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - const ImVec2 p4, - const ImVec2 p, - float tess_tol); -CIMGUI_API void igImBezierQuadraticCalc(ImVec2* pOut, - const ImVec2 p1, - const ImVec2 p2, - const ImVec2 p3, - float t); -CIMGUI_API void igImLineClosestPoint(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - const ImVec2 p); -CIMGUI_API bool igImTriangleContainsPoint(const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 p); -CIMGUI_API void igImTriangleClosestPoint(ImVec2* pOut, - const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 p); -CIMGUI_API void igImTriangleBarycentricCoords(const ImVec2 a, - const ImVec2 b, - const ImVec2 c, - const ImVec2 p, - float* out_u, - float* out_v, - float* out_w); -CIMGUI_API float igImTriangleArea(const ImVec2 a, - const ImVec2 b, - const ImVec2 c); -CIMGUI_API bool igImTriangleIsClockwise(const ImVec2 a, - const ImVec2 b, - const ImVec2 c); -CIMGUI_API ImVec1* ImVec1_ImVec1_Nil(void); -CIMGUI_API void ImVec1_destroy(ImVec1* self); -CIMGUI_API ImVec1* ImVec1_ImVec1_Float(float _x); -CIMGUI_API ImVec2ih* ImVec2ih_ImVec2ih_Nil(void); -CIMGUI_API void ImVec2ih_destroy(ImVec2ih* self); -CIMGUI_API ImVec2ih* ImVec2ih_ImVec2ih_short(short _x, short _y); -CIMGUI_API ImVec2ih* ImVec2ih_ImVec2ih_Vec2(const ImVec2 rhs); -CIMGUI_API ImRect* ImRect_ImRect_Nil(void); -CIMGUI_API void ImRect_destroy(ImRect* self); -CIMGUI_API ImRect* ImRect_ImRect_Vec2(const ImVec2 min, const ImVec2 max); -CIMGUI_API ImRect* ImRect_ImRect_Vec4(const ImVec4 v); -CIMGUI_API ImRect* ImRect_ImRect_Float(float x1, float y1, float x2, float y2); -CIMGUI_API void ImRect_GetCenter(ImVec2* pOut, ImRect* self); -CIMGUI_API void ImRect_GetSize(ImVec2* pOut, ImRect* self); -CIMGUI_API float ImRect_GetWidth(ImRect* self); -CIMGUI_API float ImRect_GetHeight(ImRect* self); -CIMGUI_API float ImRect_GetArea(ImRect* self); -CIMGUI_API void ImRect_GetTL(ImVec2* pOut, ImRect* self); -CIMGUI_API void ImRect_GetTR(ImVec2* pOut, ImRect* self); -CIMGUI_API void ImRect_GetBL(ImVec2* pOut, ImRect* self); -CIMGUI_API void ImRect_GetBR(ImVec2* pOut, ImRect* self); -CIMGUI_API bool ImRect_Contains_Vec2(ImRect* self, const ImVec2 p); -CIMGUI_API bool ImRect_Contains_Rect(ImRect* self, const ImRect r); -CIMGUI_API bool ImRect_ContainsWithPad(ImRect* self, - const ImVec2 p, - const ImVec2 pad); -CIMGUI_API bool ImRect_Overlaps(ImRect* self, const ImRect r); -CIMGUI_API void ImRect_Add_Vec2(ImRect* self, const ImVec2 p); -CIMGUI_API void ImRect_Add_Rect(ImRect* self, const ImRect r); -CIMGUI_API void ImRect_Expand_Float(ImRect* self, const float amount); -CIMGUI_API void ImRect_Expand_Vec2(ImRect* self, const ImVec2 amount); -CIMGUI_API void ImRect_Translate(ImRect* self, const ImVec2 d); -CIMGUI_API void ImRect_TranslateX(ImRect* self, float dx); -CIMGUI_API void ImRect_TranslateY(ImRect* self, float dy); -CIMGUI_API void ImRect_ClipWith(ImRect* self, const ImRect r); -CIMGUI_API void ImRect_ClipWithFull(ImRect* self, const ImRect r); -CIMGUI_API void ImRect_Floor(ImRect* self); -CIMGUI_API bool ImRect_IsInverted(ImRect* self); -CIMGUI_API void ImRect_ToVec4(ImVec4* pOut, ImRect* self); -CIMGUI_API size_t igImBitArrayGetStorageSizeInBytes(int bitcount); -CIMGUI_API void igImBitArrayClearAllBits(ImU32* arr, int bitcount); -CIMGUI_API bool igImBitArrayTestBit(const ImU32* arr, int n); -CIMGUI_API void igImBitArrayClearBit(ImU32* arr, int n); -CIMGUI_API void igImBitArraySetBit(ImU32* arr, int n); -CIMGUI_API void igImBitArraySetBitRange(ImU32* arr, int n, int n2); -CIMGUI_API void ImBitVector_Create(ImBitVector* self, int sz); -CIMGUI_API void ImBitVector_Clear(ImBitVector* self); -CIMGUI_API bool ImBitVector_TestBit(ImBitVector* self, int n); -CIMGUI_API void ImBitVector_SetBit(ImBitVector* self, int n); -CIMGUI_API void ImBitVector_ClearBit(ImBitVector* self, int n); -CIMGUI_API void ImGuiTextIndex_clear(ImGuiTextIndex* self); -CIMGUI_API int ImGuiTextIndex_size(ImGuiTextIndex* self); -CIMGUI_API const char* ImGuiTextIndex_get_line_begin(ImGuiTextIndex* self, - const char* base, - int n); -CIMGUI_API const char* ImGuiTextIndex_get_line_end(ImGuiTextIndex* self, - const char* base, - int n); -CIMGUI_API void ImGuiTextIndex_append(ImGuiTextIndex* self, - const char* base, - int old_size, - int new_size); -CIMGUI_API ImDrawListSharedData* ImDrawListSharedData_ImDrawListSharedData( - void); -CIMGUI_API void ImDrawListSharedData_destroy(ImDrawListSharedData* self); -CIMGUI_API void ImDrawListSharedData_SetCircleTessellationMaxError( - ImDrawListSharedData* self, - float max_error); -CIMGUI_API ImDrawDataBuilder* ImDrawDataBuilder_ImDrawDataBuilder(void); -CIMGUI_API void ImDrawDataBuilder_destroy(ImDrawDataBuilder* self); -CIMGUI_API ImGuiStyleMod* ImGuiStyleMod_ImGuiStyleMod_Int(ImGuiStyleVar idx, - int v); -CIMGUI_API void ImGuiStyleMod_destroy(ImGuiStyleMod* self); -CIMGUI_API ImGuiStyleMod* ImGuiStyleMod_ImGuiStyleMod_Float(ImGuiStyleVar idx, - float v); -CIMGUI_API ImGuiStyleMod* ImGuiStyleMod_ImGuiStyleMod_Vec2(ImGuiStyleVar idx, - ImVec2 v); -CIMGUI_API ImGuiComboPreviewData* ImGuiComboPreviewData_ImGuiComboPreviewData( - void); -CIMGUI_API void ImGuiComboPreviewData_destroy(ImGuiComboPreviewData* self); -CIMGUI_API ImGuiMenuColumns* ImGuiMenuColumns_ImGuiMenuColumns(void); -CIMGUI_API void ImGuiMenuColumns_destroy(ImGuiMenuColumns* self); -CIMGUI_API void ImGuiMenuColumns_Update(ImGuiMenuColumns* self, - float spacing, - bool window_reappearing); -CIMGUI_API float ImGuiMenuColumns_DeclColumns(ImGuiMenuColumns* self, - float w_icon, - float w_label, - float w_shortcut, - float w_mark); -CIMGUI_API void ImGuiMenuColumns_CalcNextTotalWidth(ImGuiMenuColumns* self, - bool update_offsets); -CIMGUI_API ImGuiInputTextDeactivatedState* -ImGuiInputTextDeactivatedState_ImGuiInputTextDeactivatedState(void); -CIMGUI_API void ImGuiInputTextDeactivatedState_destroy( - ImGuiInputTextDeactivatedState* self); -CIMGUI_API void ImGuiInputTextDeactivatedState_ClearFreeMemory( - ImGuiInputTextDeactivatedState* self); -CIMGUI_API ImGuiInputTextState* ImGuiInputTextState_ImGuiInputTextState(void); -CIMGUI_API void ImGuiInputTextState_destroy(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_ClearText(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_ClearFreeMemory(ImGuiInputTextState* self); -CIMGUI_API int ImGuiInputTextState_GetUndoAvailCount(ImGuiInputTextState* self); -CIMGUI_API int ImGuiInputTextState_GetRedoAvailCount(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_OnKeyPressed(ImGuiInputTextState* self, - int key); -CIMGUI_API void ImGuiInputTextState_CursorAnimReset(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_CursorClamp(ImGuiInputTextState* self); -CIMGUI_API bool ImGuiInputTextState_HasSelection(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_ClearSelection(ImGuiInputTextState* self); -CIMGUI_API int ImGuiInputTextState_GetCursorPos(ImGuiInputTextState* self); -CIMGUI_API int ImGuiInputTextState_GetSelectionStart(ImGuiInputTextState* self); -CIMGUI_API int ImGuiInputTextState_GetSelectionEnd(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_SelectAll(ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_ReloadUserBufAndSelectAll( - ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_ReloadUserBufAndKeepSelection( - ImGuiInputTextState* self); -CIMGUI_API void ImGuiInputTextState_ReloadUserBufAndMoveToEnd( - ImGuiInputTextState* self); -CIMGUI_API ImGuiNextWindowData* ImGuiNextWindowData_ImGuiNextWindowData(void); -CIMGUI_API void ImGuiNextWindowData_destroy(ImGuiNextWindowData* self); -CIMGUI_API void ImGuiNextWindowData_ClearFlags(ImGuiNextWindowData* self); -CIMGUI_API ImGuiNextItemData* ImGuiNextItemData_ImGuiNextItemData(void); -CIMGUI_API void ImGuiNextItemData_destroy(ImGuiNextItemData* self); -CIMGUI_API void ImGuiNextItemData_ClearFlags(ImGuiNextItemData* self); -CIMGUI_API ImGuiLastItemData* ImGuiLastItemData_ImGuiLastItemData(void); -CIMGUI_API void ImGuiLastItemData_destroy(ImGuiLastItemData* self); -CIMGUI_API ImGuiStackSizes* ImGuiStackSizes_ImGuiStackSizes(void); -CIMGUI_API void ImGuiStackSizes_destroy(ImGuiStackSizes* self); -CIMGUI_API void ImGuiStackSizes_SetToContextState(ImGuiStackSizes* self, - ImGuiContext* ctx); -CIMGUI_API void ImGuiStackSizes_CompareWithContextState(ImGuiStackSizes* self, - ImGuiContext* ctx); -CIMGUI_API ImGuiPtrOrIndex* ImGuiPtrOrIndex_ImGuiPtrOrIndex_Ptr(void* ptr); -CIMGUI_API void ImGuiPtrOrIndex_destroy(ImGuiPtrOrIndex* self); -CIMGUI_API ImGuiPtrOrIndex* ImGuiPtrOrIndex_ImGuiPtrOrIndex_Int(int index); -CIMGUI_API void* ImGuiDataVarInfo_GetVarPtr(ImGuiDataVarInfo* self, - void* parent); -CIMGUI_API ImGuiPopupData* ImGuiPopupData_ImGuiPopupData(void); -CIMGUI_API void ImGuiPopupData_destroy(ImGuiPopupData* self); -CIMGUI_API ImGuiInputEvent* ImGuiInputEvent_ImGuiInputEvent(void); -CIMGUI_API void ImGuiInputEvent_destroy(ImGuiInputEvent* self); -CIMGUI_API ImGuiKeyRoutingData* ImGuiKeyRoutingData_ImGuiKeyRoutingData(void); -CIMGUI_API void ImGuiKeyRoutingData_destroy(ImGuiKeyRoutingData* self); -CIMGUI_API ImGuiKeyRoutingTable* ImGuiKeyRoutingTable_ImGuiKeyRoutingTable( - void); -CIMGUI_API void ImGuiKeyRoutingTable_destroy(ImGuiKeyRoutingTable* self); -CIMGUI_API void ImGuiKeyRoutingTable_Clear(ImGuiKeyRoutingTable* self); -CIMGUI_API ImGuiKeyOwnerData* ImGuiKeyOwnerData_ImGuiKeyOwnerData(void); -CIMGUI_API void ImGuiKeyOwnerData_destroy(ImGuiKeyOwnerData* self); -CIMGUI_API ImGuiListClipperRange ImGuiListClipperRange_FromIndices(int min, - int max); -CIMGUI_API ImGuiListClipperRange -ImGuiListClipperRange_FromPositions(float y1, - float y2, - int off_min, - int off_max); -CIMGUI_API ImGuiListClipperData* ImGuiListClipperData_ImGuiListClipperData( - void); -CIMGUI_API void ImGuiListClipperData_destroy(ImGuiListClipperData* self); -CIMGUI_API void ImGuiListClipperData_Reset(ImGuiListClipperData* self, - ImGuiListClipper* clipper); -CIMGUI_API ImGuiNavItemData* ImGuiNavItemData_ImGuiNavItemData(void); -CIMGUI_API void ImGuiNavItemData_destroy(ImGuiNavItemData* self); -CIMGUI_API void ImGuiNavItemData_Clear(ImGuiNavItemData* self); -CIMGUI_API ImGuiTypingSelectState* -ImGuiTypingSelectState_ImGuiTypingSelectState(void); -CIMGUI_API void ImGuiTypingSelectState_destroy(ImGuiTypingSelectState* self); -CIMGUI_API void ImGuiTypingSelectState_Clear(ImGuiTypingSelectState* self); -CIMGUI_API ImGuiOldColumnData* ImGuiOldColumnData_ImGuiOldColumnData(void); -CIMGUI_API void ImGuiOldColumnData_destroy(ImGuiOldColumnData* self); -CIMGUI_API ImGuiOldColumns* ImGuiOldColumns_ImGuiOldColumns(void); -CIMGUI_API void ImGuiOldColumns_destroy(ImGuiOldColumns* self); -CIMGUI_API ImGuiDockNode* ImGuiDockNode_ImGuiDockNode(ImGuiID id); -CIMGUI_API void ImGuiDockNode_destroy(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsRootNode(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsDockSpace(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsFloatingNode(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsCentralNode(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsHiddenTabBar(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsNoTabBar(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsSplitNode(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsLeafNode(ImGuiDockNode* self); -CIMGUI_API bool ImGuiDockNode_IsEmpty(ImGuiDockNode* self); -CIMGUI_API void ImGuiDockNode_Rect(ImRect* pOut, ImGuiDockNode* self); -CIMGUI_API void ImGuiDockNode_SetLocalFlags(ImGuiDockNode* self, - ImGuiDockNodeFlags flags); -CIMGUI_API void ImGuiDockNode_UpdateMergedFlags(ImGuiDockNode* self); -CIMGUI_API ImGuiDockContext* ImGuiDockContext_ImGuiDockContext(void); -CIMGUI_API void ImGuiDockContext_destroy(ImGuiDockContext* self); -CIMGUI_API ImGuiViewportP* ImGuiViewportP_ImGuiViewportP(void); -CIMGUI_API void ImGuiViewportP_destroy(ImGuiViewportP* self); -CIMGUI_API void ImGuiViewportP_ClearRequestFlags(ImGuiViewportP* self); -CIMGUI_API void ImGuiViewportP_CalcWorkRectPos(ImVec2* pOut, - ImGuiViewportP* self, - const ImVec2 off_min); -CIMGUI_API void ImGuiViewportP_CalcWorkRectSize(ImVec2* pOut, - ImGuiViewportP* self, - const ImVec2 off_min, - const ImVec2 off_max); -CIMGUI_API void ImGuiViewportP_UpdateWorkRect(ImGuiViewportP* self); -CIMGUI_API void ImGuiViewportP_GetMainRect(ImRect* pOut, ImGuiViewportP* self); -CIMGUI_API void ImGuiViewportP_GetWorkRect(ImRect* pOut, ImGuiViewportP* self); -CIMGUI_API void ImGuiViewportP_GetBuildWorkRect(ImRect* pOut, - ImGuiViewportP* self); -CIMGUI_API ImGuiWindowSettings* ImGuiWindowSettings_ImGuiWindowSettings(void); -CIMGUI_API void ImGuiWindowSettings_destroy(ImGuiWindowSettings* self); -CIMGUI_API char* ImGuiWindowSettings_GetName(ImGuiWindowSettings* self); -CIMGUI_API ImGuiSettingsHandler* ImGuiSettingsHandler_ImGuiSettingsHandler( - void); -CIMGUI_API void ImGuiSettingsHandler_destroy(ImGuiSettingsHandler* self); -CIMGUI_API ImGuiDebugAllocInfo* ImGuiDebugAllocInfo_ImGuiDebugAllocInfo(void); -CIMGUI_API void ImGuiDebugAllocInfo_destroy(ImGuiDebugAllocInfo* self); -CIMGUI_API ImGuiStackLevelInfo* ImGuiStackLevelInfo_ImGuiStackLevelInfo(void); -CIMGUI_API void ImGuiStackLevelInfo_destroy(ImGuiStackLevelInfo* self); -CIMGUI_API ImGuiIDStackTool* ImGuiIDStackTool_ImGuiIDStackTool(void); -CIMGUI_API void ImGuiIDStackTool_destroy(ImGuiIDStackTool* self); -CIMGUI_API ImGuiContextHook* ImGuiContextHook_ImGuiContextHook(void); -CIMGUI_API void ImGuiContextHook_destroy(ImGuiContextHook* self); -CIMGUI_API ImGuiContext* ImGuiContext_ImGuiContext( - ImFontAtlas* shared_font_atlas); -CIMGUI_API void ImGuiContext_destroy(ImGuiContext* self); -CIMGUI_API ImGuiWindow* ImGuiWindow_ImGuiWindow(ImGuiContext* context, - const char* name); -CIMGUI_API void ImGuiWindow_destroy(ImGuiWindow* self); -CIMGUI_API ImGuiID ImGuiWindow_GetID_Str(ImGuiWindow* self, - const char* str, - const char* str_end); -CIMGUI_API ImGuiID ImGuiWindow_GetID_Ptr(ImGuiWindow* self, const void* ptr); -CIMGUI_API ImGuiID ImGuiWindow_GetID_Int(ImGuiWindow* self, int n); -CIMGUI_API ImGuiID ImGuiWindow_GetIDFromRectangle(ImGuiWindow* self, - const ImRect r_abs); -CIMGUI_API void ImGuiWindow_Rect(ImRect* pOut, ImGuiWindow* self); -CIMGUI_API float ImGuiWindow_CalcFontSize(ImGuiWindow* self); -CIMGUI_API float ImGuiWindow_TitleBarHeight(ImGuiWindow* self); -CIMGUI_API void ImGuiWindow_TitleBarRect(ImRect* pOut, ImGuiWindow* self); -CIMGUI_API float ImGuiWindow_MenuBarHeight(ImGuiWindow* self); -CIMGUI_API void ImGuiWindow_MenuBarRect(ImRect* pOut, ImGuiWindow* self); -CIMGUI_API ImGuiTabItem* ImGuiTabItem_ImGuiTabItem(void); -CIMGUI_API void ImGuiTabItem_destroy(ImGuiTabItem* self); -CIMGUI_API ImGuiTabBar* ImGuiTabBar_ImGuiTabBar(void); -CIMGUI_API void ImGuiTabBar_destroy(ImGuiTabBar* self); -CIMGUI_API ImGuiTableColumn* ImGuiTableColumn_ImGuiTableColumn(void); -CIMGUI_API void ImGuiTableColumn_destroy(ImGuiTableColumn* self); -CIMGUI_API ImGuiTableInstanceData* -ImGuiTableInstanceData_ImGuiTableInstanceData(void); -CIMGUI_API void ImGuiTableInstanceData_destroy(ImGuiTableInstanceData* self); -CIMGUI_API ImGuiTable* ImGuiTable_ImGuiTable(void); -CIMGUI_API void ImGuiTable_destroy(ImGuiTable* self); -CIMGUI_API ImGuiTableTempData* ImGuiTableTempData_ImGuiTableTempData(void); -CIMGUI_API void ImGuiTableTempData_destroy(ImGuiTableTempData* self); -CIMGUI_API ImGuiTableColumnSettings* -ImGuiTableColumnSettings_ImGuiTableColumnSettings(void); -CIMGUI_API void ImGuiTableColumnSettings_destroy( - ImGuiTableColumnSettings* self); -CIMGUI_API ImGuiTableSettings* ImGuiTableSettings_ImGuiTableSettings(void); -CIMGUI_API void ImGuiTableSettings_destroy(ImGuiTableSettings* self); -CIMGUI_API ImGuiTableColumnSettings* ImGuiTableSettings_GetColumnSettings( - ImGuiTableSettings* self); -CIMGUI_API ImGuiWindow* igGetCurrentWindowRead(void); -CIMGUI_API ImGuiWindow* igGetCurrentWindow(void); -CIMGUI_API ImGuiWindow* igFindWindowByID(ImGuiID id); -CIMGUI_API ImGuiWindow* igFindWindowByName(const char* name); -CIMGUI_API void igUpdateWindowParentAndRootLinks(ImGuiWindow* window, - ImGuiWindowFlags flags, - ImGuiWindow* parent_window); -CIMGUI_API void igUpdateWindowSkipRefresh(ImGuiWindow* window); -CIMGUI_API void igCalcWindowNextAutoFitSize(ImVec2* pOut, ImGuiWindow* window); -CIMGUI_API bool igIsWindowChildOf(ImGuiWindow* window, - ImGuiWindow* potential_parent, - bool popup_hierarchy, - bool dock_hierarchy); -CIMGUI_API bool igIsWindowWithinBeginStackOf(ImGuiWindow* window, - ImGuiWindow* potential_parent); -CIMGUI_API bool igIsWindowAbove(ImGuiWindow* potential_above, - ImGuiWindow* potential_below); -CIMGUI_API bool igIsWindowNavFocusable(ImGuiWindow* window); -CIMGUI_API void igSetWindowPos_WindowPtr(ImGuiWindow* window, - const ImVec2 pos, - ImGuiCond cond); -CIMGUI_API void igSetWindowSize_WindowPtr(ImGuiWindow* window, - const ImVec2 size, - ImGuiCond cond); -CIMGUI_API void igSetWindowCollapsed_WindowPtr(ImGuiWindow* window, - bool collapsed, - ImGuiCond cond); -CIMGUI_API void igSetWindowHitTestHole(ImGuiWindow* window, - const ImVec2 pos, - const ImVec2 size); -CIMGUI_API void igSetWindowHiddenAndSkipItemsForCurrentFrame( - ImGuiWindow* window); -CIMGUI_API void igSetWindowParentWindowForFocusRoute( - ImGuiWindow* window, - ImGuiWindow* parent_window); -CIMGUI_API void igWindowRectAbsToRel(ImRect* pOut, - ImGuiWindow* window, - const ImRect r); -CIMGUI_API void igWindowRectRelToAbs(ImRect* pOut, - ImGuiWindow* window, - const ImRect r); -CIMGUI_API void igWindowPosRelToAbs(ImVec2* pOut, - ImGuiWindow* window, - const ImVec2 p); -CIMGUI_API void igFocusWindow(ImGuiWindow* window, - ImGuiFocusRequestFlags flags); -CIMGUI_API void igFocusTopMostWindowUnderOne(ImGuiWindow* under_this_window, - ImGuiWindow* ignore_window, - ImGuiViewport* filter_viewport, - ImGuiFocusRequestFlags flags); -CIMGUI_API void igBringWindowToFocusFront(ImGuiWindow* window); -CIMGUI_API void igBringWindowToDisplayFront(ImGuiWindow* window); -CIMGUI_API void igBringWindowToDisplayBack(ImGuiWindow* window); -CIMGUI_API void igBringWindowToDisplayBehind(ImGuiWindow* window, - ImGuiWindow* above_window); -CIMGUI_API int igFindWindowDisplayIndex(ImGuiWindow* window); -CIMGUI_API ImGuiWindow* igFindBottomMostVisibleWindowWithinBeginStack( - ImGuiWindow* window); -CIMGUI_API void igSetNextWindowRefreshPolicy(ImGuiWindowRefreshFlags flags); -CIMGUI_API void igSetCurrentFont(ImFont* font); -CIMGUI_API ImFont* igGetDefaultFont(void); -CIMGUI_API ImDrawList* igGetForegroundDrawList_WindowPtr(ImGuiWindow* window); -CIMGUI_API void igAddDrawListToDrawDataEx(ImDrawData* draw_data, - ImVector_ImDrawListPtr* out_list, - ImDrawList* draw_list); -CIMGUI_API void igInitialize(void); -CIMGUI_API void igShutdown(void); -CIMGUI_API void igUpdateInputEvents(bool trickle_fast_inputs); -CIMGUI_API void igUpdateHoveredWindowAndCaptureFlags(void); -CIMGUI_API void igStartMouseMovingWindow(ImGuiWindow* window); -CIMGUI_API void igStartMouseMovingWindowOrNode(ImGuiWindow* window, - ImGuiDockNode* node, - bool undock); -CIMGUI_API void igUpdateMouseMovingWindowNewFrame(void); -CIMGUI_API void igUpdateMouseMovingWindowEndFrame(void); -CIMGUI_API ImGuiID igAddContextHook(ImGuiContext* context, - const ImGuiContextHook* hook); -CIMGUI_API void igRemoveContextHook(ImGuiContext* context, - ImGuiID hook_to_remove); -CIMGUI_API void igCallContextHooks(ImGuiContext* context, - ImGuiContextHookType type); -CIMGUI_API void igTranslateWindowsInViewport(ImGuiViewportP* viewport, - const ImVec2 old_pos, - const ImVec2 new_pos); -CIMGUI_API void igScaleWindowsInViewport(ImGuiViewportP* viewport, float scale); -CIMGUI_API void igDestroyPlatformWindow(ImGuiViewportP* viewport); -CIMGUI_API void igSetWindowViewport(ImGuiWindow* window, - ImGuiViewportP* viewport); -CIMGUI_API void igSetCurrentViewport(ImGuiWindow* window, - ImGuiViewportP* viewport); -CIMGUI_API const ImGuiPlatformMonitor* igGetViewportPlatformMonitor( - ImGuiViewport* viewport); -CIMGUI_API ImGuiViewportP* igFindHoveredViewportFromPlatformWindowStack( - const ImVec2 mouse_platform_pos); -CIMGUI_API void igMarkIniSettingsDirty_Nil(void); -CIMGUI_API void igMarkIniSettingsDirty_WindowPtr(ImGuiWindow* window); -CIMGUI_API void igClearIniSettings(void); -CIMGUI_API void igAddSettingsHandler(const ImGuiSettingsHandler* handler); -CIMGUI_API void igRemoveSettingsHandler(const char* type_name); -CIMGUI_API ImGuiSettingsHandler* igFindSettingsHandler(const char* type_name); -CIMGUI_API ImGuiWindowSettings* igCreateNewWindowSettings(const char* name); -CIMGUI_API ImGuiWindowSettings* igFindWindowSettingsByID(ImGuiID id); -CIMGUI_API ImGuiWindowSettings* igFindWindowSettingsByWindow( - ImGuiWindow* window); -CIMGUI_API void igClearWindowSettings(const char* name); -CIMGUI_API void igLocalizeRegisterEntries(const ImGuiLocEntry* entries, - int count); -CIMGUI_API const char* igLocalizeGetMsg(ImGuiLocKey key); -CIMGUI_API void igSetScrollX_WindowPtr(ImGuiWindow* window, float scroll_x); -CIMGUI_API void igSetScrollY_WindowPtr(ImGuiWindow* window, float scroll_y); -CIMGUI_API void igSetScrollFromPosX_WindowPtr(ImGuiWindow* window, - float local_x, - float center_x_ratio); -CIMGUI_API void igSetScrollFromPosY_WindowPtr(ImGuiWindow* window, - float local_y, - float center_y_ratio); -CIMGUI_API void igScrollToItem(ImGuiScrollFlags flags); -CIMGUI_API void igScrollToRect(ImGuiWindow* window, - const ImRect rect, - ImGuiScrollFlags flags); -CIMGUI_API void igScrollToRectEx(ImVec2* pOut, - ImGuiWindow* window, - const ImRect rect, - ImGuiScrollFlags flags); -CIMGUI_API void igScrollToBringRectIntoView(ImGuiWindow* window, - const ImRect rect); -CIMGUI_API ImGuiItemStatusFlags igGetItemStatusFlags(void); -CIMGUI_API ImGuiItemFlags igGetItemFlags(void); -CIMGUI_API ImGuiID igGetActiveID(void); -CIMGUI_API ImGuiID igGetFocusID(void); -CIMGUI_API void igSetActiveID(ImGuiID id, ImGuiWindow* window); -CIMGUI_API void igSetFocusID(ImGuiID id, ImGuiWindow* window); -CIMGUI_API void igClearActiveID(void); -CIMGUI_API ImGuiID igGetHoveredID(void); -CIMGUI_API void igSetHoveredID(ImGuiID id); -CIMGUI_API void igKeepAliveID(ImGuiID id); -CIMGUI_API void igMarkItemEdited(ImGuiID id); -CIMGUI_API void igPushOverrideID(ImGuiID id); -CIMGUI_API ImGuiID igGetIDWithSeed_Str(const char* str_id_begin, - const char* str_id_end, - ImGuiID seed); -CIMGUI_API ImGuiID igGetIDWithSeed_Int(int n, ImGuiID seed); -CIMGUI_API void igItemSize_Vec2(const ImVec2 size, float text_baseline_y); -CIMGUI_API void igItemSize_Rect(const ImRect bb, float text_baseline_y); -CIMGUI_API bool igItemAdd(const ImRect bb, - ImGuiID id, - const ImRect* nav_bb, - ImGuiItemFlags extra_flags); -CIMGUI_API bool igItemHoverable(const ImRect bb, - ImGuiID id, - ImGuiItemFlags item_flags); -CIMGUI_API bool igIsWindowContentHoverable(ImGuiWindow* window, - ImGuiHoveredFlags flags); -CIMGUI_API bool igIsClippedEx(const ImRect bb, ImGuiID id); -CIMGUI_API void igSetLastItemData(ImGuiID item_id, - ImGuiItemFlags in_flags, - ImGuiItemStatusFlags status_flags, - const ImRect item_rect); -CIMGUI_API void igCalcItemSize(ImVec2* pOut, - ImVec2 size, - float default_w, - float default_h); -CIMGUI_API float igCalcWrapWidthForPos(const ImVec2 pos, float wrap_pos_x); -CIMGUI_API void igPushMultiItemsWidths(int components, float width_full); -CIMGUI_API bool igIsItemToggledSelection(void); -CIMGUI_API void igGetContentRegionMaxAbs(ImVec2* pOut); -CIMGUI_API void igShrinkWidths(ImGuiShrinkWidthItem* items, - int count, - float width_excess); -CIMGUI_API void igPushItemFlag(ImGuiItemFlags option, bool enabled); -CIMGUI_API void igPopItemFlag(void); -CIMGUI_API const ImGuiDataVarInfo* igGetStyleVarInfo(ImGuiStyleVar idx); -CIMGUI_API void igLogBegin(ImGuiLogType type, int auto_open_depth); -CIMGUI_API void igLogToBuffer(int auto_open_depth); -CIMGUI_API void igLogRenderedText(const ImVec2* ref_pos, - const char* text, - const char* text_end); -CIMGUI_API void igLogSetNextTextDecoration(const char* prefix, - const char* suffix); -CIMGUI_API bool igBeginChildEx(const char* name, - ImGuiID id, - const ImVec2 size_arg, - ImGuiChildFlags child_flags, - ImGuiWindowFlags window_flags); -CIMGUI_API void igOpenPopupEx(ImGuiID id, ImGuiPopupFlags popup_flags); -CIMGUI_API void igClosePopupToLevel(int remaining, - bool restore_focus_to_window_under_popup); -CIMGUI_API void igClosePopupsOverWindow( - ImGuiWindow* ref_window, - bool restore_focus_to_window_under_popup); -CIMGUI_API void igClosePopupsExceptModals(void); -CIMGUI_API bool igIsPopupOpen_ID(ImGuiID id, ImGuiPopupFlags popup_flags); -CIMGUI_API bool igBeginPopupEx(ImGuiID id, ImGuiWindowFlags extra_flags); -CIMGUI_API bool igBeginTooltipEx(ImGuiTooltipFlags tooltip_flags, - ImGuiWindowFlags extra_window_flags); -CIMGUI_API bool igBeginTooltipHidden(void); -CIMGUI_API void igGetPopupAllowedExtentRect(ImRect* pOut, ImGuiWindow* window); -CIMGUI_API ImGuiWindow* igGetTopMostPopupModal(void); -CIMGUI_API ImGuiWindow* igGetTopMostAndVisiblePopupModal(void); -CIMGUI_API ImGuiWindow* igFindBlockingModal(ImGuiWindow* window); -CIMGUI_API void igFindBestWindowPosForPopup(ImVec2* pOut, ImGuiWindow* window); -CIMGUI_API void igFindBestWindowPosForPopupEx(ImVec2* pOut, - const ImVec2 ref_pos, - const ImVec2 size, - ImGuiDir* last_dir, - const ImRect r_outer, - const ImRect r_avoid, - ImGuiPopupPositionPolicy policy); -CIMGUI_API bool igBeginViewportSideBar(const char* name, - ImGuiViewport* viewport, - ImGuiDir dir, - float size, - ImGuiWindowFlags window_flags); -CIMGUI_API bool igBeginMenuEx(const char* label, - const char* icon, - bool enabled); -CIMGUI_API bool igMenuItemEx(const char* label, - const char* icon, - const char* shortcut, - bool selected, - bool enabled); -CIMGUI_API bool igBeginComboPopup(ImGuiID popup_id, - const ImRect bb, - ImGuiComboFlags flags); -CIMGUI_API bool igBeginComboPreview(void); -CIMGUI_API void igEndComboPreview(void); -CIMGUI_API void igNavInitWindow(ImGuiWindow* window, bool force_reinit); -CIMGUI_API void igNavInitRequestApplyResult(void); -CIMGUI_API bool igNavMoveRequestButNoResultYet(void); -CIMGUI_API void igNavMoveRequestSubmit(ImGuiDir move_dir, - ImGuiDir clip_dir, - ImGuiNavMoveFlags move_flags, - ImGuiScrollFlags scroll_flags); -CIMGUI_API void igNavMoveRequestForward(ImGuiDir move_dir, - ImGuiDir clip_dir, - ImGuiNavMoveFlags move_flags, - ImGuiScrollFlags scroll_flags); -CIMGUI_API void igNavMoveRequestResolveWithLastItem(ImGuiNavItemData* result); -CIMGUI_API void igNavMoveRequestResolveWithPastTreeNode( - ImGuiNavItemData* result, - ImGuiNavTreeNodeData* tree_node_data); -CIMGUI_API void igNavMoveRequestCancel(void); -CIMGUI_API void igNavMoveRequestApplyResult(void); -CIMGUI_API void igNavMoveRequestTryWrapping(ImGuiWindow* window, - ImGuiNavMoveFlags move_flags); -CIMGUI_API void igNavHighlightActivated(ImGuiID id); -CIMGUI_API void igNavClearPreferredPosForAxis(ImGuiAxis axis); -CIMGUI_API void igNavRestoreHighlightAfterMove(void); -CIMGUI_API void igNavUpdateCurrentWindowIsScrollPushableX(void); -CIMGUI_API void igSetNavWindow(ImGuiWindow* window); -CIMGUI_API void igSetNavID(ImGuiID id, - ImGuiNavLayer nav_layer, - ImGuiID focus_scope_id, - const ImRect rect_rel); -CIMGUI_API void igSetNavFocusScope(ImGuiID focus_scope_id); -CIMGUI_API void igFocusItem(void); -CIMGUI_API void igActivateItemByID(ImGuiID id); -CIMGUI_API bool igIsNamedKey(ImGuiKey key); -CIMGUI_API bool igIsNamedKeyOrModKey(ImGuiKey key); -CIMGUI_API bool igIsLegacyKey(ImGuiKey key); -CIMGUI_API bool igIsKeyboardKey(ImGuiKey key); -CIMGUI_API bool igIsGamepadKey(ImGuiKey key); -CIMGUI_API bool igIsMouseKey(ImGuiKey key); -CIMGUI_API bool igIsAliasKey(ImGuiKey key); -CIMGUI_API bool igIsModKey(ImGuiKey key); -CIMGUI_API ImGuiKeyChord igFixupKeyChord(ImGuiContext* ctx, - ImGuiKeyChord key_chord); -CIMGUI_API ImGuiKey igConvertSingleModFlagToKey(ImGuiContext* ctx, - ImGuiKey key); -CIMGUI_API ImGuiKeyData* igGetKeyData_ContextPtr(ImGuiContext* ctx, - ImGuiKey key); -CIMGUI_API ImGuiKeyData* igGetKeyData_Key(ImGuiKey key); -CIMGUI_API const char* igGetKeyChordName(ImGuiKeyChord key_chord); -CIMGUI_API ImGuiKey igMouseButtonToKey(ImGuiMouseButton button); -CIMGUI_API bool igIsMouseDragPastThreshold(ImGuiMouseButton button, - float lock_threshold); -CIMGUI_API void igGetKeyMagnitude2d(ImVec2* pOut, - ImGuiKey key_left, - ImGuiKey key_right, - ImGuiKey key_up, - ImGuiKey key_down); -CIMGUI_API float igGetNavTweakPressedAmount(ImGuiAxis axis); -CIMGUI_API int igCalcTypematicRepeatAmount(float t0, - float t1, - float repeat_delay, - float repeat_rate); -CIMGUI_API void igGetTypematicRepeatRate(ImGuiInputFlags flags, - float* repeat_delay, - float* repeat_rate); -CIMGUI_API void igTeleportMousePos(const ImVec2 pos); -CIMGUI_API void igSetActiveIdUsingAllKeyboardKeys(void); -CIMGUI_API bool igIsActiveIdUsingNavDir(ImGuiDir dir); -CIMGUI_API ImGuiID igGetKeyOwner(ImGuiKey key); -CIMGUI_API void igSetKeyOwner(ImGuiKey key, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API void igSetKeyOwnersForKeyChord(ImGuiKeyChord key, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API void igSetItemKeyOwner(ImGuiKey key, ImGuiInputFlags flags); -CIMGUI_API bool igTestKeyOwner(ImGuiKey key, ImGuiID owner_id); -CIMGUI_API ImGuiKeyOwnerData* igGetKeyOwnerData(ImGuiContext* ctx, - ImGuiKey key); -CIMGUI_API bool igIsKeyDown_ID(ImGuiKey key, ImGuiID owner_id); -CIMGUI_API bool igIsKeyPressed_ID(ImGuiKey key, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API bool igIsKeyReleased_ID(ImGuiKey key, ImGuiID owner_id); -CIMGUI_API bool igIsMouseDown_ID(ImGuiMouseButton button, ImGuiID owner_id); -CIMGUI_API bool igIsMouseClicked_ID(ImGuiMouseButton button, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API bool igIsMouseReleased_ID(ImGuiMouseButton button, ImGuiID owner_id); -CIMGUI_API bool igIsMouseDoubleClicked_ID(ImGuiMouseButton button, - ImGuiID owner_id); -CIMGUI_API bool igIsKeyChordPressed_ID(ImGuiKeyChord key_chord, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API void igSetNextItemShortcut(ImGuiKeyChord key_chord); -CIMGUI_API bool igShortcut(ImGuiKeyChord key_chord, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API bool igSetShortcutRouting(ImGuiKeyChord key_chord, - ImGuiID owner_id, - ImGuiInputFlags flags); -CIMGUI_API bool igTestShortcutRouting(ImGuiKeyChord key_chord, - ImGuiID owner_id); -CIMGUI_API ImGuiKeyRoutingData* igGetShortcutRoutingData( - ImGuiKeyChord key_chord); -CIMGUI_API void igDockContextInitialize(ImGuiContext* ctx); -CIMGUI_API void igDockContextShutdown(ImGuiContext* ctx); -CIMGUI_API void igDockContextClearNodes(ImGuiContext* ctx, - ImGuiID root_id, - bool clear_settings_refs); -CIMGUI_API void igDockContextRebuildNodes(ImGuiContext* ctx); -CIMGUI_API void igDockContextNewFrameUpdateUndocking(ImGuiContext* ctx); -CIMGUI_API void igDockContextNewFrameUpdateDocking(ImGuiContext* ctx); -CIMGUI_API void igDockContextEndFrame(ImGuiContext* ctx); -CIMGUI_API ImGuiID igDockContextGenNodeID(ImGuiContext* ctx); -CIMGUI_API void igDockContextQueueDock(ImGuiContext* ctx, - ImGuiWindow* target, - ImGuiDockNode* target_node, - ImGuiWindow* payload, - ImGuiDir split_dir, - float split_ratio, - bool split_outer); -CIMGUI_API void igDockContextQueueUndockWindow(ImGuiContext* ctx, - ImGuiWindow* window); -CIMGUI_API void igDockContextQueueUndockNode(ImGuiContext* ctx, - ImGuiDockNode* node); -CIMGUI_API void igDockContextProcessUndockWindow( - ImGuiContext* ctx, - ImGuiWindow* window, - bool clear_persistent_docking_ref); -CIMGUI_API void igDockContextProcessUndockNode(ImGuiContext* ctx, - ImGuiDockNode* node); -CIMGUI_API bool igDockContextCalcDropPosForDocking(ImGuiWindow* target, - ImGuiDockNode* target_node, - ImGuiWindow* payload_window, - ImGuiDockNode* payload_node, - ImGuiDir split_dir, - bool split_outer, - ImVec2* out_pos); -CIMGUI_API ImGuiDockNode* igDockContextFindNodeByID(ImGuiContext* ctx, - ImGuiID id); -CIMGUI_API void igDockNodeWindowMenuHandler_Default(ImGuiContext* ctx, - ImGuiDockNode* node, - ImGuiTabBar* tab_bar); -CIMGUI_API bool igDockNodeBeginAmendTabBar(ImGuiDockNode* node); -CIMGUI_API void igDockNodeEndAmendTabBar(void); -CIMGUI_API ImGuiDockNode* igDockNodeGetRootNode(ImGuiDockNode* node); -CIMGUI_API bool igDockNodeIsInHierarchyOf(ImGuiDockNode* node, - ImGuiDockNode* parent); -CIMGUI_API int igDockNodeGetDepth(const ImGuiDockNode* node); -CIMGUI_API ImGuiID igDockNodeGetWindowMenuButtonId(const ImGuiDockNode* node); -CIMGUI_API ImGuiDockNode* igGetWindowDockNode(void); -CIMGUI_API bool igGetWindowAlwaysWantOwnTabBar(ImGuiWindow* window); -CIMGUI_API void igBeginDocked(ImGuiWindow* window, bool* p_open); -CIMGUI_API void igBeginDockableDragDropSource(ImGuiWindow* window); -CIMGUI_API void igBeginDockableDragDropTarget(ImGuiWindow* window); -CIMGUI_API void igSetWindowDock(ImGuiWindow* window, - ImGuiID dock_id, - ImGuiCond cond); -CIMGUI_API void igDockBuilderDockWindow(const char* window_name, - ImGuiID node_id); -CIMGUI_API ImGuiDockNode* igDockBuilderGetNode(ImGuiID node_id); -CIMGUI_API ImGuiDockNode* igDockBuilderGetCentralNode(ImGuiID node_id); -CIMGUI_API ImGuiID igDockBuilderAddNode(ImGuiID node_id, - ImGuiDockNodeFlags flags); -CIMGUI_API void igDockBuilderRemoveNode(ImGuiID node_id); -CIMGUI_API void igDockBuilderRemoveNodeDockedWindows(ImGuiID node_id, - bool clear_settings_refs); -CIMGUI_API void igDockBuilderRemoveNodeChildNodes(ImGuiID node_id); -CIMGUI_API void igDockBuilderSetNodePos(ImGuiID node_id, ImVec2 pos); -CIMGUI_API void igDockBuilderSetNodeSize(ImGuiID node_id, ImVec2 size); -CIMGUI_API ImGuiID igDockBuilderSplitNode(ImGuiID node_id, - ImGuiDir split_dir, - float size_ratio_for_node_at_dir, - ImGuiID* out_id_at_dir, - ImGuiID* out_id_at_opposite_dir); -CIMGUI_API void igDockBuilderCopyDockSpace( - ImGuiID src_dockspace_id, - ImGuiID dst_dockspace_id, - ImVector_const_charPtr* in_window_remap_pairs); -CIMGUI_API void igDockBuilderCopyNode(ImGuiID src_node_id, - ImGuiID dst_node_id, - ImVector_ImGuiID* out_node_remap_pairs); -CIMGUI_API void igDockBuilderCopyWindowSettings(const char* src_name, - const char* dst_name); -CIMGUI_API void igDockBuilderFinish(ImGuiID node_id); -CIMGUI_API void igPushFocusScope(ImGuiID id); -CIMGUI_API void igPopFocusScope(void); -CIMGUI_API ImGuiID igGetCurrentFocusScope(void); -CIMGUI_API bool igIsDragDropActive(void); -CIMGUI_API bool igBeginDragDropTargetCustom(const ImRect bb, ImGuiID id); -CIMGUI_API void igClearDragDrop(void); -CIMGUI_API bool igIsDragDropPayloadBeingAccepted(void); -CIMGUI_API void igRenderDragDropTargetRect(const ImRect bb, - const ImRect item_clip_rect); -CIMGUI_API ImGuiTypingSelectRequest* igGetTypingSelectRequest( - ImGuiTypingSelectFlags flags); -CIMGUI_API int igTypingSelectFindMatch(ImGuiTypingSelectRequest* req, - int items_count, - const char* (*get_item_name_func)(void*, - int), - void* user_data, - int nav_item_idx); -CIMGUI_API int igTypingSelectFindNextSingleCharMatch( - ImGuiTypingSelectRequest* req, - int items_count, - const char* (*get_item_name_func)(void*, int), - void* user_data, - int nav_item_idx); -CIMGUI_API int igTypingSelectFindBestLeadingMatch( - ImGuiTypingSelectRequest* req, - int items_count, - const char* (*get_item_name_func)(void*, int), - void* user_data); -CIMGUI_API void igSetWindowClipRectBeforeSetChannel(ImGuiWindow* window, - const ImRect clip_rect); -CIMGUI_API void igBeginColumns(const char* str_id, - int count, - ImGuiOldColumnFlags flags); -CIMGUI_API void igEndColumns(void); -CIMGUI_API void igPushColumnClipRect(int column_index); -CIMGUI_API void igPushColumnsBackground(void); -CIMGUI_API void igPopColumnsBackground(void); -CIMGUI_API ImGuiID igGetColumnsID(const char* str_id, int count); -CIMGUI_API ImGuiOldColumns* igFindOrCreateColumns(ImGuiWindow* window, - ImGuiID id); -CIMGUI_API float igGetColumnOffsetFromNorm(const ImGuiOldColumns* columns, - float offset_norm); -CIMGUI_API float igGetColumnNormFromOffset(const ImGuiOldColumns* columns, - float offset); -CIMGUI_API void igTableOpenContextMenu(int column_n); -CIMGUI_API void igTableSetColumnWidth(int column_n, float width); -CIMGUI_API void igTableSetColumnSortDirection(int column_n, - ImGuiSortDirection sort_direction, - bool append_to_sort_specs); -CIMGUI_API int igTableGetHoveredColumn(void); -CIMGUI_API int igTableGetHoveredRow(void); -CIMGUI_API float igTableGetHeaderRowHeight(void); -CIMGUI_API float igTableGetHeaderAngledMaxLabelWidth(void); -CIMGUI_API void igTablePushBackgroundChannel(void); -CIMGUI_API void igTablePopBackgroundChannel(void); -CIMGUI_API void igTableAngledHeadersRowEx(ImGuiID row_id, - float angle, - float max_label_width, - const ImGuiTableHeaderData* data, - int data_count); -CIMGUI_API ImGuiTable* igGetCurrentTable(void); -CIMGUI_API ImGuiTable* igTableFindByID(ImGuiID id); -CIMGUI_API bool igBeginTableEx(const char* name, - ImGuiID id, - int columns_count, - ImGuiTableFlags flags, - const ImVec2 outer_size, - float inner_width); -CIMGUI_API void igTableBeginInitMemory(ImGuiTable* table, int columns_count); -CIMGUI_API void igTableBeginApplyRequests(ImGuiTable* table); -CIMGUI_API void igTableSetupDrawChannels(ImGuiTable* table); -CIMGUI_API void igTableUpdateLayout(ImGuiTable* table); -CIMGUI_API void igTableUpdateBorders(ImGuiTable* table); -CIMGUI_API void igTableUpdateColumnsWeightFromWidth(ImGuiTable* table); -CIMGUI_API void igTableDrawBorders(ImGuiTable* table); -CIMGUI_API void igTableDrawDefaultContextMenu( - ImGuiTable* table, - ImGuiTableFlags flags_for_section_to_display); -CIMGUI_API bool igTableBeginContextMenuPopup(ImGuiTable* table); -CIMGUI_API void igTableMergeDrawChannels(ImGuiTable* table); -CIMGUI_API ImGuiTableInstanceData* igTableGetInstanceData(ImGuiTable* table, - int instance_no); -CIMGUI_API ImGuiID igTableGetInstanceID(ImGuiTable* table, int instance_no); -CIMGUI_API void igTableSortSpecsSanitize(ImGuiTable* table); -CIMGUI_API void igTableSortSpecsBuild(ImGuiTable* table); -CIMGUI_API ImGuiSortDirection -igTableGetColumnNextSortDirection(ImGuiTableColumn* column); -CIMGUI_API void igTableFixColumnSortDirection(ImGuiTable* table, - ImGuiTableColumn* column); -CIMGUI_API float igTableGetColumnWidthAuto(ImGuiTable* table, - ImGuiTableColumn* column); -CIMGUI_API void igTableBeginRow(ImGuiTable* table); -CIMGUI_API void igTableEndRow(ImGuiTable* table); -CIMGUI_API void igTableBeginCell(ImGuiTable* table, int column_n); -CIMGUI_API void igTableEndCell(ImGuiTable* table); -CIMGUI_API void igTableGetCellBgRect(ImRect* pOut, - const ImGuiTable* table, - int column_n); -CIMGUI_API const char* igTableGetColumnName_TablePtr(const ImGuiTable* table, - int column_n); -CIMGUI_API ImGuiID igTableGetColumnResizeID(ImGuiTable* table, - int column_n, - int instance_no); -CIMGUI_API float igTableGetMaxColumnWidth(const ImGuiTable* table, - int column_n); -CIMGUI_API void igTableSetColumnWidthAutoSingle(ImGuiTable* table, - int column_n); -CIMGUI_API void igTableSetColumnWidthAutoAll(ImGuiTable* table); -CIMGUI_API void igTableRemove(ImGuiTable* table); -CIMGUI_API void igTableGcCompactTransientBuffers_TablePtr(ImGuiTable* table); -CIMGUI_API void igTableGcCompactTransientBuffers_TableTempDataPtr( - ImGuiTableTempData* table); -CIMGUI_API void igTableGcCompactSettings(void); -CIMGUI_API void igTableLoadSettings(ImGuiTable* table); -CIMGUI_API void igTableSaveSettings(ImGuiTable* table); -CIMGUI_API void igTableResetSettings(ImGuiTable* table); -CIMGUI_API ImGuiTableSettings* igTableGetBoundSettings(ImGuiTable* table); -CIMGUI_API void igTableSettingsAddSettingsHandler(void); -CIMGUI_API ImGuiTableSettings* igTableSettingsCreate(ImGuiID id, - int columns_count); -CIMGUI_API ImGuiTableSettings* igTableSettingsFindByID(ImGuiID id); -CIMGUI_API ImGuiTabBar* igGetCurrentTabBar(void); -CIMGUI_API bool igBeginTabBarEx(ImGuiTabBar* tab_bar, - const ImRect bb, - ImGuiTabBarFlags flags); -CIMGUI_API ImGuiTabItem* igTabBarFindTabByID(ImGuiTabBar* tab_bar, - ImGuiID tab_id); -CIMGUI_API ImGuiTabItem* igTabBarFindTabByOrder(ImGuiTabBar* tab_bar, - int order); -CIMGUI_API ImGuiTabItem* igTabBarFindMostRecentlySelectedTabForActiveWindow( - ImGuiTabBar* tab_bar); -CIMGUI_API ImGuiTabItem* igTabBarGetCurrentTab(ImGuiTabBar* tab_bar); -CIMGUI_API int igTabBarGetTabOrder(ImGuiTabBar* tab_bar, ImGuiTabItem* tab); -CIMGUI_API const char* igTabBarGetTabName(ImGuiTabBar* tab_bar, - ImGuiTabItem* tab); -CIMGUI_API void igTabBarAddTab(ImGuiTabBar* tab_bar, - ImGuiTabItemFlags tab_flags, - ImGuiWindow* window); -CIMGUI_API void igTabBarRemoveTab(ImGuiTabBar* tab_bar, ImGuiID tab_id); -CIMGUI_API void igTabBarCloseTab(ImGuiTabBar* tab_bar, ImGuiTabItem* tab); -CIMGUI_API void igTabBarQueueFocus(ImGuiTabBar* tab_bar, ImGuiTabItem* tab); -CIMGUI_API void igTabBarQueueReorder(ImGuiTabBar* tab_bar, - ImGuiTabItem* tab, - int offset); -CIMGUI_API void igTabBarQueueReorderFromMousePos(ImGuiTabBar* tab_bar, - ImGuiTabItem* tab, - ImVec2 mouse_pos); -CIMGUI_API bool igTabBarProcessReorder(ImGuiTabBar* tab_bar); -CIMGUI_API bool igTabItemEx(ImGuiTabBar* tab_bar, - const char* label, - bool* p_open, - ImGuiTabItemFlags flags, - ImGuiWindow* docked_window); -CIMGUI_API void igTabItemCalcSize_Str(ImVec2* pOut, - const char* label, - bool has_close_button_or_unsaved_marker); -CIMGUI_API void igTabItemCalcSize_WindowPtr(ImVec2* pOut, ImGuiWindow* window); -CIMGUI_API void igTabItemBackground(ImDrawList* draw_list, - const ImRect bb, - ImGuiTabItemFlags flags, - ImU32 col); -CIMGUI_API void igTabItemLabelAndCloseButton(ImDrawList* draw_list, - const ImRect bb, - ImGuiTabItemFlags flags, - ImVec2 frame_padding, - const char* label, - ImGuiID tab_id, - ImGuiID close_button_id, - bool is_contents_visible, - bool* out_just_closed, - bool* out_text_clipped); -CIMGUI_API void igRenderText(ImVec2 pos, - const char* text, - const char* text_end, - bool hide_text_after_hash); -CIMGUI_API void igRenderTextWrapped(ImVec2 pos, - const char* text, - const char* text_end, - float wrap_width); -CIMGUI_API void igRenderTextClipped(const ImVec2 pos_min, - const ImVec2 pos_max, - const char* text, - const char* text_end, - const ImVec2* text_size_if_known, - const ImVec2 align, - const ImRect* clip_rect); -CIMGUI_API void igRenderTextClippedEx(ImDrawList* draw_list, - const ImVec2 pos_min, - const ImVec2 pos_max, - const char* text, - const char* text_end, - const ImVec2* text_size_if_known, - const ImVec2 align, - const ImRect* clip_rect); -CIMGUI_API void igRenderTextEllipsis(ImDrawList* draw_list, - const ImVec2 pos_min, - const ImVec2 pos_max, - float clip_max_x, - float ellipsis_max_x, - const char* text, - const char* text_end, - const ImVec2* text_size_if_known); -CIMGUI_API void igRenderFrame(ImVec2 p_min, - ImVec2 p_max, - ImU32 fill_col, - bool border, - float rounding); -CIMGUI_API void igRenderFrameBorder(ImVec2 p_min, ImVec2 p_max, float rounding); -CIMGUI_API void igRenderColorRectWithAlphaCheckerboard(ImDrawList* draw_list, - ImVec2 p_min, - ImVec2 p_max, - ImU32 fill_col, - float grid_step, - ImVec2 grid_off, - float rounding, - ImDrawFlags flags); -CIMGUI_API void igRenderNavHighlight(const ImRect bb, - ImGuiID id, - ImGuiNavHighlightFlags flags); -CIMGUI_API const char* igFindRenderedTextEnd(const char* text, - const char* text_end); -CIMGUI_API void igRenderMouseCursor(ImVec2 pos, - float scale, - ImGuiMouseCursor mouse_cursor, - ImU32 col_fill, - ImU32 col_border, - ImU32 col_shadow); -CIMGUI_API void igRenderArrow(ImDrawList* draw_list, - ImVec2 pos, - ImU32 col, - ImGuiDir dir, - float scale); -CIMGUI_API void igRenderBullet(ImDrawList* draw_list, ImVec2 pos, ImU32 col); -CIMGUI_API void igRenderCheckMark(ImDrawList* draw_list, - ImVec2 pos, - ImU32 col, - float sz); -CIMGUI_API void igRenderArrowPointingAt(ImDrawList* draw_list, - ImVec2 pos, - ImVec2 half_sz, - ImGuiDir direction, - ImU32 col); -CIMGUI_API void igRenderArrowDockMenu(ImDrawList* draw_list, - ImVec2 p_min, - float sz, - ImU32 col); -CIMGUI_API void igRenderRectFilledRangeH(ImDrawList* draw_list, - const ImRect rect, - ImU32 col, - float x_start_norm, - float x_end_norm, - float rounding); -CIMGUI_API void igRenderRectFilledWithHole(ImDrawList* draw_list, - const ImRect outer, - const ImRect inner, - ImU32 col, - float rounding); -CIMGUI_API ImDrawFlags igCalcRoundingFlagsForRectInRect(const ImRect r_in, - const ImRect r_outer, - float threshold); -CIMGUI_API void igTextEx(const char* text, - const char* text_end, - ImGuiTextFlags flags); -CIMGUI_API bool igButtonEx(const char* label, - const ImVec2 size_arg, - ImGuiButtonFlags flags); -CIMGUI_API bool igArrowButtonEx(const char* str_id, - ImGuiDir dir, - ImVec2 size_arg, - ImGuiButtonFlags flags); -CIMGUI_API bool igImageButtonEx(ImGuiID id, - ImTextureID texture_id, - const ImVec2 image_size, - const ImVec2 uv0, - const ImVec2 uv1, - const ImVec4 bg_col, - const ImVec4 tint_col, - ImGuiButtonFlags flags); -CIMGUI_API void igSeparatorEx(ImGuiSeparatorFlags flags, float thickness); -CIMGUI_API void igSeparatorTextEx(ImGuiID id, - const char* label, - const char* label_end, - float extra_width); -CIMGUI_API bool igCheckboxFlags_S64Ptr(const char* label, - ImS64* flags, - ImS64 flags_value); -CIMGUI_API bool igCheckboxFlags_U64Ptr(const char* label, - ImU64* flags, - ImU64 flags_value); -CIMGUI_API bool igCloseButton(ImGuiID id, const ImVec2 pos); -CIMGUI_API bool igCollapseButton(ImGuiID id, - const ImVec2 pos, - ImGuiDockNode* dock_node); -CIMGUI_API void igScrollbar(ImGuiAxis axis); -CIMGUI_API bool igScrollbarEx(const ImRect bb, - ImGuiID id, - ImGuiAxis axis, - ImS64* p_scroll_v, - ImS64 avail_v, - ImS64 contents_v, - ImDrawFlags flags); -CIMGUI_API void igGetWindowScrollbarRect(ImRect* pOut, - ImGuiWindow* window, - ImGuiAxis axis); -CIMGUI_API ImGuiID igGetWindowScrollbarID(ImGuiWindow* window, ImGuiAxis axis); -CIMGUI_API ImGuiID igGetWindowResizeCornerID(ImGuiWindow* window, int n); -CIMGUI_API ImGuiID igGetWindowResizeBorderID(ImGuiWindow* window, ImGuiDir dir); -CIMGUI_API bool igButtonBehavior(const ImRect bb, - ImGuiID id, - bool* out_hovered, - bool* out_held, - ImGuiButtonFlags flags); -CIMGUI_API bool igDragBehavior(ImGuiID id, - ImGuiDataType data_type, - void* p_v, - float v_speed, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags); -CIMGUI_API bool igSliderBehavior(const ImRect bb, - ImGuiID id, - ImGuiDataType data_type, - void* p_v, - const void* p_min, - const void* p_max, - const char* format, - ImGuiSliderFlags flags, - ImRect* out_grab_bb); -CIMGUI_API bool igSplitterBehavior(const ImRect bb, - ImGuiID id, - ImGuiAxis axis, - float* size1, - float* size2, - float min_size1, - float min_size2, - float hover_extend, - float hover_visibility_delay, - ImU32 bg_col); -CIMGUI_API bool igTreeNodeBehavior(ImGuiID id, - ImGuiTreeNodeFlags flags, - const char* label, - const char* label_end); -CIMGUI_API void igTreePushOverrideID(ImGuiID id); -CIMGUI_API void igTreeNodeSetOpen(ImGuiID id, bool open); -CIMGUI_API bool igTreeNodeUpdateNextOpen(ImGuiID id, ImGuiTreeNodeFlags flags); -CIMGUI_API void igSetNextItemSelectionUserData( - ImGuiSelectionUserData selection_user_data); -CIMGUI_API const ImGuiDataTypeInfo* igDataTypeGetInfo(ImGuiDataType data_type); -CIMGUI_API int igDataTypeFormatString(char* buf, - int buf_size, - ImGuiDataType data_type, - const void* p_data, - const char* format); -CIMGUI_API void igDataTypeApplyOp(ImGuiDataType data_type, - int op, - void* output, - const void* arg_1, - const void* arg_2); -CIMGUI_API bool igDataTypeApplyFromText(const char* buf, - ImGuiDataType data_type, - void* p_data, - const char* format); -CIMGUI_API int igDataTypeCompare(ImGuiDataType data_type, - const void* arg_1, - const void* arg_2); -CIMGUI_API bool igDataTypeClamp(ImGuiDataType data_type, - void* p_data, - const void* p_min, - const void* p_max); -CIMGUI_API bool igInputTextEx(const char* label, - const char* hint, - char* buf, - int buf_size, - const ImVec2 size_arg, - ImGuiInputTextFlags flags, - ImGuiInputTextCallback callback, - void* user_data); -CIMGUI_API void igInputTextDeactivateHook(ImGuiID id); -CIMGUI_API bool igTempInputText(const ImRect bb, - ImGuiID id, - const char* label, - char* buf, - int buf_size, - ImGuiInputTextFlags flags); -CIMGUI_API bool igTempInputScalar(const ImRect bb, - ImGuiID id, - const char* label, - ImGuiDataType data_type, - void* p_data, - const char* format, - const void* p_clamp_min, - const void* p_clamp_max); -CIMGUI_API bool igTempInputIsActive(ImGuiID id); -CIMGUI_API ImGuiInputTextState* igGetInputTextState(ImGuiID id); -CIMGUI_API void igColorTooltip(const char* text, - const float* col, - ImGuiColorEditFlags flags); -CIMGUI_API void igColorEditOptionsPopup(const float* col, - ImGuiColorEditFlags flags); -CIMGUI_API void igColorPickerOptionsPopup(const float* ref_col, - ImGuiColorEditFlags flags); -CIMGUI_API int igPlotEx(ImGuiPlotType plot_type, - const char* label, - float (*values_getter)(void* data, int idx), - void* data, - int values_count, - int values_offset, - const char* overlay_text, - float scale_min, - float scale_max, - const ImVec2 size_arg); -CIMGUI_API void igShadeVertsLinearColorGradientKeepAlpha(ImDrawList* draw_list, - int vert_start_idx, - int vert_end_idx, - ImVec2 gradient_p0, - ImVec2 gradient_p1, - ImU32 col0, - ImU32 col1); -CIMGUI_API void igShadeVertsLinearUV(ImDrawList* draw_list, - int vert_start_idx, - int vert_end_idx, - const ImVec2 a, - const ImVec2 b, - const ImVec2 uv_a, - const ImVec2 uv_b, - bool clamp); -CIMGUI_API void igShadeVertsTransformPos(ImDrawList* draw_list, - int vert_start_idx, - int vert_end_idx, - const ImVec2 pivot_in, - float cos_a, - float sin_a, - const ImVec2 pivot_out); -CIMGUI_API void igGcCompactTransientMiscBuffers(void); -CIMGUI_API void igGcCompactTransientWindowBuffers(ImGuiWindow* window); -CIMGUI_API void igGcAwakeTransientWindowBuffers(ImGuiWindow* window); -CIMGUI_API void igDebugLog(const char* fmt, ...); -CIMGUI_API void igDebugLogV(const char* fmt, va_list args); -CIMGUI_API void igDebugAllocHook(ImGuiDebugAllocInfo* info, - int frame_count, - void* ptr, - size_t size); -CIMGUI_API void igErrorCheckEndFrameRecover(ImGuiErrorLogCallback log_callback, - void* user_data); -CIMGUI_API void igErrorCheckEndWindowRecover(ImGuiErrorLogCallback log_callback, - void* user_data); -CIMGUI_API void igErrorCheckUsingSetCursorPosToExtendParentBoundaries(void); -CIMGUI_API void igDebugDrawCursorPos(ImU32 col); -CIMGUI_API void igDebugDrawLineExtents(ImU32 col); -CIMGUI_API void igDebugDrawItemRect(ImU32 col); -CIMGUI_API void igDebugLocateItem(ImGuiID target_id); -CIMGUI_API void igDebugLocateItemOnHover(ImGuiID target_id); -CIMGUI_API void igDebugLocateItemResolveWithLastItem(void); -CIMGUI_API void igDebugBreakClearData(void); -CIMGUI_API bool igDebugBreakButton(const char* label, - const char* description_of_location); -CIMGUI_API void igDebugBreakButtonTooltip(bool keyboard_only, - const char* description_of_location); -CIMGUI_API void igShowFontAtlas(ImFontAtlas* atlas); -CIMGUI_API void igDebugHookIdInfo(ImGuiID id, - ImGuiDataType data_type, - const void* data_id, - const void* data_id_end); -CIMGUI_API void igDebugNodeColumns(ImGuiOldColumns* columns); -CIMGUI_API void igDebugNodeDockNode(ImGuiDockNode* node, const char* label); -CIMGUI_API void igDebugNodeDrawList(ImGuiWindow* window, - ImGuiViewportP* viewport, - const ImDrawList* draw_list, - const char* label); -CIMGUI_API void igDebugNodeDrawCmdShowMeshAndBoundingBox( - ImDrawList* out_draw_list, - const ImDrawList* draw_list, - const ImDrawCmd* draw_cmd, - bool show_mesh, - bool show_aabb); -CIMGUI_API void igDebugNodeFont(ImFont* font); -CIMGUI_API void igDebugNodeFontGlyph(ImFont* font, const ImFontGlyph* glyph); -CIMGUI_API void igDebugNodeStorage(ImGuiStorage* storage, const char* label); -CIMGUI_API void igDebugNodeTabBar(ImGuiTabBar* tab_bar, const char* label); -CIMGUI_API void igDebugNodeTable(ImGuiTable* table); -CIMGUI_API void igDebugNodeTableSettings(ImGuiTableSettings* settings); -CIMGUI_API void igDebugNodeInputTextState(ImGuiInputTextState* state); -CIMGUI_API void igDebugNodeTypingSelectState(ImGuiTypingSelectState* state); -CIMGUI_API void igDebugNodeWindow(ImGuiWindow* window, const char* label); -CIMGUI_API void igDebugNodeWindowSettings(ImGuiWindowSettings* settings); -CIMGUI_API void igDebugNodeWindowsList(ImVector_ImGuiWindowPtr* windows, - const char* label); -CIMGUI_API void igDebugNodeWindowsListByBeginStackParent( - ImGuiWindow** windows, - int windows_size, - ImGuiWindow* parent_in_begin_stack); -CIMGUI_API void igDebugNodeViewport(ImGuiViewportP* viewport); -CIMGUI_API void igDebugRenderKeyboardPreview(ImDrawList* draw_list); -CIMGUI_API void igDebugRenderViewportThumbnail(ImDrawList* draw_list, - ImGuiViewportP* viewport, - const ImRect bb); -CIMGUI_API void igImFontAtlasUpdateConfigDataPointers(ImFontAtlas* atlas); -CIMGUI_API void igImFontAtlasBuildInit(ImFontAtlas* atlas); -CIMGUI_API void igImFontAtlasBuildSetupFont(ImFontAtlas* atlas, - ImFont* font, - ImFontConfig* font_config, - float ascent, - float descent); -CIMGUI_API void igImFontAtlasBuildPackCustomRects(ImFontAtlas* atlas, - void* stbrp_context_opaque); -CIMGUI_API void igImFontAtlasBuildFinish(ImFontAtlas* atlas); -CIMGUI_API void igImFontAtlasBuildRender8bppRectFromString( - ImFontAtlas* atlas, - int x, - int y, - int w, - int h, - const char* in_str, - char in_marker_char, - unsigned char in_marker_pixel_value); -CIMGUI_API void igImFontAtlasBuildRender32bppRectFromString( - ImFontAtlas* atlas, - int x, - int y, - int w, - int h, - const char* in_str, - char in_marker_char, - unsigned int in_marker_pixel_value); -CIMGUI_API void igImFontAtlasBuildMultiplyCalcLookupTable( - unsigned char out_table[256], - float in_multiply_factor); -CIMGUI_API void igImFontAtlasBuildMultiplyRectAlpha8( - const unsigned char table[256], - unsigned char* pixels, - int x, - int y, - int w, - int h, - int stride); - -/////////////////////////hand written functions -// no LogTextV -CIMGUI_API void igLogText(CONST char* fmt, ...); -// no appendfV -CIMGUI_API void ImGuiTextBuffer_appendf(struct ImGuiTextBuffer* buffer, - const char* fmt, - ...); -// for getting FLT_MAX in bindings -CIMGUI_API float igGET_FLT_MAX(void); -// for getting FLT_MIN in bindings -CIMGUI_API float igGET_FLT_MIN(void); - -CIMGUI_API ImVector_ImWchar* ImVector_ImWchar_create(void); -CIMGUI_API void ImVector_ImWchar_destroy(ImVector_ImWchar* self); -CIMGUI_API void ImVector_ImWchar_Init(ImVector_ImWchar* p); -CIMGUI_API void ImVector_ImWchar_UnInit(ImVector_ImWchar* p); - -#endif // CIMGUI_INCLUDED From 8449c3efb8a9179fcde7263a9b9c273914bfcc29 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 13:39:17 -0800 Subject: [PATCH 340/605] update all deps --- build.zig.zon.json | 11 ++++++++--- build.zig.zon.nix | 14 +++++++++++--- build.zig.zon.txt | 3 ++- flatpak/zig-packages.json | 12 +++++++++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/build.zig.zon.json b/build.zig.zon.json index 6ab567279..28fd4927e 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -1,4 +1,9 @@ { + "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr": { + "name": "bindings", + "url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz", + "hash": "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM=" + }, "N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ": { "name": "breakpad", "url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz", @@ -44,10 +49,10 @@ "url": "https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz", "hash": "sha256-h9T4iT704I8iSXNgj/6/lCaKgTgLp5wS6IQZaMgKohI=" }, - "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3": { + "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI": { "name": "imgui", - "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", - "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" + "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", + "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU": { "name": "iterm2_themes", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 905ee1ec3..3cfa8f280 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -82,6 +82,14 @@ fetcher.${proto}; in linkFarm name [ + { + name = "N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr"; + path = fetchZigArtifact { + name = "bindings"; + url = "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz"; + hash = "sha256-i/7FAOAJJvZ5hT7iPWfMOS08MYFzPKRwRzhlHT9wuqM="; + }; + } { name = "N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ"; path = fetchZigArtifact { @@ -155,11 +163,11 @@ in }; } { - name = "N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3"; + name = "N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI"; path = fetchZigArtifact { name = "imgui"; - url = "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz"; - hash = "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="; + url = "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz"; + hash = "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 09cf8007c..c84b24638 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -1,4 +1,5 @@ git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732 +https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz @@ -11,7 +12,6 @@ https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz -https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -32,4 +32,5 @@ https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz +https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 03907fba1..b511a4f27 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -1,4 +1,10 @@ [ + { + "type": "archive", + "url": "https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz", + "dest": "vendor/p/N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr", + "sha256": "8bfec500e00926f679853ee23d67cc392d3c3181733ca4704738651d3f70baa3" + }, { "type": "archive", "url": "https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz", @@ -55,9 +61,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", - "dest": "vendor/p/N-V-__8AAH0GaQC8a52s6vfIxg88OZgFgEW6DFxfSK4lX_l3", - "sha256": "a05fd01e04cf11ab781e28387c621d2e420f1e6044c8e27a25e603ea99ef7860" + "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", + "dest": "vendor/p/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI", + "sha256": "c816c20e8c75f3e15ae867350e79925502d1a6a85938bb1a73b8927e5f31f9cb" }, { "type": "archive", From c694517432adf6a2bd28633de52b0193153282fc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Dec 2025 13:41:58 -0800 Subject: [PATCH 341/605] update gitattributes, removed file --- .gitattributes | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 87f1eb32e..9fe672044 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,6 @@ build.zig.zon.json linguist-generated=true vendor/** linguist-vendored website/** linguist-documentation pkg/breakpad/vendor/** linguist-vendored -pkg/cimgui/vendor/** linguist-vendored pkg/glfw/wayland-headers/** linguist-vendored pkg/libintl/config.h linguist-generated=true pkg/libintl/libintl.h linguist-generated=true From 74fc48682a26bfde50ba9085568178b5713ecd86 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:31:11 +0100 Subject: [PATCH 342/605] macOS: remove unused file --- macos/Ghostty.xcodeproj/project.pbxproj | 1 - .../Sources/Helpers/DraggableWindowView.swift | 19 ------------------- 2 files changed, 20 deletions(-) delete mode 100644 macos/Sources/Helpers/DraggableWindowView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ad2c54ca9..9bd36eaad 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -152,7 +152,6 @@ Helpers/AppInfo.swift, Helpers/CodableBridge.swift, Helpers/Cursor.swift, - Helpers/DraggableWindowView.swift, Helpers/ExpiringUndoManager.swift, "Helpers/Extensions/Double+Extension.swift", "Helpers/Extensions/EventModifiers+Extension.swift", diff --git a/macos/Sources/Helpers/DraggableWindowView.swift b/macos/Sources/Helpers/DraggableWindowView.swift deleted file mode 100644 index 8d88e2f66..000000000 --- a/macos/Sources/Helpers/DraggableWindowView.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Cocoa -import SwiftUI - -struct DraggableWindowView: NSViewRepresentable { - func makeNSView(context: Context) -> DraggableWindowNSView { - return DraggableWindowNSView() - } - - func updateNSView(_ nsView: DraggableWindowNSView, context: Context) { - // No need to update anything here - } -} - -class DraggableWindowNSView: NSView { - override func mouseDown(with event: NSEvent) { - guard let window = self.window else { return } - window.performDrag(with: event) - } -} From 1249f3b88cda09ca81a2d3c983036093983982e8 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:37:08 +0100 Subject: [PATCH 343/605] macOS: temporarily disable `window.isMovable` to fix #10110 --- .../Ghostty/Surface View/SurfaceDragSource.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 21416ac75..059428b15 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -104,7 +104,11 @@ extension Ghostty { /// Whether the current drag was cancelled by pressing escape. private var dragCancelledByEscape: Bool = false - + + /// Original value of `window.isMovable` to restore + /// when the mouse exits. + private var isWindowMovable: Bool? + deinit { if let escapeMonitor { NSEvent.removeMonitor(escapeMonitor) @@ -131,10 +135,18 @@ extension Ghostty { } override func mouseEntered(with event: NSEvent) { + // Temporarily disable `isMovable` to fix + // https://github.com/ghostty-org/ghostty/issues/10110 + isWindowMovable = window?.isMovable + window?.isMovable = false onHoverChanged?(true) } override func mouseExited(with event: NSEvent) { + if let isWindowMovable { + window?.isMovable = isWindowMovable + self.isWindowMovable = nil + } onHoverChanged?(false) } From 12024ed8310c73e793bf286a18c226135487ab46 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 1 Jan 2026 08:52:53 -0500 Subject: [PATCH 344/605] macos: simplify .keyDown guard condition This condition is more naturally expressed as a `guard`. --- .../Ghostty/Surface View/SurfaceView_AppKit.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index f9a5480ec..fcff6cd8b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1181,16 +1181,9 @@ extension Ghostty { /// Special case handling for some control keys override func performKeyEquivalent(with event: NSEvent) -> Bool { - switch (event.type) { - case .keyDown: - // Continue, we care about key down events - break - - default: - // Any other key event we don't care about. I don't think its even - // possible to receive any other event type. - return false - } + // We only care about key down events. It might not even be possible + // to receive any other event type here. + guard event.type == .keyDown else { return false } // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the From c081adce052ac431bfaf80ff32835c2f301ba30b Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Thu, 1 Jan 2026 10:47:40 -0500 Subject: [PATCH 345/605] chore: write page vt test bg color with trailing row The issue is in ghostty_src/src/terminal/formatter.zig#L1117-L1129: - Cells without text are treated as "blank" (line 1117-1119) - this includes cells that only have background colors - When blank cells are emitted, they're plain spaces (line 1129) - writer.splatByteAll(' ', blank_cells) outputs spaces without any SGR styling - Background-only cells (bg_color_palette, bg_color_rgb) are marked unreachable (lines 1233-1235) because the code assumes hasText() already filtered them This means when htop draws a row like: `[green bg]CPU: 45%[red bg] [default]` The trailing cells with red background but no text get accumulated as blanks and emitted as plain spaces - losing the background color. --- src/terminal/formatter.zig | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 74bbfe482..d0e248d72 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -5819,3 +5819,57 @@ test "Page codepoint_map empty map" { const output = builder.writer.buffered(); try testing.expectEqualStrings("hello world", output); } + +test "Page VT background color on trailing blank cells" { + // This test reproduces a bug where trailing cells with background color + // but no text are emitted as plain spaces without SGR sequences. + // This causes TUIs like htop to lose background colors on rehydration. + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 20, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Simulate a TUI row: "CPU:" with text, then trailing cells with red background + // to end of line (no text after the colored region). + // \x1b[41m sets red background, then EL fills rest of row with that bg. + try s.nextSlice("CPU:\x1b[41m\x1b[K"); + // Reset colors and move to next line with different content + try s.nextSlice("\x1b[0m\r\nline2"); + + const pages = &t.screens.active.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .vt); + formatter.opts.trim = false; // Don't trim so we can see the trailing behavior + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // The output should preserve the red background SGR for trailing cells on line 1. + // Bug: the first row outputs "CPU:\r\n" only - losing the background color fill. + // The red background should appear BEFORE the newline, not after. + + // Find position of CRLF + const crlf_pos = std.mem.indexOf(u8, output, "\r\n") orelse { + // No CRLF found, fail the test + return error.TestUnexpectedResult; + }; + + // Check that red background (48;5;1) appears BEFORE the newline (on line 1) + const line1 = output[0..crlf_pos]; + const has_red_bg_line1 = std.mem.indexOf(u8, line1, "\x1b[41m") != null or + std.mem.indexOf(u8, line1, "\x1b[48;5;1m") != null; + + // This should be true but currently fails due to the bug + try testing.expect(has_red_bg_line1); +} From c89627fe752804c678b8caf4ddb2214de54289c3 Mon Sep 17 00:00:00 2001 From: Ivan Buiko Date: Wed, 31 Dec 2025 20:55:18 +0100 Subject: [PATCH 346/605] macOS: Add menu shortcut handling in macOS key event processing Allow menu bar to flash for shortcuts and handle key equivalents before checking for Ghostty key bindings --- .../Ghostty/Surface View/SurfaceView_AppKit.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index fcff6cd8b..7f33df45a 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1195,7 +1195,15 @@ extension Ghostty { return false } - // If this event as-is would result in a key binding then we send it. + // Let the menu system handle this event if we're not in a key sequence or key table. + // This allows the menu bar to flash for shortcuts like Command+V. + if keySequence.isEmpty && keyTables.isEmpty { + if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { + return true + } + } + + // If the menu didn't handle it, check Ghostty bindings for custom shortcuts. if let surface { var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) let match = (event.characters ?? "").withCString { ptr in @@ -2209,7 +2217,7 @@ extension Ghostty.SurfaceView { return NSAttributedString(string: plainString, attributes: attributes) } - + } /// Caches a value for some period of time, evicting it automatically when that time expires. From ec2612f9ce199b88f96f70f1b5cef9477534bae6 Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Wed, 31 Dec 2025 19:59:28 -0800 Subject: [PATCH 347/605] Add iTimeFocus shader uniform to track time since focus --- src/config/Config.zig | 16 ++++++++ src/renderer/generic.zig | 38 +++++++++++++---- src/renderer/shaders/shadertoy_prefix.glsl | 2 + .../shaders/test_shadertoy_focus.glsl | 41 +++++++++++++++++++ src/renderer/shadertoy.zig | 3 ++ 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 src/renderer/shaders/test_shadertoy_focus.glsl diff --git a/src/config/Config.zig b/src/config/Config.zig index 92caa5744..88f3d5375 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2783,6 +2783,22 @@ keybind: Keybinds = .{}, /// the same time as the `iTime` uniform, allowing you to compute the /// time since the change by subtracting this from `iTime`. /// +/// * `float iTimeFocus` - Timestamp when the surface last gained iFocus. +/// +/// When the surface gains focus, this is set to the current value of +/// `iTime`, similar to how `iTimeCursorChange` works. This allows you +/// to compute the time since focus was gained or lost by calculating +/// `iTime - iTimeFocus`. Use this to create animations that restart +/// when the terminal regains focus. +/// +/// * `int iFocus` - Current focus state of the surface. +/// +/// Set to 1.0 when the surface is focused, 0.0 when unfocused. This +/// allows shaders to detect unfocused state and avoid animation artifacts +/// from large time deltas caused by infrequent "deceptive frames" +/// (e.g., modifier key presses, link hover events in unfocused split panes). +/// Check `iFocus > 0` to determine if the surface is currently focused. +/// /// If the shader fails to compile, the shader will be ignored. Any errors /// related to shader compilation will not show up as configuration errors /// and only show up in the log, since shader compilation happens after diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 4ebe501f7..5fa3432a6 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -116,6 +116,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// True if the window is focused focused: bool, + /// Flag to indicate that our focus state changed for custom + /// shaders to update their state. + custom_shader_focused_changed: bool = false, + /// The most recent scrollbar state. We use this as a cache to /// determine if we need to notify the apprt that there was a /// scrollbar change. @@ -746,6 +750,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .current_cursor_color = @splat(0), .previous_cursor_color = @splat(0), .cursor_change_time = 0, + .time_focus = 0, + .focus = 1, // assume focused initially }, .bg_image_buffer = undefined, @@ -1008,8 +1014,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// /// Must be called on the render thread. pub fn setFocus(self: *Self, focus: bool) !void { + assert(self.focused != focus); + self.focused = focus; + // Flag that we need to update our custom shaders + self.custom_shader_focused_changed = true; + // If we're not focused, then we want to stop the display link // because it is a waste of resources and we can move to pure // change-driven updates. @@ -2255,6 +2266,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; + const uniforms = &self.custom_shader_uniforms; + const now = try std.time.Instant.now(); defer self.last_frame_time = now; const first_frame_time = self.first_frame_time orelse t: { @@ -2264,23 +2277,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const last_frame_time = self.last_frame_time orelse now; const since_ns: f32 = @floatFromInt(now.since(first_frame_time)); - self.custom_shader_uniforms.time = since_ns / std.time.ns_per_s; + uniforms.time = since_ns / std.time.ns_per_s; const delta_ns: f32 = @floatFromInt(now.since(last_frame_time)); - self.custom_shader_uniforms.time_delta = delta_ns / std.time.ns_per_s; + uniforms.time_delta = delta_ns / std.time.ns_per_s; - self.custom_shader_uniforms.frame += 1; + uniforms.frame += 1; const screen = self.size.screen; const padding = self.size.padding; const cell = self.size.cell; - self.custom_shader_uniforms.resolution = .{ + uniforms.resolution = .{ @floatFromInt(screen.width), @floatFromInt(screen.height), 1, }; - self.custom_shader_uniforms.channel_resolution[0] = .{ + uniforms.channel_resolution[0] = .{ @floatFromInt(screen.width), @floatFromInt(screen.height), 1, @@ -2345,8 +2358,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @as(f32, @floatFromInt(cursor.color[3])) / 255.0, }; - const uniforms = &self.custom_shader_uniforms; - const cursor_changed: bool = !std.meta.eql(new_cursor, uniforms.current_cursor) or !std.meta.eql(cursor_color, uniforms.current_cursor_color); @@ -2359,6 +2370,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type { uniforms.cursor_change_time = uniforms.time; } } + + // Update focus uniforms + uniforms.focus = @intFromBool(self.focused); + + // If we need to update the time our focus state changed + // then update it to our current frame time. This may not be + // exactly correct since it is frame time, not exact focus + // time, but focus time on its own isn't exactly correct anyways + // since it comes async from a message. + if (self.custom_shader_focused_changed and self.focused) { + uniforms.time_focus = uniforms.time; + self.custom_shader_focused_changed = false; + } } /// Convert the terminal state to GPU cells stored in CPU memory. These diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl index 6d9cf0f68..c984334f4 100644 --- a/src/renderer/shaders/shadertoy_prefix.glsl +++ b/src/renderer/shaders/shadertoy_prefix.glsl @@ -16,6 +16,8 @@ layout(binding = 1, std140) uniform Globals { uniform vec4 iCurrentCursorColor; uniform vec4 iPreviousCursorColor; uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; }; layout(binding = 0) uniform sampler2D iChannel0; diff --git a/src/renderer/shaders/test_shadertoy_focus.glsl b/src/renderer/shaders/test_shadertoy_focus.glsl new file mode 100644 index 000000000..9fc2304df --- /dev/null +++ b/src/renderer/shaders/test_shadertoy_focus.glsl @@ -0,0 +1,41 @@ +// Test shader for iTimeFocus and iFocus +// Shows border when focused, green fade that restarts on each focus gain +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + // Sample the terminal content + vec4 terminal = texture2D(iChannel0, uv); + vec3 color = terminal.rgb; + + if (iFocus > 0) { + // FOCUSED: Add border and fading green overlay + + // Calculate time since focus was gained + float timeSinceFocus = iTime - iTimeFocus; + + // Green fade: starts at 1.0 (full green), fades to 0.0 over 3 seconds + float fadeOut = max(0.0, 1.0 - (timeSinceFocus / 3.0)); + + // Add green overlay that fades out + color = mix(color, vec3(0.0, 1.0, 0.0), fadeOut * 0.4); + + // Add border (5 pixels) + float borderSize = 5.0; + vec2 pixelCoord = fragCoord; + bool isBorder = pixelCoord.x < borderSize || + pixelCoord.x > iResolution.x - borderSize || + pixelCoord.y < borderSize || + pixelCoord.y > iResolution.y - borderSize; + + if (isBorder) { + // Bright cyan border that pulses subtly + float pulse = sin(timeSinceFocus * 2.0) * 0.1 + 0.9; + color = vec3(0.0, 1.0, 1.0) * pulse; + } + } else { + // UNFOCUSED: Solid red overlay (no border) + color = mix(color, vec3(1.0, 0.0, 0.0), 0.3); + } + + fragColor = vec4(color, 1.0); +} diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index 0d096c0fc..f71200610 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -25,6 +25,8 @@ pub const Uniforms = extern struct { current_cursor_color: [4]f32 align(16), previous_cursor_color: [4]f32 align(16), cursor_change_time: f32 align(4), + time_focus: f32 align(4), + focus: i32 align(4), }; /// The target to load shaders for. @@ -412,3 +414,4 @@ test "shadertoy to glsl" { const test_crt = @embedFile("shaders/test_shadertoy_crt.glsl"); const test_invalid = @embedFile("shaders/test_shadertoy_invalid.glsl"); +const test_focus = @embedFile("shaders/test_shadertoy_focus.glsl"); From c384cd050e747579b78c738a09061e1815c5c46a Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Thu, 1 Jan 2026 15:27:26 -0800 Subject: [PATCH 348/605] Fix Mac window becomes unmovable after pane rearrangement After rearranging panes, the window becomes permanently unmovable. Grab handles temporarily set `window.isMovable = false` on hover to prevent window dragging from interfering with pane dragging. Override `viewWillMove(toWindow:)` to catch when the view is being removed from the window. This lifecycle method is called before the window reference becomes nil, allowing us to restore `window.isMovable`. --- .../Surface View/SurfaceDragSource.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 059428b15..37a69852e 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -105,22 +105,30 @@ extension Ghostty { /// Whether the current drag was cancelled by pressing escape. private var dragCancelledByEscape: Bool = false - /// Original value of `window.isMovable` to restore - /// when the mouse exits. - private var isWindowMovable: Bool? - deinit { if let escapeMonitor { NSEvent.removeMonitor(escapeMonitor) } } - + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + // Ensure this view gets the mouse event before window dragging handlers + return true + } + + override func mouseDown(with event: NSEvent) { + // Consume the mouseDown event to prevent it from propagating to the + // window's drag handler. This fixes issue #10110 where grab handles + // would drag the window instead of initiating pane drags. + // Don't call super - the drag will be initiated in mouseDragged. + } + override func updateTrackingAreas() { super.updateTrackingAreas() - + // To update our tracking area we just recreate it all. trackingAreas.forEach { removeTrackingArea($0) } - + // Add our tracking area for mouse events addTrackingArea(NSTrackingArea( rect: bounds, @@ -135,18 +143,10 @@ extension Ghostty { } override func mouseEntered(with event: NSEvent) { - // Temporarily disable `isMovable` to fix - // https://github.com/ghostty-org/ghostty/issues/10110 - isWindowMovable = window?.isMovable - window?.isMovable = false onHoverChanged?(true) } override func mouseExited(with event: NSEvent) { - if let isWindowMovable { - window?.isMovable = isWindowMovable - self.isWindowMovable = nil - } onHoverChanged?(false) } @@ -237,7 +237,7 @@ extension Ghostty { NSEvent.removeMonitor(escapeMonitor) self.escapeMonitor = nil } - + if operation == [] && !dragCancelledByEscape { let endsInWindow = NSApplication.shared.windows.contains { window in window.isVisible && window.frame.contains(screenPoint) @@ -250,7 +250,7 @@ extension Ghostty { ) } } - + isTracking = false onDragStateChanged?(false) } From 6fdbc29b9ae0eb72ca7904eedda7702aa6f0c5c7 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Thu, 1 Jan 2026 20:07:27 -0500 Subject: [PATCH 349/605] fix(formatter): preserve background colors on cells without text The VT formatter was treating cells without text as blank and emitting them as plain spaces, losing any background color styling. This caused TUIs like htop to lose their background colors when rehydrating terminal state (e.g., after detach/reattach in zmx). For styled formats (VT/HTML), cells with background colors or style_id are now emitted with proper SGR sequences and a space character instead of being accumulated as unstyled blanks. Adds handling for bg_color_palette and bg_color_rgb content tags which were previously unreachable. Reference: https://ampcode.com/threads/T-019b7a35-c3f3-73fc-adfa-00bbe9dbda3c --- src/terminal/formatter.zig | 97 +++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index d0e248d72..a107b0535 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1113,12 +1113,16 @@ pub const PageFormatter = struct { // If we have a zero value, then we accumulate a counter. We // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (!cell.hasText()) { + // char sometime later. However, for styled formats (VT, HTML), if + // the cell has styling (e.g., background color), we must emit it + // to preserve the visual appearance. + const dominated_by_style = self.opts.emit.styled() and + (!cell.isEmpty() or cell.hasStyling()); + if (!dominated_by_style and !cell.hasText()) { blank_cells += 1; continue; } - if (cell.codepoint() == ' ' and self.opts.trim) { + if (cell.codepoint() == ' ' and self.opts.trim and !dominated_by_style) { blank_cells += 1; continue; } @@ -1215,24 +1219,46 @@ pub const PageFormatter = struct { } } - try self.writeCell(tag, writer, cell); + // For styled cells without text, emit a space to carry the styling + if (cell.hasText()) { + try self.writeCell(tag, writer, cell); + } else { + try writer.writeByte(' '); + } // If we have a point map, all codepoints map to this // cell. if (self.point_map) |*map| { - var discarding: std.Io.Writer.Discarding = .init(&.{}); - try self.writeCell(tag, &discarding.writer, cell); - for (0..discarding.count) |_| map.map.append(map.alloc, .{ + const byte_count: usize = if (cell.hasText()) count: { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try self.writeCell(tag, &discarding.writer, cell); + break :count discarding.count; + } else 1; + for (0..byte_count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, }) catch return error.WriteFailed; } }, - // Unreachable since we do hasText() above - .bg_color_palette, - .bg_color_rgb, - => unreachable, + // Cells with only background color (no text). Emit a space + // with the appropriate background color SGR sequence. + .bg_color_palette => { + const index = cell.content.color_palette; + try self.emitBgColorSgr(writer, index, null, &style); + try writer.writeByte(' '); + if (self.point_map) |*map| { + map.map.append(map.alloc, .{ .x = x, .y = y }) catch return error.WriteFailed; + } + }, + .bg_color_rgb => { + const rgb = cell.content.color_rgb; + try self.emitBgColorSgr(writer, null, rgb, &style); + try writer.writeByte(' '); + if (self.point_map) |*map| { + map.map.append(map.alloc, .{ .x = x, .y = y }) catch return error.WriteFailed; + } + }, } } } @@ -1348,6 +1374,55 @@ pub const PageFormatter = struct { } } + /// Emit background color SGR sequence for bg_color_* content tags. + /// Updates the style tracking to reflect the emitted background. + fn emitBgColorSgr( + self: PageFormatter, + writer: *std.Io.Writer, + palette_index: ?u8, + rgb: ?Cell.RGB, + style: *Style, + ) std.Io.Writer.Error!void { + switch (self.opts.emit) { + .plain => {}, + .vt => { + // Close previous style if non-default + if (!style.default()) try writer.writeAll("\x1b[0m"); + // Emit background color + if (palette_index) |idx| { + try writer.print("\x1b[48;5;{d}m", .{idx}); + } else if (rgb) |c| { + try writer.print("\x1b[48;2;{d};{d};{d}m", .{ c.r, c.g, c.b }); + } + // Update style tracking - set bg_color so we know to reset later + style.* = .{}; + style.bg_color = if (palette_index) |idx| + .{ .palette = idx } + else if (rgb) |c| + .{ .rgb = .{ .r = c.r, .g = c.g, .b = c.b } } + else + .none; + }, + .html => { + // Close previous tag if needed + if (!style.default()) try writer.writeAll(""); + // Emit background color as inline style + if (palette_index) |idx| { + try writer.print("
", .{idx}); + } else if (rgb) |c| { + try writer.print("
2}{x:0>2}{x:0>2};\">", .{ c.r, c.g, c.b }); + } + style.* = .{}; + style.bg_color = if (palette_index) |idx| + .{ .palette = idx } + else if (rgb) |c| + .{ .rgb = .{ .r = c.r, .g = c.g, .b = c.b } } + else + .none; + }, + } + } + fn formatStyleOpen( self: PageFormatter, writer: *std.Io.Writer, From ea03c5c8a20ccb508f62d9da8ea54fb2d9a34729 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Thu, 1 Jan 2026 20:15:41 -0500 Subject: [PATCH 350/605] fix: reset style to prevent bleeding bg colors newline --- src/terminal/formatter.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index a107b0535..4c7fadc00 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1042,6 +1042,13 @@ pub const PageFormatter = struct { } if (blank_rows > 0) { + // Reset style before emitting newlines to prevent background + // colors from bleeding into the next line's leading cells. + if (!style.default()) { + try self.formatStyleClose(writer); + style = .{}; + } + const sequence: []const u8 = switch (self.opts.emit) { // Plaintext just uses standard newlines because newlines // on their own usually move the cursor back in anywhere From f316c969a5e72029df0454d791109964c9244639 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Thu, 1 Jan 2026 20:24:33 -0500 Subject: [PATCH 351/605] chore: update test expectation --- src/terminal/formatter.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 4c7fadc00..2e846f7c1 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -3427,7 +3427,9 @@ test "Page VT multi-line with styles" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond\x1b[0m", output); + // Note: style is reset before newline to prevent background colors from + // bleeding to the next line's leading cells. + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\x1b[0m\r\n\x1b[0m\x1b[3msecond\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); From 4ea669562e4104b6991bd5a0c190a098c27a0660 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 3 Jan 2026 03:57:44 +0100 Subject: [PATCH 352/605] fix: use flush instead of end on stdout in code generators for Windows compatibility --- src/unicode/props_uucode.zig | 4 +++- src/unicode/symbols_uucode.zig | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index 2440d437c..9813ecf43 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -87,7 +87,9 @@ pub fn main() !void { var buf: [4096]u8 = undefined; var stdout = std.fs.File.stdout().writer(&buf); try t.writeZig(&stdout.interface); - try stdout.end(); + // Use flush instead of end because stdout is a pipe when captured by + // the build system, and pipes cannot be truncated (especially on Windows). + try stdout.interface.flush(); // Uncomment when manually debugging to see our table sizes. // std.log.warn("stage1={} stage2={} stage3={}", .{ diff --git a/src/unicode/symbols_uucode.zig b/src/unicode/symbols_uucode.zig index 8cbd59211..1a7e20252 100644 --- a/src/unicode/symbols_uucode.zig +++ b/src/unicode/symbols_uucode.zig @@ -34,7 +34,9 @@ pub fn main() !void { var buf: [4096]u8 = undefined; var stdout = std.fs.File.stdout().writer(&buf); try t.writeZig(&stdout.interface); - try stdout.end(); + // Use flush instead of end because stdout is a pipe when captured by + // the build system, and pipes cannot be truncated (especially on Windows). + try stdout.interface.flush(); // Uncomment when manually debugging to see our table sizes. // std.log.warn("stage1={} stage2={} stage3={}", .{ From e2de0bfd9338321e59c0e157bab0696f4ce8a9a8 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 3 Jan 2026 13:44:29 +0100 Subject: [PATCH 353/605] fix: clarify error codes in comment --- src/unicode/props_uucode.zig | 3 ++- src/unicode/symbols_uucode.zig | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index 9813ecf43..bee942422 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -88,7 +88,8 @@ pub fn main() !void { var stdout = std.fs.File.stdout().writer(&buf); try t.writeZig(&stdout.interface); // Use flush instead of end because stdout is a pipe when captured by - // the build system, and pipes cannot be truncated (especially on Windows). + // the build system, and pipes cannot be truncated (Windows returns + // INVALID_PARAMETER, Linux returns EINVAL). try stdout.interface.flush(); // Uncomment when manually debugging to see our table sizes. diff --git a/src/unicode/symbols_uucode.zig b/src/unicode/symbols_uucode.zig index 1a7e20252..794ca5bab 100644 --- a/src/unicode/symbols_uucode.zig +++ b/src/unicode/symbols_uucode.zig @@ -35,7 +35,8 @@ pub fn main() !void { var stdout = std.fs.File.stdout().writer(&buf); try t.writeZig(&stdout.interface); // Use flush instead of end because stdout is a pipe when captured by - // the build system, and pipes cannot be truncated (especially on Windows). + // the build system, and pipes cannot be truncated (Windows returns + // INVALID_PARAMETER, Linux returns EINVAL). try stdout.interface.flush(); // Uncomment when manually debugging to see our table sizes. From d7972cb8b7219c0a9a489a8f46abf4939dfb34d4 Mon Sep 17 00:00:00 2001 From: Tommy Brunn Date: Sat, 3 Jan 2026 18:36:43 +0100 Subject: [PATCH 354/605] gtk: Session Search Gtk implementation of #9945. Fixes #9948. This adds session search to the command palette on Gtk, allowing you to jump to any surface by title or working directory. The main difference to the Mac OS implementation is that tabs do not have colors by which to search. --- src/apprt/gtk/class/application.zig | 30 ++ src/apprt/gtk/class/command_palette.zig | 398 +++++++++++++++++++++--- src/apprt/gtk/class/split_tree.zig | 6 + src/apprt/gtk/class/window.zig | 52 ++++ 4 files changed, 440 insertions(+), 46 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index b16bce049..eb83fa8a2 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1176,6 +1176,36 @@ pub const Application = extern struct { return self.private().config.ref(); } + /// Collect all surfaces from all windows in the application. + /// The caller must unref each surface and window and deinit the list. + pub fn collectAllSurfaces( + self: *Self, + alloc: Allocator, + ) !std.ArrayList(Window.SurfaceInfo) { + var all_surfaces: std.ArrayList(Window.SurfaceInfo) = .{}; + errdefer { + for (all_surfaces.items) |info| { + info.surface.unref(); + info.window.unref(); + } + all_surfaces.deinit(alloc); + } + + const windows = self.as(gtk.Application).getWindows(); + var it: ?*glib.List = windows; + while (it) |node| : (it = node.f_next) { + const window_widget = @as(*gtk.Window, @ptrCast(@alignCast(node.f_data))); + const window = gobject.ext.cast(Window, window_widget) orelse continue; + + var window_surfaces = try window.collectSurfaces(alloc); + defer window_surfaces.deinit(alloc); + + try all_surfaces.appendSlice(alloc, window_surfaces.items); + } + + return all_surfaces; + } + /// Set the configuration for this application. The reference count /// is increased on the new configuration and the old one is /// unreferenced. diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index 6da49115e..b5d4b34d4 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -13,6 +13,8 @@ const key = @import("../key.zig"); const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Window = @import("window.zig").Window; +const Surface = @import("surface.zig").Surface; +const Tab = @import("tab.zig").Tab; const Config = @import("config.zig").Config; const log = std.log.scoped(.gtk_ghostty_command_palette); @@ -146,34 +148,155 @@ pub const CommandPalette = extern struct { return; }; - const cfg = config.get(); - // Clear existing binds priv.source.removeAll(); + const alloc = Application.default().allocator(); + var commands: std.ArrayList(*Command) = .{}; + defer { + for (commands.items) |cmd| cmd.unref(); + commands.deinit(alloc); + } + + self.collectJumpCommands(config, &commands) catch |err| { + log.warn("failed to collect jump commands: {}", .{err}); + }; + + self.collectRegularCommands(config, &commands, alloc); + + // Sort commands + std.mem.sort(*Command, commands.items, {}, struct { + fn lessThan(_: void, a: *Command, b: *Command) bool { + return compareCommands(a, b); + } + }.lessThan); + + for (commands.items) |cmd| { + const cmd_ref = cmd.as(gobject.Object); + priv.source.append(cmd_ref); + } + } + + /// Collect regular commands from configuration, filtering out unsupported actions. + fn collectRegularCommands( + self: *CommandPalette, + config: *Config, + commands: *std.ArrayList(*Command), + alloc: std.mem.Allocator, + ) void { + _ = self; + const cfg = config.get(); + for (cfg.@"command-palette-entry".value.items) |command| { // Filter out actions that are not implemented or don't make sense // for GTK. - switch (command.action) { - .close_all_windows, - .toggle_secure_input, - .check_for_updates, - .redo, - .undo, - .reset_window_size, - .toggle_window_float_on_top, - => continue, - - else => {}, - } + if (!isActionSupportedOnGtk(command.action)) continue; const cmd = Command.new(config, command); - const cmd_ref = cmd.as(gobject.Object); - priv.source.append(cmd_ref); - cmd_ref.unref(); + commands.append(alloc, cmd) catch |err| { + log.warn("failed to add command to list: {}", .{err}); + cmd.unref(); + continue; + }; } } + /// Check if an action is supported on GTK. + fn isActionSupportedOnGtk(action: input.Binding.Action) bool { + return switch (action) { + .close_all_windows, + .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, + => false, + + else => true, + }; + } + + /// Collect jump commands for all surfaces across all windows. + fn collectJumpCommands( + self: *CommandPalette, + config: *Config, + commands: *std.ArrayList(*Command), + ) !void { + _ = self; + const app = Application.default(); + const alloc = app.allocator(); + + // Collect all surfaces from all windows + var surfaces = app.collectAllSurfaces(alloc) catch |err| { + log.warn("failed to collect surfaces for jump commands: {}", .{err}); + return; + }; + defer { + for (surfaces.items) |info| { + info.surface.unref(); + info.window.unref(); + } + surfaces.deinit(alloc); + } + + for (surfaces.items) |info| { + const cmd = Command.newJump(config, info.surface, info.window); + errdefer cmd.unref(); + try commands.append(alloc, cmd); + } + } + + /// Compare two commands for sorting. + /// Sorts alphabetically by title (case-insensitive), with colon normalization + /// so "Foo:" sorts before "Foo Bar:". Uses sort_key as tie-breaker. + fn compareCommands(a: *Command, b: *Command) bool { + const a_title = a.propGetTitle() orelse return false; + const b_title = b.propGetTitle() orelse return true; + + // Compare case-insensitively with colon normalization + var i: usize = 0; + var j: usize = 0; + while (i < a_title.len and j < b_title.len) { + // Get characters, replacing ':' with '\t' + const a_char = if (a_title[i] == ':') '\t' else a_title[i]; + const b_char = if (b_title[j] == ':') '\t' else b_title[j]; + + const a_lower = std.ascii.toLower(a_char); + const b_lower = std.ascii.toLower(b_char); + + if (a_lower != b_lower) { + return a_lower < b_lower; + } + + i += 1; + j += 1; + } + + // If one title is a prefix of the other, shorter one comes first + if (a_title.len != b_title.len) { + return a_title.len < b_title.len; + } + + // Titles are equal - use sort_key as tie-breaker if both have one + const a_priv = a.private(); + const b_priv = b.private(); + + const a_sort_key = switch (a_priv.data) { + .regular => 0, + .jump => |*ja| ja.sort_key, + }; + const b_sort_key = switch (b_priv.data) { + .regular => 0, + .jump => |*jb| jb.sort_key, + }; + + if (a_sort_key != 0 and b_sort_key != 0) { + return a_sort_key < b_sort_key; + } + return false; + } + fn close(self: *CommandPalette) void { const priv = self.private(); _ = priv.dialog.close(); @@ -234,6 +357,16 @@ pub const CommandPalette = extern struct { self.close(); const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return; + + // Handle jump commands differently + if (cmd.isJump()) { + const surface = cmd.getJumpSurface() orelse return; + const window = cmd.getJumpWindow() orelse return; + focusSurface(surface, window); + return; + } + + // Regular command - emit trigger signal const action = cmd.getAction() orelse return; // Signal that an an action has been selected. Signals are synchronous @@ -413,22 +546,30 @@ const Command = extern struct { }; pub const Private = struct { - /// The configuration we should use to get keybindings. config: ?*Config = null, - - /// Arena used to manage our allocations. arena: ArenaAllocator, - - /// The command. - command: ?input.Command = null, - - /// Cache the formatted action. - action: ?[:0]const u8 = null, - - /// Cache the formatted action_key. - action_key: ?[:0]const u8 = null, + data: CommandData, pub var offset: c_int = 0; + + pub const CommandData = union(enum) { + regular: RegularData, + jump: JumpData, + }; + + pub const RegularData = struct { + command: input.Command, + action: ?[:0]const u8 = null, + action_key: ?[:0]const u8 = null, + }; + + pub const JumpData = struct { + surface: *Surface, + window: *Window, + title: ?[:0]const u8 = null, + description: ?[:0]const u8 = null, + sort_key: usize, + }; }; pub fn new(config: *Config, command: input.Command) *Self { @@ -437,7 +578,34 @@ const Command = extern struct { }); const priv = self.private(); - priv.command = command.clone(priv.arena.allocator()) catch null; + const cloned = command.clone(priv.arena.allocator()) catch { + self.unref(); + return undefined; + }; + + priv.data = .{ + .regular = .{ + .command = cloned, + }, + }; + + return self; + } + + /// Create a new jump command that focuses a specific surface. + pub fn newJump(config: *Config, surface: *Surface, window: *Window) *Self { + const self = gobject.ext.newInstance(Self, .{ + .config = config, + }); + + const priv = self.private(); + priv.data = .{ + .jump = .{ + .surface = surface.ref(), + .window = window.ref(), + .sort_key = @intFromPtr(surface), + }, + }; return self; } @@ -459,6 +627,14 @@ const Command = extern struct { priv.config = null; } + switch (priv.data) { + .regular => {}, + .jump => |*j| { + j.surface.unref(); + j.window.unref(); + }, + } + gobject.Object.virtual_methods.dispose.call( Class.parent, self.as(Parent), @@ -481,52 +657,116 @@ const Command = extern struct { fn propGetActionKey(self: *Self) ?[:0]const u8 { const priv = self.private(); - if (priv.action_key) |action_key| return action_key; + const regular = switch (priv.data) { + .regular => |*r| r, + .jump => return null, + }; - const command = priv.command orelse return null; + if (regular.action_key) |action_key| return action_key; - priv.action_key = std.fmt.allocPrintSentinel( + regular.action_key = std.fmt.allocPrintSentinel( priv.arena.allocator(), "{f}", - .{command.action}, + .{regular.command.action}, 0, ) catch null; - return priv.action_key; + return regular.action_key; } fn propGetAction(self: *Self) ?[:0]const u8 { const priv = self.private(); - if (priv.action) |action| return action; + const regular = switch (priv.data) { + .regular => |*r| r, + .jump => return null, + }; - const command = priv.command orelse return null; + if (regular.action) |action| return action; const cfg = if (priv.config) |config| config.get() else return null; const keybinds = cfg.keybind.set; const alloc = priv.arena.allocator(); - priv.action = action: { + regular.action = action: { var buf: [64]u8 = undefined; - const trigger = keybinds.getTrigger(command.action) orelse break :action null; + const trigger = keybinds.getTrigger(regular.command.action) orelse break :action null; const accel = (key.accelFromTrigger(&buf, trigger) catch break :action null) orelse break :action null; break :action alloc.dupeZ(u8, accel) catch return null; }; - return priv.action; + return regular.action; } fn propGetTitle(self: *Self) ?[:0]const u8 { const priv = self.private(); - const command = priv.command orelse return null; - return command.title; + + switch (priv.data) { + .regular => |*r| return r.command.title, + .jump => |*j| { + if (j.title) |title| return title; + + const alloc = priv.arena.allocator(); + + var val = gobject.ext.Value.new(?[:0]const u8); + defer val.unset(); + gobject.Object.getProperty( + j.surface.as(gobject.Object), + "title", + &val, + ); + const surface_title = gobject.ext.Value.get(&val, ?[:0]const u8) orelse "Untitled"; + + j.title = std.fmt.allocPrintSentinel( + alloc, + "Focus: {s}", + .{surface_title}, + 0, + ) catch null; + + return j.title; + }, + } } fn propGetDescription(self: *Self) ?[:0]const u8 { const priv = self.private(); - const command = priv.command orelse return null; - return command.description; + + switch (priv.data) { + .regular => |*r| return r.command.description, + .jump => |*j| { + if (j.description) |desc| return desc; + + const alloc = priv.arena.allocator(); + + var title_val = gobject.ext.Value.new(?[:0]const u8); + defer title_val.unset(); + gobject.Object.getProperty( + j.surface.as(gobject.Object), + "title", + &title_val, + ); + const title = gobject.ext.Value.get(&title_val, ?[:0]const u8) orelse "Untitled"; + + var pwd_val = gobject.ext.Value.new(?[:0]const u8); + defer pwd_val.unset(); + gobject.Object.getProperty( + j.surface.as(gobject.Object), + "pwd", + &pwd_val, + ); + const pwd = gobject.ext.Value.get(&pwd_val, ?[:0]const u8); + + if (pwd) |p| { + if (std.mem.indexOf(u8, title, p) == null) { + j.description = alloc.dupeZ(u8, p) catch null; + } + } + + return j.description; + }, + } } //--------------------------------------------------------------- @@ -536,8 +776,34 @@ const Command = extern struct { /// allocated data that will be freed when this object is. pub fn getAction(self: *Self) ?input.Binding.Action { const priv = self.private(); - const command = priv.command orelse return null; - return command.action; + return switch (priv.data) { + .regular => |*r| r.command.action, + .jump => null, + }; + } + + /// Check if this is a jump command. + pub fn isJump(self: *Self) bool { + const priv = self.private(); + return priv.data == .jump; + } + + /// Get the jump surface. + pub fn getJumpSurface(self: *Self) ?*Surface { + const priv = self.private(); + return switch (priv.data) { + .regular => null, + .jump => |*j| j.surface, + }; + } + + /// Get the jump window. + pub fn getJumpWindow(self: *Self) ?*Window { + const priv = self.private(); + return switch (priv.data) { + .regular => null, + .jump => |*j| j.window, + }; } //--------------------------------------------------------------- @@ -567,3 +833,43 @@ const Command = extern struct { } }; }; + +/// Focus a surface in a window, bringing the window to front and switching +/// to the appropriate tab if needed. +fn focusSurface(surface: *Surface, window: *Window) void { + window.as(gtk.Window).present(); + + // Find the tab containing this surface + const tab_view = window.getTabView(); + const n = tab_view.getNPages(); + if (n < 0) return; + + for (0..@intCast(n)) |i| { + const page = tab_view.getNthPage(@intCast(i)); + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse continue; + + // Check if this tab contains the surface + const tree = tab.getSurfaceTree() orelse continue; + var it = tree.iterator(); + var found = false; + while (it.next()) |entry| { + if (entry.view == surface) { + found = true; + break; + } + } + + if (found) { + // Switch to this tab + tab_view.setSelectedPage(page); + + // Look up the split tree and update last focused surface + const split_tree = tab.getSplitTree(); + split_tree.setLastFocusedSurface(surface); + + surface.grabFocus(); + break; + } + } +} diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 46b3268d9..e203879ca 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -478,6 +478,12 @@ pub const SplitTree = extern struct { return surface; } + /// Sets the last focused surface in the tree. This is used to track + /// which surface should be considered "active" for the split tree. + pub fn setLastFocusedSurface(self: *Self, surface: ?*Surface) void { + self.private().last_focused.set(surface); + } + /// Returns whether any of the surfaces in the tree have a parent. /// This is important because we can only rebuild the widget tree /// when every surface has no parent. diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 77fd2eea5..638db87b8 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -804,6 +804,58 @@ pub const Window = extern struct { return self.private().config; } + /// Information about a surface in a window. + pub const SurfaceInfo = struct { + surface: *Surface, + window: *Window, + }; + + /// Collect all surfaces from all tabs in this window. + /// The caller must unref each surface and window when done. + pub fn collectSurfaces( + self: *Self, + alloc: std.mem.Allocator, + ) !std.ArrayList(SurfaceInfo) { + var surfaces: std.ArrayList(SurfaceInfo) = .{}; + errdefer { + for (surfaces.items) |info| { + info.surface.unref(); + info.window.unref(); + } + surfaces.deinit(alloc); + } + + const priv = self.private(); + const n = priv.tab_view.getNPages(); + if (n < 0) return surfaces; + + for (0..@intCast(n)) |i| { + const page = priv.tab_view.getNthPage(@intCast(i)); + const child = page.getChild(); + const tab = gobject.ext.cast(Tab, child) orelse { + log.warn("unexpected non-Tab child in tab view", .{}); + continue; + }; + + const tree = tab.getSurfaceTree() orelse continue; + + var it = tree.iterator(); + while (it.next()) |entry| { + try surfaces.append(alloc, .{ + .surface = entry.view.ref(), + .window = self.ref(), + }); + } + } + + return surfaces; + } + + /// Get the tab view for this window. + pub fn getTabView(self: *Self) *adw.TabView { + return self.private().tab_view; + } + /// Get the current window decoration value for this window. pub fn getWindowDecoration(self: *Self) configpkg.WindowDecoration { const priv = self.private(); From 8754c53e0e538feb5ea74b9dac274784465f6ba0 Mon Sep 17 00:00:00 2001 From: Tommy Brunn Date: Sat, 3 Jan 2026 22:07:57 +0100 Subject: [PATCH 355/605] gtk: Get jump command title from Surface title --- src/apprt/gtk/class/command_palette.zig | 29 +++---------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index b5d4b34d4..f022f13a7 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -708,15 +708,7 @@ const Command = extern struct { if (j.title) |title| return title; const alloc = priv.arena.allocator(); - - var val = gobject.ext.Value.new(?[:0]const u8); - defer val.unset(); - gobject.Object.getProperty( - j.surface.as(gobject.Object), - "title", - &val, - ); - const surface_title = gobject.ext.Value.get(&val, ?[:0]const u8) orelse "Untitled"; + const surface_title = j.surface.getTitle() orelse "Untitled"; j.title = std.fmt.allocPrintSentinel( alloc, @@ -740,23 +732,8 @@ const Command = extern struct { const alloc = priv.arena.allocator(); - var title_val = gobject.ext.Value.new(?[:0]const u8); - defer title_val.unset(); - gobject.Object.getProperty( - j.surface.as(gobject.Object), - "title", - &title_val, - ); - const title = gobject.ext.Value.get(&title_val, ?[:0]const u8) orelse "Untitled"; - - var pwd_val = gobject.ext.Value.new(?[:0]const u8); - defer pwd_val.unset(); - gobject.Object.getProperty( - j.surface.as(gobject.Object), - "pwd", - &pwd_val, - ); - const pwd = gobject.ext.Value.get(&pwd_val, ?[:0]const u8); + const title = j.surface.getTitle() orelse "Untitled"; + const pwd = j.surface.getPwd(); if (pwd) |p| { if (std.mem.indexOf(u8, title, p) == null) { From d3aa68413974c6e5b932d52fdf04372c6e494c8b Mon Sep 17 00:00:00 2001 From: Tommy Brunn Date: Sat, 3 Jan 2026 22:15:23 +0100 Subject: [PATCH 356/605] gtk: Remove window reference from jump commands Removes redundant implementations that is already present in the core application to work with surfaces. --- src/apprt/gtk/class/application.zig | 30 -------- src/apprt/gtk/class/command_palette.zig | 93 +++++-------------------- src/apprt/gtk/class/split_tree.zig | 6 -- src/apprt/gtk/class/window.zig | 47 ------------- 4 files changed, 16 insertions(+), 160 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eb83fa8a2..b16bce049 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1176,36 +1176,6 @@ pub const Application = extern struct { return self.private().config.ref(); } - /// Collect all surfaces from all windows in the application. - /// The caller must unref each surface and window and deinit the list. - pub fn collectAllSurfaces( - self: *Self, - alloc: Allocator, - ) !std.ArrayList(Window.SurfaceInfo) { - var all_surfaces: std.ArrayList(Window.SurfaceInfo) = .{}; - errdefer { - for (all_surfaces.items) |info| { - info.surface.unref(); - info.window.unref(); - } - all_surfaces.deinit(alloc); - } - - const windows = self.as(gtk.Application).getWindows(); - var it: ?*glib.List = windows; - while (it) |node| : (it = node.f_next) { - const window_widget = @as(*gtk.Window, @ptrCast(@alignCast(node.f_data))); - const window = gobject.ext.cast(Window, window_widget) orelse continue; - - var window_surfaces = try window.collectSurfaces(alloc); - defer window_surfaces.deinit(alloc); - - try all_surfaces.appendSlice(alloc, window_surfaces.items); - } - - return all_surfaces; - } - /// Set the configuration for this application. The reference count /// is increased on the new configuration and the old one is /// unreferenced. diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index f022f13a7..5e942e4a3 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -192,10 +192,14 @@ pub const CommandPalette = extern struct { // for GTK. if (!isActionSupportedOnGtk(command.action)) continue; - const cmd = Command.new(config, command); + const cmd = Command.new(config, command) catch |err| { + log.warn("failed to create command: {}", .{err}); + continue; + }; + errdefer cmd.unref(); + commands.append(alloc, cmd) catch |err| { log.warn("failed to add command to list: {}", .{err}); - cmd.unref(); continue; }; } @@ -227,21 +231,11 @@ pub const CommandPalette = extern struct { const app = Application.default(); const alloc = app.allocator(); - // Collect all surfaces from all windows - var surfaces = app.collectAllSurfaces(alloc) catch |err| { - log.warn("failed to collect surfaces for jump commands: {}", .{err}); - return; - }; - defer { - for (surfaces.items) |info| { - info.surface.unref(); - info.window.unref(); - } - surfaces.deinit(alloc); - } - - for (surfaces.items) |info| { - const cmd = Command.newJump(config, info.surface, info.window); + // Get all surfaces from the core app + const core_app = app.core(); + for (core_app.surfaces.items) |apprt_surface| { + const surface = apprt_surface.gobj(); + const cmd = Command.newJump(config, surface); errdefer cmd.unref(); try commands.append(alloc, cmd); } @@ -361,8 +355,7 @@ pub const CommandPalette = extern struct { // Handle jump commands differently if (cmd.isJump()) { const surface = cmd.getJumpSurface() orelse return; - const window = cmd.getJumpWindow() orelse return; - focusSurface(surface, window); + surface.present(); return; } @@ -565,23 +558,20 @@ const Command = extern struct { pub const JumpData = struct { surface: *Surface, - window: *Window, title: ?[:0]const u8 = null, description: ?[:0]const u8 = null, sort_key: usize, }; }; - pub fn new(config: *Config, command: input.Command) *Self { + pub fn new(config: *Config, command: input.Command) Allocator.Error!*Self { const self = gobject.ext.newInstance(Self, .{ .config = config, }); + errdefer self.unref(); const priv = self.private(); - const cloned = command.clone(priv.arena.allocator()) catch { - self.unref(); - return undefined; - }; + const cloned = try command.clone(priv.arena.allocator()); priv.data = .{ .regular = .{ @@ -593,7 +583,7 @@ const Command = extern struct { } /// Create a new jump command that focuses a specific surface. - pub fn newJump(config: *Config, surface: *Surface, window: *Window) *Self { + pub fn newJump(config: *Config, surface: *Surface) *Self { const self = gobject.ext.newInstance(Self, .{ .config = config, }); @@ -602,7 +592,6 @@ const Command = extern struct { priv.data = .{ .jump = .{ .surface = surface.ref(), - .window = window.ref(), .sort_key = @intFromPtr(surface), }, }; @@ -631,7 +620,6 @@ const Command = extern struct { .regular => {}, .jump => |*j| { j.surface.unref(); - j.window.unref(); }, } @@ -774,15 +762,6 @@ const Command = extern struct { }; } - /// Get the jump window. - pub fn getJumpWindow(self: *Self) ?*Window { - const priv = self.private(); - return switch (priv.data) { - .regular => null, - .jump => |*j| j.window, - }; - } - //--------------------------------------------------------------- const C = Common(Self, Private); @@ -810,43 +789,3 @@ const Command = extern struct { } }; }; - -/// Focus a surface in a window, bringing the window to front and switching -/// to the appropriate tab if needed. -fn focusSurface(surface: *Surface, window: *Window) void { - window.as(gtk.Window).present(); - - // Find the tab containing this surface - const tab_view = window.getTabView(); - const n = tab_view.getNPages(); - if (n < 0) return; - - for (0..@intCast(n)) |i| { - const page = tab_view.getNthPage(@intCast(i)); - const child = page.getChild(); - const tab = gobject.ext.cast(Tab, child) orelse continue; - - // Check if this tab contains the surface - const tree = tab.getSurfaceTree() orelse continue; - var it = tree.iterator(); - var found = false; - while (it.next()) |entry| { - if (entry.view == surface) { - found = true; - break; - } - } - - if (found) { - // Switch to this tab - tab_view.setSelectedPage(page); - - // Look up the split tree and update last focused surface - const split_tree = tab.getSplitTree(); - split_tree.setLastFocusedSurface(surface); - - surface.grabFocus(); - break; - } - } -} diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index e203879ca..46b3268d9 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -478,12 +478,6 @@ pub const SplitTree = extern struct { return surface; } - /// Sets the last focused surface in the tree. This is used to track - /// which surface should be considered "active" for the split tree. - pub fn setLastFocusedSurface(self: *Self, surface: ?*Surface) void { - self.private().last_focused.set(surface); - } - /// Returns whether any of the surfaces in the tree have a parent. /// This is important because we can only rebuild the widget tree /// when every surface has no parent. diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 638db87b8..8df250447 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -804,53 +804,6 @@ pub const Window = extern struct { return self.private().config; } - /// Information about a surface in a window. - pub const SurfaceInfo = struct { - surface: *Surface, - window: *Window, - }; - - /// Collect all surfaces from all tabs in this window. - /// The caller must unref each surface and window when done. - pub fn collectSurfaces( - self: *Self, - alloc: std.mem.Allocator, - ) !std.ArrayList(SurfaceInfo) { - var surfaces: std.ArrayList(SurfaceInfo) = .{}; - errdefer { - for (surfaces.items) |info| { - info.surface.unref(); - info.window.unref(); - } - surfaces.deinit(alloc); - } - - const priv = self.private(); - const n = priv.tab_view.getNPages(); - if (n < 0) return surfaces; - - for (0..@intCast(n)) |i| { - const page = priv.tab_view.getNthPage(@intCast(i)); - const child = page.getChild(); - const tab = gobject.ext.cast(Tab, child) orelse { - log.warn("unexpected non-Tab child in tab view", .{}); - continue; - }; - - const tree = tab.getSurfaceTree() orelse continue; - - var it = tree.iterator(); - while (it.next()) |entry| { - try surfaces.append(alloc, .{ - .surface = entry.view.ref(), - .window = self.ref(), - }); - } - } - - return surfaces; - } - /// Get the tab view for this window. pub fn getTabView(self: *Self) *adw.TabView { return self.private().tab_view; From bd20f844aa80e5b4a9960c24e6378f12f88e06e1 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 4 Jan 2026 00:16:28 +0000 Subject: [PATCH 357/605] 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 c9c3093b6..c3e2de9f8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", - .hash = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz", + .hash = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 28fd4927e..022a7401e 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU": { + "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", - "hash": "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz", + "hash": "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 3cfa8f280..d845ea10e 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU"; + name = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz"; - hash = "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz"; + hash = "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index c84b24638..0eeb7c5f1 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index b511a4f27..7749eb67e 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", - "dest": "vendor/p/N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU", - "sha256": "70c2040d91587629426af84be69590ea345eac65054992232f06f1368b2c49e8" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz", + "dest": "vendor/p/N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV", + "sha256": "6d6290c51820cff64bafac3466dfe14e79c001bd77550595ef449c0ac3452199" }, { "type": "archive", From f5d7108c51240724e71ed85de06ee64d8cba014f Mon Sep 17 00:00:00 2001 From: Tommy Brunn Date: Sun, 4 Jan 2026 08:36:40 +0100 Subject: [PATCH 358/605] gtk: Remove strong reference to surface from command palette --- src/apprt/gtk/class/command_palette.zig | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index 5e942e4a3..af1ef8e5e 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -10,6 +10,7 @@ const gtk = @import("gtk"); const input = @import("../../../input.zig"); const gresource = @import("../build/gresource.zig"); const key = @import("../key.zig"); +const WeakRef = @import("../weak_ref.zig").WeakRef; const Common = @import("../class.zig").Common; const Application = @import("application.zig").Application; const Window = @import("window.zig").Window; @@ -355,6 +356,7 @@ pub const CommandPalette = extern struct { // Handle jump commands differently if (cmd.isJump()) { const surface = cmd.getJumpSurface() orelse return; + defer surface.unref(); surface.present(); return; } @@ -557,7 +559,7 @@ const Command = extern struct { }; pub const JumpData = struct { - surface: *Surface, + surface: WeakRef(Surface) = .empty, title: ?[:0]const u8 = null, description: ?[:0]const u8 = null, sort_key: usize, @@ -591,10 +593,10 @@ const Command = extern struct { const priv = self.private(); priv.data = .{ .jump = .{ - .surface = surface.ref(), .sort_key = @intFromPtr(surface), }, }; + priv.data.jump.surface.set(surface); return self; } @@ -619,7 +621,7 @@ const Command = extern struct { switch (priv.data) { .regular => {}, .jump => |*j| { - j.surface.unref(); + j.surface.set(null); }, } @@ -695,8 +697,11 @@ const Command = extern struct { .jump => |*j| { if (j.title) |title| return title; + const surface = j.surface.get() orelse return null; + defer surface.unref(); + const alloc = priv.arena.allocator(); - const surface_title = j.surface.getTitle() orelse "Untitled"; + const surface_title = surface.getTitle() orelse "Untitled"; j.title = std.fmt.allocPrintSentinel( alloc, @@ -718,10 +723,13 @@ const Command = extern struct { .jump => |*j| { if (j.description) |desc| return desc; + const surface = j.surface.get() orelse return null; + defer surface.unref(); + const alloc = priv.arena.allocator(); - const title = j.surface.getTitle() orelse "Untitled"; - const pwd = j.surface.getPwd(); + const title = surface.getTitle() orelse "Untitled"; + const pwd = surface.getPwd(); if (pwd) |p| { if (std.mem.indexOf(u8, title, p) == null) { @@ -753,12 +761,13 @@ const Command = extern struct { return priv.data == .jump; } - /// Get the jump surface. + /// Get the jump surface. Returns a strong reference that the caller + /// must unref when done, or null if the surface has been destroyed. pub fn getJumpSurface(self: *Self) ?*Surface { const priv = self.private(); return switch (priv.data) { .regular => null, - .jump => |*j| j.surface, + .jump => |*j| j.surface.get(), }; } From 742c9ca390a199eae1f1796ebb6469b96b2089d3 Mon Sep 17 00:00:00 2001 From: Jan Klass Date: Sun, 4 Jan 2026 23:44:04 +0100 Subject: [PATCH 359/605] i18n: Add missing German translation --- po/de_DE.UTF-8.po | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/po/de_DE.UTF-8.po b/po/de_DE.UTF-8.po index c7fc6643f..27b353e38 100644 --- a/po/de_DE.UTF-8.po +++ b/po/de_DE.UTF-8.po @@ -3,6 +3,7 @@ # 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. +# Jan Klass , 2026. # msgid "" msgstr "" @@ -320,4 +321,4 @@ msgstr "Ghostty-Entwickler" #: src/apprt/gtk/inspector.zig:144 msgid "Ghostty: Terminal Inspector" -msgstr "" +msgstr "Ghostty: Terminal Inspektor" From 1d4a5d91e08eb09eb1b1b984278c8ec980a3b2ca Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Sun, 4 Jan 2026 21:15:29 -0500 Subject: [PATCH 360/605] More debugging for position.y differences --- src/font/shaper/coretext.zig | 80 ++++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 09f123b4b..4b8b05419 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -382,15 +382,16 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current x offset within a run. + // This keeps track of the current x offset (sum of advance.width) for + // a run. var run_offset_x: f64 = 0.0; - // This keeps track of the current x offset and cluster for a cell. + // This keeps track of the cell starting x and cluster. var cell_offset: CellOffset = .{}; // For debugging positions, turn this on: - //var run_offset_y: f64 = 0.0; - //var cell_offset_y: f64 = 0.0; + var run_offset_y: f64 = 0.0; + var cell_offset_y: f64 = 0.0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -448,11 +449,11 @@ pub const Shaper = struct { }; // For debugging positions, turn this on: - //cell_offset_y = run_offset_y; + cell_offset_y = run_offset_y; } // For debugging positions, turn this on: - //try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); + try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); const x_offset = position.x - cell_offset.x; @@ -468,7 +469,7 @@ pub const Shaper = struct { run_offset_x += advance.width; // For debugging positions, turn this on: - //run_offset_y += advance.height; + run_offset_y += advance.height; } } @@ -654,21 +655,31 @@ pub const Shaper = struct { const advance_y_offset = run_offset_y - cell_offset_y; const x_offset_diff = x_offset - advance_x_offset; const y_offset_diff = position.y - advance_y_offset; + const positions_differ = @abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001; + const old_offset_y = position.y - cell_offset_y; + const position_y_differs = @abs(cell_offset_y) > 0.0001; - if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + if (positions_differ or position_y_differs) { var allocating = std.Io.Writer.Allocating.init(alloc); const writer = &allocating.writer; const codepoints = state.codepoints.items; + var last_cluster: ?u32 = null; for (codepoints) |cp| { - if (cp.cluster == cell_offset.cluster and + if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { + if (last_cluster) |last| { + if (cp.cluster != last) { + try writer.writeAll(" "); + } + } try writer.print("\\u{{{x}}}", .{cp.codepoint}); + last_cluster = cp.cluster; } } try writer.writeAll(" → "); for (codepoints) |cp| { - if (cp.cluster == cell_offset.cluster and + if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); @@ -676,27 +687,34 @@ pub const Shaper = struct { } const formatted_cps = try allocating.toOwnedSlice(); - // Note that the codepoints from `start_index .. end_index + 1` - // might not include all the codepoints being shaped. Sometimes a - // codepoint gets represented in a glyph with a later codepoint - // such that the index for the former codepoint is skipped and just - // the index for the latter codepoint is used. Additionally, this - // gets called as we iterate through the glyphs, so it won't - // include the codepoints that come later that might be affecting - // positions for the current glyph. Usually though, for that case - // the positions of the later glyphs will also be affected and show - // up in the logs. - log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - cell_offset.cluster, - x_offset, - position.y, - advance_x_offset, - advance_y_offset, - x_offset_diff, - y_offset_diff, - state.codepoints.items[index].codepoint, - formatted_cps, - }); + if (positions_differ) { + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cell_offset.cluster, + x_offset, + position.y, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); + } + + if (position_y_differs) { + log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} current cp={x}, cps={s}", .{ + cell_offset.cluster, + x_offset, + position.y, + run_offset_x, + run_offset_y, + cell_offset.x, + cell_offset_y, + old_offset_y, + state.codepoints.items[index].codepoint, + formatted_cps, + }); + } } } }; From b830a8397ce411e2546282798058779b3ace0b23 Mon Sep 17 00:00:00 2001 From: Jan Klass Date: Mon, 5 Jan 2026 10:48:09 +0100 Subject: [PATCH 361/605] Update po/de_DE.UTF-8.po Co-authored-by: Klaus Hipp --- po/de_DE.UTF-8.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/de_DE.UTF-8.po b/po/de_DE.UTF-8.po index 27b353e38..82d186967 100644 --- a/po/de_DE.UTF-8.po +++ b/po/de_DE.UTF-8.po @@ -321,4 +321,4 @@ msgstr "Ghostty-Entwickler" #: src/apprt/gtk/inspector.zig:144 msgid "Ghostty: Terminal Inspector" -msgstr "Ghostty: Terminal Inspektor" +msgstr "Ghostty: Terminalinspektor" From f36abed35a52f7cfd0e21debbf90a5705abc7acc Mon Sep 17 00:00:00 2001 From: Peter Cardenas <16930781+PeterCardenas@users.noreply.github.com> Date: Mon, 5 Jan 2026 05:26:33 -0800 Subject: [PATCH 362/605] fix: reset progress bar on reset terminal --- src/termio/stream_handler.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 182770339..c647e3ba2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -952,6 +952,9 @@ pub const StreamHandler = struct { // Reset resets our palette so we report it for mode 2031. self.surfaceMessageWriter(.{ .report_color_scheme = false }); + + // Clear the progress bar + self.progressReport(.{ .state = .remove }); } pub fn queryKittyKeyboard(self: *StreamHandler) !void { From d38558aee10fb444c6eb7e9d38b04b777035cacd Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 5 Jan 2026 09:34:17 -0500 Subject: [PATCH 363/605] =?UTF-8?q?Show=20current=20cp=20with=20=E2=96=B8?= =?UTF-8?q?=20in=20list=20of=20cps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/font/shaper/coretext.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 4b8b05419..f1c643848 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -663,6 +663,7 @@ pub const Shaper = struct { var allocating = std.Io.Writer.Allocating.init(alloc); const writer = &allocating.writer; const codepoints = state.codepoints.items; + const current_cp = state.codepoints.items[index].codepoint; var last_cluster: ?u32 = null; for (codepoints) |cp| { if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and @@ -673,6 +674,9 @@ pub const Shaper = struct { try writer.writeAll(" "); } } + if (cp.cluster == cell_offset.cluster and cp.codepoint == current_cp) { + try writer.writeAll("▸"); + } try writer.print("\\u{{{x}}}", .{cp.codepoint}); last_cluster = cp.cluster; } @@ -688,7 +692,7 @@ pub const Shaper = struct { const formatted_cps = try allocating.toOwnedSlice(); if (positions_differ) { - log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) cps = {s}", .{ cell_offset.cluster, x_offset, position.y, @@ -696,13 +700,12 @@ pub const Shaper = struct { advance_y_offset, x_offset_diff, y_offset_diff, - state.codepoints.items[index].codepoint, formatted_cps, }); } if (position_y_differs) { - log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} current cp={x}, cps={s}", .{ + log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} cps = {s}", .{ cell_offset.cluster, x_offset, position.y, @@ -711,7 +714,6 @@ pub const Shaper = struct { cell_offset.x, cell_offset_y, old_offset_y, - state.codepoints.items[index].codepoint, formatted_cps, }); } From 41f63384f54c429ee873faeed41c9aba53218441 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 5 Jan 2026 09:59:37 -0500 Subject: [PATCH 364/605] Turn off debugging --- src/font/shaper/coretext.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index f1c643848..a1e0c0129 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -390,8 +390,8 @@ pub const Shaper = struct { var cell_offset: CellOffset = .{}; // For debugging positions, turn this on: - var run_offset_y: f64 = 0.0; - var cell_offset_y: f64 = 0.0; + //var run_offset_y: f64 = 0.0; + //var cell_offset_y: f64 = 0.0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -449,11 +449,11 @@ pub const Shaper = struct { }; // For debugging positions, turn this on: - cell_offset_y = run_offset_y; + //cell_offset_y = run_offset_y; } // For debugging positions, turn this on: - try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); + //try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); const x_offset = position.x - cell_offset.x; @@ -469,7 +469,7 @@ pub const Shaper = struct { run_offset_x += advance.width; // For debugging positions, turn this on: - run_offset_y += advance.height; + //run_offset_y += advance.height; } } From 7aec7effea33b39bb8caabda26e6eaad0486815f Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 5 Jan 2026 10:12:05 -0500 Subject: [PATCH 365/605] Add test for Tai Tham letter position.y difference --- src/font/shaper/coretext.zig | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index a1e0c0129..bcf1fe879 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1533,6 +1533,66 @@ test "shape Tai Tham vowels (position differs from advance)" { try testing.expectEqual(@as(usize, 1), count); } +test "shape Tai Tham letters (position.y differs from advance)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA + buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT + // Second grapheme cluster, combining with the first in a ligature: + buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA + buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 1), cells[2].x); // U from second grapheme + + // The U glyph renders at a y below zero + try testing.expectEqual(@as(i16, -3), cells[2].y_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; From 15899b70fb111d5ba6206a5cb329444ed64f49c2 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 5 Jan 2026 10:35:22 -0500 Subject: [PATCH 366/605] simplify run_offset_x comment --- src/font/shaper/coretext.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index bcf1fe879..c8822a373 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -382,8 +382,7 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current x offset (sum of advance.width) for - // a run. + // This keeps track of the current x offset (sum of advance.width) var run_offset_x: f64 = 0.0; // This keeps track of the cell starting x and cluster. From f3e90e23d92cc9c829bd8e4ee35f091062647897 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 5 Jan 2026 15:47:07 -0600 Subject: [PATCH 367/605] Lower unimplemented OSC from warning to debug --- src/terminal/stream.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index ba6b57d5c..665b54284 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -2108,7 +2108,7 @@ pub fn Stream(comptime Handler: type) type { .conemu_wait_input, .conemu_guimacro, => { - log.warn("unimplemented OSC callback: {}", .{cmd}); + log.debug("unimplemented OSC callback: {}", .{cmd}); }, .invalid => { From 87fc5357eb20d174e9fc67525fa0cb08f5b67caa Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 21:36:04 -0700 Subject: [PATCH 368/605] Add config entries for tab and split inheritance --- src/config/Config.zig | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 88f3d5375..f4ec21684 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1845,11 +1845,21 @@ keybind: Keybinds = .{}, /// This setting is only supported currently on macOS. @"window-vsync": bool = true, -/// If true, new windows and tabs will inherit the working directory of the +/// If true, new windows will inherit the working directory of the /// previously focused window. If no window was previously focused, the default /// working directory will be used (the `working-directory` option). @"window-inherit-working-directory": bool = true, +/// If true, new tabs will inherit the working directory of the +/// previously focused window. If no window was previously focused, the default +/// working directory will be used (the `working-directory` option). +@"tab-inherit-working-directory": bool = true, + +/// If true, new split panes will inherit the working directory of the +/// previously focused window. If no window was previously focused, the default +/// working directory will be used (the `working-directory` option). +@"split-inherit-working-directory": bool = true, + /// If true, new windows and tabs will inherit the font size of the previously /// focused window. If no window was previously focused, the default font size /// will be used. If this is false, the default font size specified in the From 02e1a68263763e8e64bc86365227ea46855a9ca5 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:08:30 -0700 Subject: [PATCH 369/605] Use config to determine cwd inheritance for windows, tabs, and splits - Define NewSurfaceContext enum (window, tab, split) - Add shouldInheritWorkingDirectory helper function - Thread surface context through newConfig and newSurfaceOptions - Replace window-inherit-working-directory checks with context-aware logic - Add context to embedded CAPI and GTK Surface structs --- src/apprt/embedded.zig | 32 ++++++++++++++++++++++++++++++-- src/apprt/gtk/class/surface.zig | 14 ++++++-------- src/apprt/surface.zig | 18 +++++++++++++++++- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 27f07604a..2c329af7c 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -456,6 +456,15 @@ pub const Surface = struct { /// Wait after the command exits wait_after_command: bool = false, + + /// Context for the new surface + context: Context = .c_window, + + pub const Context = enum(c_int) { + c_window = 0, + c_tab = 1, + c_split = 2, + }; }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -477,7 +486,13 @@ pub const Surface = struct { errdefer app.core_app.deleteSurface(self); // Shallow copy the config so that we can modify it. - var config = try apprt.surface.newConfig(app.core_app, &app.config); + const surface_context: apprt.surface.NewSurfaceContext = switch (opts.context) { + .c_window => .window, + .c_tab => .tab, + .c_split => .split, + }; + + var config = try apprt.surface.newConfig(app.core_app, &app.config, surface_context); defer config.deinit(); // If we have a working directory from the options then we set it. @@ -894,14 +909,27 @@ pub const Surface = struct { }; } - pub fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options { + pub fn newSurfaceOptions(self: *const Surface, context: apprt.surface.NewSurfaceContext) apprt.Surface.Options { const font_size: f32 = font_size: { if (!self.app.config.@"window-inherit-font-size") break :font_size 0; break :font_size self.core_surface.font_size.points; }; + const working_directory: ?[*:0]const u8 = wd: { + if (!apprt.surface.shouldInheritWorkingDirectory(context, &self.app.config)) break :wd null; + const cwd = self.core_surface.pwd(self.app.core_app.alloc) catch null orelse break :wd null; + defer self.app.core_app.alloc.free(cwd); + break :wd self.app.core_app.alloc.dupeZ(u8, cwd) catch null; + }; + return .{ .font_size = font_size, + .working_directory = working_directory, + .context = switch (context) { + .window => .c_window, + .tab => .c_tab, + .split => .c_split, + }, }; } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cb5122314..f5bf01d06 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -671,6 +671,9 @@ pub const Surface = extern struct { error_page: *adw.StatusPage, terminal_page: *gtk.Overlay, + /// The context for this surface (window, tab, or split) + context: apprt.surface.NewSurfaceContext = .window, + pub var offset: c_int = 0; }; @@ -716,10 +719,8 @@ pub const Surface = extern struct { // Remainder needs a config. If there is no config we just assume // we aren't inheriting any of these values. if (priv.config) |config_obj| { - const config = config_obj.get(); - - // Setup our pwd if configured to inherit - if (config.@"window-inherit-working-directory") { + // Setup our cwd if configured to inherit + if (apprt.surface.shouldInheritWorkingDirectory(context, config_obj.get())) { if (parent.rt_surface.surface.getPwd()) |pwd| { priv.pwd = glib.ext.dupeZ(u8, pwd); self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); @@ -3203,10 +3204,7 @@ pub const Surface = extern struct { errdefer app.core().deleteSurface(self.rt()); // Initialize our surface configuration. - var config = try apprt.surface.newConfig( - app.core(), - priv.config.?.get(), - ); + var config = try apprt.surface.newConfig(app.core(), priv.config.?.get(), priv.context); defer config.deinit(); // Properties that can impact surface init diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 45a847493..56dfae31b 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -159,12 +159,28 @@ pub const Mailbox = struct { } }; +/// Context for new surface creation to determine inheritance behavior +pub const NewSurfaceContext = enum { + window, + tab, + split, +}; + +pub fn shouldInheritWorkingDirectory(context: NewSurfaceContext, config: *const Config) bool { + return switch (context) { + .window => config.@"window-inherit-working-directory", + .tab => config.@"tab-inherit-working-directory", + .split => config.@"split-inherit-working-directory", + }; +} + /// Returns a new config for a surface for the given app that should be /// used for any new surfaces. The resulting config should be deinitialized /// after the surface is initialized. pub fn newConfig( app: *const App, config: *const Config, + context: NewSurfaceContext, ) Allocator.Error!Config { // Create a shallow clone var copy = config.shallowClone(app.alloc); @@ -175,7 +191,7 @@ pub fn newConfig( // Get our previously focused surface for some inherited values. const prev = app.focusedSurface(); if (prev) |p| { - if (config.@"window-inherit-working-directory") { + if (shouldInheritWorkingDirectory(context, config)) { if (try p.pwd(alloc)) |pwd| { copy.@"working-directory" = pwd; } From 7cf4c8dc538e3fbc3fb37d399e30776fb298acfc Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:12:10 -0700 Subject: [PATCH 370/605] Add context field to C config struct --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 0ad15cf69..ef7c0e4c8 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -428,6 +428,7 @@ typedef struct { size_t env_var_count; const char* initial_input; bool wait_after_command; + int context; // 0=window, 1=tab, 2=split } ghostty_surface_config_s; typedef struct { From dba0ff5339e0d49e16d3b74686a7282333d90501 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:14:00 -0700 Subject: [PATCH 371/605] Add C API function to handle new surfaces of different types --- include/ghostty.h | 5 ++++- macos/Sources/Ghostty/Ghostty.App.swift | 6 +++--- src/apprt/embedded.zig | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index ef7c0e4c8..ea7fd07aa 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1036,7 +1036,10 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t, void ghostty_surface_free(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); -ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); +ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); /* deprecated: use context-specific functions */ +ghostty_surface_config_s ghostty_surface_inherited_config_window(ghostty_surface_t); +ghostty_surface_config_s ghostty_surface_inherited_config_tab(ghostty_surface_t); +ghostty_surface_config_s ghostty_surface_inherited_config_split(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); diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9166168..e23bf0457 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -773,7 +773,7 @@ extension Ghostty { name: Notification.ghosttyNewWindow, object: surfaceView, userInfo: [ - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config_window(surface)), ] ) @@ -810,7 +810,7 @@ extension Ghostty { name: Notification.ghosttyNewTab, object: surfaceView, userInfo: [ - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config_tab(surface)), ] ) @@ -839,7 +839,7 @@ extension Ghostty { object: surfaceView, userInfo: [ "direction": direction, - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config_split(surface)), ] ) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 2c329af7c..8836db403 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1551,8 +1551,24 @@ pub const CAPI = struct { } /// Returns the config to use for surfaces that inherit from this one. + /// Deprecated: Use ghostty_surface_inherited_config_window/tab/split instead. export fn ghostty_surface_inherited_config(surface: *Surface) Surface.Options { - return surface.newSurfaceOptions(); + return surface.newSurfaceOptions(.window); + } + + /// Returns the config to use for new windows that inherit from this surface. + export fn ghostty_surface_inherited_config_window(surface: *Surface) Surface.Options { + return surface.newSurfaceOptions(.window); + } + + /// Returns the config to use for new tabs that inherit from this surface. + export fn ghostty_surface_inherited_config_tab(surface: *Surface) Surface.Options { + return surface.newSurfaceOptions(.tab); + } + + /// Returns the config to use for new splits that inherit from this surface. + export fn ghostty_surface_inherited_config_split(surface: *Surface) Surface.Options { + return surface.newSurfaceOptions(.split); } /// Update the configuration to the provided config for only this surface. From 496f5b3ed72751727a26b363692ad3c82cd2995d Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:17:38 -0700 Subject: [PATCH 372/605] Add the context to the Swift layer. - Define NewSurfaceContext to match the Zig enum name and avoid magic numbers. --- .../Sources/Ghostty/Surface View/SurfaceView.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index c224d373e..3fa98837f 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -625,6 +625,13 @@ extension Ghostty { #endif } + /// Context for surface creation, matching the Zig NewSurfaceContext enum + enum NewSurfaceContext: Int32 { + case window = 0 + case tab = 1 + case split = 2 + } + /// The configuration for a surface. For any configuration not set, defaults will be chosen from /// libghostty, usually from the Ghostty configuration. struct SurfaceConfiguration { @@ -646,6 +653,9 @@ extension Ghostty { /// Wait after the command var waitAfterCommand: Bool = false + /// Context for surface creation + var context: NewSurfaceContext = .window + init() {} init(from config: ghostty_surface_config_s) { @@ -667,6 +677,7 @@ extension Ghostty { } } } + self.context = NewSurfaceContext(rawValue: config.context) ?? .window } /// Provides a C-compatible ghostty configuration within a closure. The configuration @@ -700,6 +711,9 @@ extension Ghostty { // Set wait after command config.wait_after_command = waitAfterCommand + // Set context + config.context = context.rawValue + // Use withCString to ensure strings remain valid for the duration of the closure return try workingDirectory.withCString { cWorkingDir in config.working_directory = cWorkingDir From 05229502bf14758b77cb0ad4ef27132734906537 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:19:17 -0700 Subject: [PATCH 373/605] Add the surface context to the Surface's setParent In order to set the private context variable so that initiSurface can use it. --- src/apprt/gtk/class/surface.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index f5bf01d06..01569dab0 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -699,6 +699,7 @@ pub const Surface = extern struct { pub fn setParent( self: *Self, parent: *CoreSurface, + context: apprt.surface.NewSurfaceContext, ) void { const priv = self.private(); @@ -709,6 +710,9 @@ pub const Surface = extern struct { return; } + // Store the context so initSurface can use it + priv.context = context; + // Setup our font size const font_size_ptr = glib.ext.create(font.face.DesiredSize); errdefer glib.ext.destroy(font_size_ptr); From 82614511ab1bd889e8870abed01d7c3db062bdd4 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:21:45 -0700 Subject: [PATCH 374/605] Use the new GTK Surface::setParent from the tab and split --- src/apprt/gtk/class/split_tree.zig | 2 +- src/apprt/gtk/class/tab.zig | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 46b3268d9..8d859adca 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -219,7 +219,7 @@ pub const SplitTree = extern struct { // Inherit properly if we were asked to. if (parent_) |p| { if (p.core()) |core| { - surface.setParent(core); + surface.setParent(core, .split); } } diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index fb3b8b0ef..ae05cd1ad 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -161,8 +161,12 @@ pub const Tab = extern struct { /// ever created for a tab. If a surface was already created this does /// nothing. pub fn setParent(self: *Self, parent: *CoreSurface) void { + self.setParentWithContext(parent, .tab); + } + + pub fn setParentWithContext(self: *Self, parent: *CoreSurface, context: apprt.surface.NewSurfaceContext) void { if (self.getActiveSurface()) |surface| { - surface.setParent(parent); + surface.setParent(parent, context); } } From 0af2a3f693fbca383c8bce9db2a9870fb71953e9 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sat, 11 Oct 2025 22:23:08 -0700 Subject: [PATCH 375/605] Enable distinguishing between a new tab in a new window (which should inherit based on the window setting), and a new tab in an existing window (which should inherit base on tab setting) --- src/apprt/gtk/class/application.zig | 4 ++-- src/apprt/gtk/class/window.zig | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index b16bce049..848aa22db 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2238,8 +2238,8 @@ const Action = struct { .{}, ); - // Create a new tab - win.newTab(parent); + // Create a new tab with window context (first tab in new window) + win.newTabForWindow(parent); // Show the window gtk.Window.present(win.as(gtk.Window)); diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 77fd2eea5..c2a8924f2 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -361,10 +361,14 @@ pub const Window = extern struct { /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_); + _ = self.newTabPage(parent_, .tab); } - fn newTabPage(self: *Self, parent_: ?*CoreSurface) *adw.TabPage { + pub fn newTabForWindow(self: *Self, parent_: ?*CoreSurface) void { + _ = self.newTabPage(parent_, .window); + } + + fn newTabPage(self: *Self, parent_: ?*CoreSurface, context: apprt.surface.NewSurfaceContext) *adw.TabPage { const priv = self.private(); const tab_view = priv.tab_view; @@ -372,7 +376,9 @@ pub const Window = extern struct { const tab = gobject.ext.newInstance(Tab, .{ .config = priv.config, }); - if (parent_) |p| tab.setParent(p); + if (parent_) |p| { + tab.setParentWithContext(p, context); + } // Get the position that we should insert the new tab at. const config = if (priv.config) |v| v.get() else { @@ -1231,7 +1237,7 @@ pub const Window = extern struct { _: *adw.TabOverview, self: *Self, ) callconv(.c) *adw.TabPage { - return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null); + return self.newTabPage(if (self.getActiveSurface()) |v| v.core() else null, .tab); } fn tabOverviewOpen( From c035fb53855a35cd06e1b3d5f3813029803f37e4 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 12 Oct 2025 22:05:21 -0700 Subject: [PATCH 376/605] Add an enum type for the C API --- include/ghostty.h | 8 ++++++- .../Ghostty/Surface View/SurfaceView.swift | 22 +++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index ea7fd07aa..7258c8838 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -416,6 +416,12 @@ typedef union { ghostty_platform_ios_s ios; } ghostty_platform_u; +typedef enum { + GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, + GHOSTTY_SURFACE_CONTEXT_TAB = 1, + GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, +} ghostty_surface_context_e; + typedef struct { ghostty_platform_e platform_tag; ghostty_platform_u platform; @@ -428,7 +434,7 @@ typedef struct { size_t env_var_count; const char* initial_input; bool wait_after_command; - int context; // 0=window, 1=tab, 2=split + ghostty_surface_context_e context; } ghostty_surface_config_s; typedef struct { diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 3fa98837f..095441192 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -625,11 +625,19 @@ extension Ghostty { #endif } - /// Context for surface creation, matching the Zig NewSurfaceContext enum - enum NewSurfaceContext: Int32 { - case window = 0 - case tab = 1 - case split = 2 + /// Context for surface creation, matching ghostty_surface_context_e + enum NewSurfaceContext: ghostty_surface_context_e.RawValue { + case window = 0 // GHOSTTY_SURFACE_CONTEXT_WINDOW + case tab = 1 // GHOSTTY_SURFACE_CONTEXT_TAB + case split = 2 // GHOSTTY_SURFACE_CONTEXT_SPLIT + + init(_ cValue: ghostty_surface_context_e) { + self.init(rawValue: cValue.rawValue)! + } + + var cValue: ghostty_surface_context_e { + ghostty_surface_context_e(rawValue: self.rawValue) + } } /// The configuration for a surface. For any configuration not set, defaults will be chosen from @@ -677,7 +685,7 @@ extension Ghostty { } } } - self.context = NewSurfaceContext(rawValue: config.context) ?? .window + self.context = NewSurfaceContext(config.context) } /// Provides a C-compatible ghostty configuration within a closure. The configuration @@ -712,7 +720,7 @@ extension Ghostty { config.wait_after_command = waitAfterCommand // Set context - config.context = context.rawValue + config.context = context.cValue // Use withCString to ensure strings remain valid for the duration of the closure return try workingDirectory.withCString { cWorkingDir in From d6607997235ad5059f3c7692df2fd2e9154127a6 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Mon, 13 Oct 2025 10:59:01 -0700 Subject: [PATCH 377/605] Consolidate the several ghostty_surface_inherited_config functions back into a single function with a second parameter for the source context. --- include/ghostty.h | 5 +---- macos/Sources/Ghostty/Ghostty.App.swift | 6 +++--- src/apprt/embedded.zig | 28 +++++++++---------------- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 7258c8838..c390e2102 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1042,10 +1042,7 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t, void ghostty_surface_free(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); -ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); /* deprecated: use context-specific functions */ -ghostty_surface_config_s ghostty_surface_inherited_config_window(ghostty_surface_t); -ghostty_surface_config_s ghostty_surface_inherited_config_tab(ghostty_surface_t); -ghostty_surface_config_s ghostty_surface_inherited_config_split(ghostty_surface_t); +ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); 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); diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index e23bf0457..191da9aca 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -773,7 +773,7 @@ extension Ghostty { name: Notification.ghosttyNewWindow, object: surfaceView, userInfo: [ - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config_window(surface)), + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW)), ] ) @@ -810,7 +810,7 @@ extension Ghostty { name: Notification.ghosttyNewTab, object: surfaceView, userInfo: [ - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config_tab(surface)), + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB)), ] ) @@ -839,7 +839,7 @@ extension Ghostty { object: surfaceView, userInfo: [ "direction": direction, - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config_split(surface)), + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)), ] ) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 8836db403..186b0c2e4 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1551,24 +1551,16 @@ pub const CAPI = struct { } /// Returns the config to use for surfaces that inherit from this one. - /// Deprecated: Use ghostty_surface_inherited_config_window/tab/split instead. - export fn ghostty_surface_inherited_config(surface: *Surface) Surface.Options { - return surface.newSurfaceOptions(.window); - } - - /// Returns the config to use for new windows that inherit from this surface. - export fn ghostty_surface_inherited_config_window(surface: *Surface) Surface.Options { - return surface.newSurfaceOptions(.window); - } - - /// Returns the config to use for new tabs that inherit from this surface. - export fn ghostty_surface_inherited_config_tab(surface: *Surface) Surface.Options { - return surface.newSurfaceOptions(.tab); - } - - /// Returns the config to use for new splits that inherit from this surface. - export fn ghostty_surface_inherited_config_split(surface: *Surface) Surface.Options { - return surface.newSurfaceOptions(.split); + export fn ghostty_surface_inherited_config( + surface: *Surface, + source: Surface.Options.Context, + ) Surface.Options { + const context: apprt.surface.NewSurfaceContext = switch (source) { + .c_window => .window, + .c_tab => .tab, + .c_split => .split, + }; + return surface.newSurfaceOptions(context); } /// Update the configuration to the provided config for only this surface. From b119bc6089f5a7e672ab8672f9de25b46a017ac0 Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Mon, 13 Oct 2025 19:12:03 -0700 Subject: [PATCH 378/605] consolidated enums --- .../Ghostty/Surface View/SurfaceView.swift | 21 ++----------- src/apprt/embedded.zig | 31 +++---------------- src/apprt/surface.zig | 8 ++--- 3 files changed, 12 insertions(+), 48 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 095441192..357e82a19 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -625,21 +625,6 @@ extension Ghostty { #endif } - /// Context for surface creation, matching ghostty_surface_context_e - enum NewSurfaceContext: ghostty_surface_context_e.RawValue { - case window = 0 // GHOSTTY_SURFACE_CONTEXT_WINDOW - case tab = 1 // GHOSTTY_SURFACE_CONTEXT_TAB - case split = 2 // GHOSTTY_SURFACE_CONTEXT_SPLIT - - init(_ cValue: ghostty_surface_context_e) { - self.init(rawValue: cValue.rawValue)! - } - - var cValue: ghostty_surface_context_e { - ghostty_surface_context_e(rawValue: self.rawValue) - } - } - /// The configuration for a surface. For any configuration not set, defaults will be chosen from /// libghostty, usually from the Ghostty configuration. struct SurfaceConfiguration { @@ -662,7 +647,7 @@ extension Ghostty { var waitAfterCommand: Bool = false /// Context for surface creation - var context: NewSurfaceContext = .window + var context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_WINDOW init() {} @@ -685,7 +670,7 @@ extension Ghostty { } } } - self.context = NewSurfaceContext(config.context) + self.context = config.context } /// Provides a C-compatible ghostty configuration within a closure. The configuration @@ -720,7 +705,7 @@ extension Ghostty { config.wait_after_command = waitAfterCommand // Set context - config.context = context.cValue + config.context = context // Use withCString to ensure strings remain valid for the duration of the closure return try workingDirectory.withCString { cWorkingDir in diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 186b0c2e4..d1d38c24d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -458,13 +458,7 @@ pub const Surface = struct { wait_after_command: bool = false, /// Context for the new surface - context: Context = .c_window, - - pub const Context = enum(c_int) { - c_window = 0, - c_tab = 1, - c_split = 2, - }; + context: apprt.surface.NewSurfaceContext = .window, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -486,13 +480,7 @@ pub const Surface = struct { errdefer app.core_app.deleteSurface(self); // Shallow copy the config so that we can modify it. - const surface_context: apprt.surface.NewSurfaceContext = switch (opts.context) { - .c_window => .window, - .c_tab => .tab, - .c_split => .split, - }; - - var config = try apprt.surface.newConfig(app.core_app, &app.config, surface_context); + var config = try apprt.surface.newConfig(app.core_app, &app.config, opts.context); defer config.deinit(); // If we have a working directory from the options then we set it. @@ -925,11 +913,7 @@ pub const Surface = struct { return .{ .font_size = font_size, .working_directory = working_directory, - .context = switch (context) { - .window => .c_window, - .tab => .c_tab, - .split => .c_split, - }, + .context = context, }; } @@ -1553,14 +1537,9 @@ pub const CAPI = struct { /// Returns the config to use for surfaces that inherit from this one. export fn ghostty_surface_inherited_config( surface: *Surface, - source: Surface.Options.Context, + source: apprt.surface.NewSurfaceContext, ) Surface.Options { - const context: apprt.surface.NewSurfaceContext = switch (source) { - .c_window => .window, - .c_tab => .tab, - .c_split => .split, - }; - return surface.newSurfaceOptions(context); + return surface.newSurfaceOptions(source); } /// Update the configuration to the provided config for only this surface. diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 56dfae31b..be2f59149 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -160,10 +160,10 @@ pub const Mailbox = struct { }; /// Context for new surface creation to determine inheritance behavior -pub const NewSurfaceContext = enum { - window, - tab, - split, +pub const NewSurfaceContext = enum(c_int) { + window = 0, + tab = 1, + split = 2, }; pub fn shouldInheritWorkingDirectory(context: NewSurfaceContext, config: *const Config) bool { From 55285fee28bf11e12e9d82b028c44c761b33713f Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Wed, 3 Dec 2025 11:15:14 -0800 Subject: [PATCH 379/605] preserve multi-line formatting --- src/apprt/gtk/class/surface.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 01569dab0..0d6c64433 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3208,7 +3208,11 @@ pub const Surface = extern struct { errdefer app.core().deleteSurface(self.rt()); // Initialize our surface configuration. - var config = try apprt.surface.newConfig(app.core(), priv.config.?.get(), priv.context); + var config = try apprt.surface.newConfig( + app.core(), + priv.config.?.get(), + priv.context, + ); defer config.deinit(); // Properties that can impact surface init From 93f33bc0d65222f8ecdd3c470c8281b35c17c43d Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Sun, 28 Dec 2025 14:45:15 -0800 Subject: [PATCH 380/605] clarify config documentation around previously focused windows/tabs/splits --- src/config/Config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f4ec21684..eef23cccb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1851,12 +1851,12 @@ keybind: Keybinds = .{}, @"window-inherit-working-directory": bool = true, /// If true, new tabs will inherit the working directory of the -/// previously focused window. If no window was previously focused, the default +/// previously focused tab. If no tab was previously focused, the default /// working directory will be used (the `working-directory` option). @"tab-inherit-working-directory": bool = true, /// If true, new split panes will inherit the working directory of the -/// previously focused window. If no window was previously focused, the default +/// previously focused split. If no split was previously focused, the default /// working directory will be used (the `working-directory` option). @"split-inherit-working-directory": bool = true, From 1c7ba3dbe01da392c3588ee0e9885d7d259dbb2d Mon Sep 17 00:00:00 2001 From: Jan Klass Date: Tue, 6 Jan 2026 10:26:40 +0100 Subject: [PATCH 381/605] Update rev date and last translator --- po/de_DE.UTF-8.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/po/de_DE.UTF-8.po b/po/de_DE.UTF-8.po index 82d186967..f73f1c251 100644 --- a/po/de_DE.UTF-8.po +++ b/po/de_DE.UTF-8.po @@ -10,8 +10,8 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-22 17:18+0000\n" -"PO-Revision-Date: 2025-08-25 19:38+0100\n" -"Last-Translator: Robin \n" +"PO-Revision-Date: 2026-01-06 10:25+0100\n" +"Last-Translator: Jan Klass \n" "Language-Team: German \n" "Language: de\n" "MIME-Version: 1.0\n" From 9b6a3be99339bcefcc49b7791b7b9761d24e6093 Mon Sep 17 00:00:00 2001 From: Aaron Ruan Date: Tue, 6 Jan 2026 22:15:19 +0800 Subject: [PATCH 382/605] macOS: Selection for Find feature Adds the `selection_for_search` action, with Cmd+E keybind by default. This action inputs the currently selected text into the search field without changing focus, matching standard macOS behavior. --- include/ghostty.h | 9 ++++- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ macos/Sources/App/macOS/MainMenu.xib | 11 ++++-- .../Terminal/BaseTerminalController.swift | 6 +++- macos/Sources/Ghostty/Ghostty.Action.swift | 12 +++++++ macos/Sources/Ghostty/Ghostty.App.swift | 35 +++++++++++++++++++ macos/Sources/Ghostty/Package.swift | 1 + .../Ghostty/Surface View/SurfaceView.swift | 11 +++++- .../Surface View/SurfaceView_AppKit.swift | 8 +++++ src/Surface.zig | 9 +++++ src/apprt/action.zig | 19 ++++++++++ src/config/Config.zig | 6 ++++ src/input/Binding.zig | 4 +++ src/input/command.zig | 6 ++++ 14 files changed, 134 insertions(+), 5 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 0ad15cf69..726b368e7 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -810,6 +810,11 @@ typedef struct { ssize_t selected; } ghostty_action_search_selected_s; +// apprt.action.SelectionForSearch +typedef struct { + const char* text; +} ghostty_action_selection_for_search_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -878,11 +883,12 @@ typedef enum { GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_SELECTION_FOR_SEARCH, GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, - } ghostty_action_tag_e; +} ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; @@ -919,6 +925,7 @@ typedef union { ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; ghostty_action_start_search_s start_search; + ghostty_action_selection_for_search_s selection_for_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 57bfba828..c365fb935 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -46,6 +46,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuFindParent: NSMenuItem? @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuSelectionForFind: NSMenuItem? @IBOutlet private var menuFindNext: NSMenuItem? @IBOutlet private var menuFindPrevious: NSMenuItem? @IBOutlet private var menuHideFindBar: NSMenuItem? @@ -615,6 +616,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "selection_for_search", menuItem: self.menuSelectionForFind) syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index a321061dd..248063f89 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -58,6 +58,7 @@ + @@ -262,6 +263,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index fb86ce8f7..a4e0da7ee 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1383,7 +1383,11 @@ class BaseTerminalController: NSWindowController, @IBAction func find(_ sender: Any) { focusedSurface?.find(sender) } - + + @IBAction func selectionForFind(_ sender: Any) { + focusedSurface?.selectionForFind(sender) + } + @IBAction func findNext(_ sender: Any) { focusedSurface?.findNext(sender) } diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 91f1491dd..c04c7d958 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -128,6 +128,18 @@ extension Ghostty.Action { } } + struct SelectionForSearch { + let text: String? + + init(c: ghostty_action_selection_for_search_s) { + if let contentCString = c.text { + self.text = String(cString: contentCString) + } else { + self.text = nil + } + } + } + enum PromptTitle { case surface case tab diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9166168..69788c194 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -621,6 +621,9 @@ extension Ghostty { case GHOSTTY_ACTION_START_SEARCH: startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_SELECTION_FOR_SEARCH: + selectionForSearch(app, target: target, v: action.action.selection_for_search) + case GHOSTTY_ACTION_END_SEARCH: endSearch(app, target: target) @@ -1881,6 +1884,38 @@ extension Ghostty { } } + private static func selectionForSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_selection_for_search_s + ) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("selection_for_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let selectionForSearch = Ghostty.Action.SelectionForSearch(c: v) + DispatchQueue.main.async { + if surfaceView.searchState != nil, let text = selectionForSearch.text { + NotificationCenter.default.post( + name: .ghosttySelectionForSearch, + object: surfaceView, + userInfo: [ + "text": text + ] + ) + } + } + + default: + assertionFailure() + } + } + private static func endSearch( _ app: ghostty_app_t, target: ghostty_target_s) { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index aa62c16f7..dbe9c173b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -406,6 +406,7 @@ extension Notification.Name { /// Focus the search field static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") + static let ghosttySelectionForSearch = Notification.Name("com.mitchellh.ghostty.selectionForSearch") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index c224d373e..b3717d4c5 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -475,7 +475,16 @@ extension Ghostty { } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in guard notification.object as? SurfaceView === surfaceView else { return } - isSearchFieldFocused = true + DispatchQueue.main.async { + isSearchFieldFocused = true + } + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySelectionForSearch)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + if let userInfo = notification.userInfo, let text = userInfo["text"] as? String { + searchState.needle = text + // We do not focus the textfield after the action to match macOS behavior + } } .background( GeometryReader { barGeo in diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 7f33df45a..1fc43ac82 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1519,6 +1519,14 @@ extension Ghostty { } } + @IBAction func selectionForFind(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "selection_for_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" diff --git a/src/Surface.zig b/src/Surface.zig index 43ee440c2..68cf46045 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5163,6 +5163,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .selection_for_search => { + const selection = try self.selectionString(self.alloc) orelse return false; + return try self.rt_app.performAction( + .{ .surface = self }, + .selection_for_search, + .{ .text = selection }, + ); + }, + .end_search => { // We only return that this was performed if we actually // stopped a search, but we also send the apprt end_search so diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 25fc6f08a..7fdaabf08 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -316,6 +316,9 @@ pub const Action = union(Key) { /// Start the search overlay with an optional initial needle. start_search: StartSearch, + /// Input the selected text into the search field. + selection_for_search: SelectionForSearch, + /// End the search overlay, clearing the search state and hiding it. end_search, @@ -389,6 +392,7 @@ pub const Action = union(Key) { show_on_screen_keyboard, command_finished, start_search, + selection_for_search, end_search, search_total, search_selected, @@ -914,3 +918,18 @@ pub const SearchSelected = struct { }; } }; + +pub const SelectionForSearch = struct { + text: [:0]const u8, + + // Sync with: ghostty_action_selection_for_search_s + pub const C = extern struct { + text: [*:0]const u8, + }; + + pub fn cval(self: SelectionForSearch) C { + return .{ + .text = self.text.ptr, + }; + } +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index 88f3d5375..698831ec1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6585,6 +6585,12 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'e' }, .mods = .{ .super = true } }, + .selection_for_search, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d5b24c61b..0ef5208bc 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -368,6 +368,9 @@ pub const Action = union(enum) { /// If a previous search is active, it is replaced. search: []const u8, + /// Input the selected text into the search field. + selection_for_search, + /// Navigate the search results. If there is no active search, this /// is not performed. navigate_search: NavigateSearch, @@ -1284,6 +1287,7 @@ pub const Action = union(enum) { .cursor_key, .search, .navigate_search, + .selection_for_search, .start_search, .end_search, .reset, diff --git a/src/input/command.zig b/src/input/command.zig index f089112db..3fc7b29f6 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -189,6 +189,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Start a search if one isn't already active.", }}, + .selection_for_search => comptime &.{.{ + .action = .selection_for_search, + .title = "Selection for Search", + .description = "Input the selected text into the search field.", + }}, + .end_search => comptime &.{.{ .action = .end_search, .title = "End Search", From f258265ef0b858472f24851f9616c7f823a4467c Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 6 Jan 2026 10:30:13 -0500 Subject: [PATCH 383/605] font/shaper: keep codepoints in same cell if detecting ligature --- src/font/shaper/coretext.zig | 93 +++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index c8822a373..6d17fc014 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -389,8 +389,8 @@ pub const Shaper = struct { var cell_offset: CellOffset = .{}; // For debugging positions, turn this on: - //var run_offset_y: f64 = 0.0; - //var cell_offset_y: f64 = 0.0; + var run_offset_y: f64 = 0.0; + var cell_offset_y: f64 = 0.0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -410,8 +410,8 @@ pub const Shaper = struct { // other so we can iterate over them and just append to our // cell buffer. const runs = line.getGlyphRuns(); - for (0..runs.getCount()) |i| { - const ctrun = runs.getValueAtIndex(macos.text.Run, i); + for (0..runs.getCount()) |run_i| { + const ctrun = runs.getValueAtIndex(macos.text.Run, run_i); const status = ctrun.getStatus(); if (status.non_monotonic or status.right_to_left) non_ltr = true; @@ -434,30 +434,45 @@ pub const Shaper = struct { // Our cluster is also our cell X position. If the cluster changes // then we need to reset our current cell offsets. const cluster = state.codepoints.items[index].cluster; - if (cell_offset.cluster != cluster) pad: { - // We previously asserted this but for rtl text this is - // not true. So we check for this and break out. In the - // future we probably need to reverse pad for rtl but - // I don't have a solid test case for this yet so let's - // wait for that. - if (cell_offset.cluster > cluster) break :pad; + if (cell_offset.cluster != cluster) { + // We previously asserted that the new cluster is greater + // than cell_offset.cluster, but for rtl text this is not + // true. We then used to break out of this block if cluster + // was less than cell_offset.cluster, but now this would + // fail to reset cell_offset.x and cell_offset.cluster and + // lead to incorrect shape.Cell `x` and `x_offset`. We + // don't have a test case for RTL, yet. - cell_offset = .{ - .cluster = cluster, - .x = run_offset_x, + const is_codepoint_first_in_cluster = blk: { + var i = index; + while (i > 0) { + i -= 1; + const codepoint = state.codepoints.items[i]; + + // Skip surrogate pair padding + if (codepoint.codepoint == 0) continue; + break :blk codepoint.cluster != cluster; + } else break :blk true; }; - // For debugging positions, turn this on: - //cell_offset_y = run_offset_y; + if (is_codepoint_first_in_cluster) { + cell_offset = .{ + .cluster = cluster, + .x = run_offset_x, + }; + + // For debugging positions, turn this on: + cell_offset_y = run_offset_y; + } } // For debugging positions, turn this on: - //try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); + try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); const x_offset = position.x - cell_offset.x; self.cell_buf.appendAssumeCapacity(.{ - .x = @intCast(cluster), + .x = @intCast(cell_offset.cluster), .x_offset = @intFromFloat(@round(x_offset)), .y_offset = @intFromFloat(@round(position.y)), .glyph_index = glyph, @@ -468,7 +483,7 @@ pub const Shaper = struct { run_offset_x += advance.width; // For debugging positions, turn this on: - //run_offset_y += advance.height; + run_offset_y += advance.height; } } @@ -657,15 +672,20 @@ pub const Shaper = struct { const positions_differ = @abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001; const old_offset_y = position.y - cell_offset_y; const position_y_differs = @abs(cell_offset_y) > 0.0001; + const cluster = state.codepoints.items[index].cluster; + const cluster_differs = cluster != cell_offset.cluster; - if (positions_differ or position_y_differs) { + if (positions_differ or position_y_differs or cluster_differs) { var allocating = std.Io.Writer.Allocating.init(alloc); const writer = &allocating.writer; const codepoints = state.codepoints.items; - const current_cp = state.codepoints.items[index].codepoint; var last_cluster: ?u32 = null; - for (codepoints) |cp| { - if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and + for (codepoints, 0..) |cp, i| { + if ((cp.cluster == cluster - 3 or + cp.cluster == cluster - 2 or + cp.cluster == cluster - 1 or + cp.cluster == cluster or + cp.cluster == cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { if (last_cluster) |last| { @@ -673,7 +693,7 @@ pub const Shaper = struct { try writer.writeAll(" "); } } - if (cp.cluster == cell_offset.cluster and cp.codepoint == current_cp) { + if (i == index) { try writer.writeAll("▸"); } try writer.print("\\u{{{x}}}", .{cp.codepoint}); @@ -682,7 +702,11 @@ pub const Shaper = struct { } try writer.writeAll(" → "); for (codepoints) |cp| { - if ((cp.cluster == cell_offset.cluster or cp.cluster == cell_offset.cluster - 1 or cp.cluster == cell_offset.cluster + 1) and + if ((cp.cluster == cluster - 3 or + cp.cluster == cluster - 2 or + cp.cluster == cluster - 1 or + cp.cluster == cluster or + cp.cluster == cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); @@ -692,7 +716,7 @@ pub const Shaper = struct { if (positions_differ) { log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) cps = {s}", .{ - cell_offset.cluster, + cluster, x_offset, position.y, advance_x_offset, @@ -705,7 +729,7 @@ pub const Shaper = struct { if (position_y_differs) { log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} cps = {s}", .{ - cell_offset.cluster, + cluster, x_offset, position.y, run_offset_x, @@ -716,6 +740,21 @@ pub const Shaper = struct { formatted_cps, }); } + + if (cluster_differs) { + log.warn("cell_offset.cluster differs from cluster (potential ligature detected) cell_offset.cluster={d} cluster={d} diff={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) cps = {s}", .{ + cell_offset.cluster, + cluster, + cluster - cell_offset.cluster, + x_offset, + position.y, + run_offset_x, + run_offset_y, + cell_offset.x, + cell_offset_y, + formatted_cps, + }); + } } } }; From 896615c00448516560d52494990d8e85b1b3a5f1 Mon Sep 17 00:00:00 2001 From: Britt Binler Date: Tue, 6 Jan 2026 10:15:06 -0500 Subject: [PATCH 384/605] font: add bitmap font tests for BDF, PCF, and OTB formats Add test coverage for bitmap font rendering using the Spleen 8x16 font in three formats: BDF (Bitmap Distribution Format), PCF (Portable Compiled Format), and OTB (OpenType Bitmap). Tests validate glyph rendering against expected pixel patterns. Addresses #8524. --- .gitattributes | 1 + src/font/embedded.zig | 6 + src/font/face/freetype.zig | 150 + src/font/res/BSD-2-Clause.txt | 24 + src/font/res/README.md | 6 +- src/font/res/spleen-8x16.bdf | 22328 ++++++++++++++++++++++++++++++++ src/font/res/spleen-8x16.otb | Bin 0 -> 25912 bytes src/font/res/spleen-8x16.pcf | Bin 0 -> 206164 bytes typos.toml | 1 + 9 files changed, 22515 insertions(+), 1 deletion(-) create mode 100644 src/font/res/BSD-2-Clause.txt create mode 100644 src/font/res/spleen-8x16.bdf create mode 100644 src/font/res/spleen-8x16.otb create mode 100644 src/font/res/spleen-8x16.pcf diff --git a/.gitattributes b/.gitattributes index 9fe672044..9158b3979 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,4 +10,5 @@ pkg/libintl/libintl.h linguist-generated=true pkg/simdutf/vendor/** linguist-vendored src/font/nerd_font_attributes.zig linguist-generated=true src/font/nerd_font_codepoint_tables.py linguist-generated=true +src/font/res/** linguist-vendored src/terminal/res/** linguist-vendored diff --git a/src/font/embedded.zig b/src/font/embedded.zig index 1e496075d..8e0ae33b8 100644 --- a/src/font/embedded.zig +++ b/src/font/embedded.zig @@ -47,3 +47,9 @@ pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf"); /// Terminus TTF is a scalable font with bitmap glyphs at various sizes. pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf"); + +/// Spleen is a monospaced bitmap font available in multiple formats. +/// Used for testing bitmap font support across different file formats. +pub const spleen_bdf = @embedFile("res/spleen-8x16.bdf"); +pub const spleen_pcf = @embedFile("res/spleen-8x16.pcf"); +pub const spleen_otb = @embedFile("res/spleen-8x16.otb"); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index a6ef52c39..827753254 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -1284,3 +1284,153 @@ test "bitmap glyph" { } } } + +// Expected pixel pattern for Spleen 8x16 'A' (glyph index from char 'A') +// Derived from BDF BITMAP data: 00,00,7C,C6,C6,C6,FE,C6,C6,C6,C6,C6,00,00,00,00 +const spleen_A = + \\........ + \\........ + \\.#####.. + \\##...##. + \\##...##. + \\##...##. + \\#######. + \\##...##. + \\##...##. + \\##...##. + \\##...##. + \\##...##. + \\........ + \\........ + \\........ + \\........ +; +// Including the newline +const spleen_A_pitch = 9; +// Test parameters for bitmap font tests +const spleen_test_point_size = 12; +const spleen_test_dpi = 96; + +test "bitmap glyph BDF" { + const alloc = testing.allocator; + const testFont = font.embedded.spleen_bdf; + + var lib = try Library.init(alloc); + defer lib.deinit(); + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + // Spleen 8x16 is a pure bitmap font at 16px height + var ft_font = try Face.init(lib, testFont, .{ .size = .{ + .points = spleen_test_point_size, + .xdpi = spleen_test_dpi, + .ydpi = spleen_test_dpi, + } }); + defer ft_font.deinit(); + + // Get glyph index for 'A' + const glyph_index = ft_font.glyphIndex('A') orelse return error.GlyphNotFound; + + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + glyph_index, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, + ); + + // Verify dimensions match Spleen 8x16 + try testing.expectEqual(8, glyph.width); + try testing.expectEqual(16, glyph.height); + + // Verify pixel-perfect rendering + for (0..glyph.height) |y| { + for (0..glyph.width) |x| { + const pixel = spleen_A[y * spleen_A_pitch + x]; + try testing.expectEqual( + @as(u8, if (pixel == '#') 255 else 0), + atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)], + ); + } + } +} + +test "bitmap glyph PCF" { + const alloc = testing.allocator; + const testFont = font.embedded.spleen_pcf; + + var lib = try Library.init(alloc); + defer lib.deinit(); + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + var ft_font = try Face.init(lib, testFont, .{ .size = .{ + .points = spleen_test_point_size, + .xdpi = spleen_test_dpi, + .ydpi = spleen_test_dpi, + } }); + defer ft_font.deinit(); + + const glyph_index = ft_font.glyphIndex('A') orelse return error.GlyphNotFound; + + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + glyph_index, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, + ); + + try testing.expectEqual(8, glyph.width); + try testing.expectEqual(16, glyph.height); + + for (0..glyph.height) |y| { + for (0..glyph.width) |x| { + const pixel = spleen_A[y * spleen_A_pitch + x]; + try testing.expectEqual( + @as(u8, if (pixel == '#') 255 else 0), + atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)], + ); + } + } +} + +test "bitmap glyph OTB" { + const alloc = testing.allocator; + const testFont = font.embedded.spleen_otb; + + var lib = try Library.init(alloc); + defer lib.deinit(); + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + var ft_font = try Face.init(lib, testFont, .{ .size = .{ + .points = spleen_test_point_size, + .xdpi = spleen_test_dpi, + .ydpi = spleen_test_dpi, + } }); + defer ft_font.deinit(); + + const glyph_index = ft_font.glyphIndex('A') orelse return error.GlyphNotFound; + + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + glyph_index, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, + ); + + try testing.expectEqual(8, glyph.width); + try testing.expectEqual(16, glyph.height); + + for (0..glyph.height) |y| { + for (0..glyph.width) |x| { + const pixel = spleen_A[y * spleen_A_pitch + x]; + try testing.expectEqual( + @as(u8, if (pixel == '#') 255 else 0), + atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)], + ); + } + } +} diff --git a/src/font/res/BSD-2-Clause.txt b/src/font/res/BSD-2-Clause.txt new file mode 100644 index 000000000..4387948e8 --- /dev/null +++ b/src/font/res/BSD-2-Clause.txt @@ -0,0 +1,24 @@ +Copyright (c) 2018-2024, Frederic Cambus +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/src/font/res/README.md b/src/font/res/README.md index 5ad4b274f..b4d77a783 100644 --- a/src/font/res/README.md +++ b/src/font/res/README.md @@ -1,6 +1,6 @@ # Fonts and Licenses -This project uses several fonts which fall under the SIL Open Font License (OFL-1.1) and MIT License: +This project uses several fonts which fall under the SIL Open Font License (OFL-1.1), MIT License, and BSD 2-Clause License: - Code New Roman (OFL-1.1) - [© 2014 Sam Radian. All Rights Reserved.](https://github.com/chrissimpkins/codeface/blob/master/fonts/code-new-roman/license.txt) @@ -28,8 +28,12 @@ This project uses several fonts which fall under the SIL Open Font License (OFL- - Terminus TTF (OFL-1.1) - [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/) - [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/) +- Spleen (BSD 2-Clause) + - [Copyright (c) 2018-2024, Frederic Cambus](https://github.com/fcambus/spleen) A full copy of the OFL license can be found at [OFL.txt](./OFL.txt). An accompanying FAQ is also available at . A full copy of the MIT license can be found at [MIT.txt](./MIT.txt). + +A full copy of the BSD 2-Clause license can be found at [BSD-2-Clause.txt](./BSD-2-Clause.txt). diff --git a/src/font/res/spleen-8x16.bdf b/src/font/res/spleen-8x16.bdf new file mode 100644 index 000000000..5c7c2684b --- /dev/null +++ b/src/font/res/spleen-8x16.bdf @@ -0,0 +1,22328 @@ +STARTFONT 2.1 +COMMENT /* +COMMENT * Spleen 8x16 2.1.0 +COMMENT * Copyright (c) 2018-2024, Frederic Cambus +COMMENT * https://www.cambus.net/ +COMMENT * +COMMENT * Created: 2018-08-11 +COMMENT * Last Updated: 2024-01-27 +COMMENT * +COMMENT * Spleen is released under the BSD 2-Clause license. +COMMENT * See LICENSE file for details. +COMMENT * +COMMENT * SPDX-License-Identifier: BSD-2-Clause +COMMENT */ +FONT -misc-spleen-medium-r-normal--16-160-72-72-C-80-ISO10646-1 +SIZE 16 72 72 +FONTBOUNDINGBOX 8 16 0 -4 +STARTPROPERTIES 20 +FAMILY_NAME "Spleen" +WEIGHT_NAME "Medium" +FONT_VERSION "2.1.0" +FOUNDRY "misc" +SLANT "R" +SETWIDTH_NAME "Normal" +PIXEL_SIZE 16 +POINT_SIZE 160 +RESOLUTION_X 72 +RESOLUTION_Y 72 +SPACING "C" +AVERAGE_WIDTH 80 +CHARSET_REGISTRY "ISO10646" +CHARSET_ENCODING "1" +MIN_SPACE 8 +FONT_ASCENT 12 +FONT_DESCENT 4 +COPYRIGHT "Copyright (c) 2018-2024, Frederic Cambus" +DEFAULT_CHAR 32 +_GBDFED_INFO "Edited with gbdfed 1.6." +ENDPROPERTIES +CHARS 969 +STARTCHAR SPACE +ENCODING 32 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR EXCLAMATION MARK +ENCODING 33 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +18 +18 +18 +18 +18 +00 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR QUOTATION MARK +ENCODING 34 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR NUMBER SIGN +ENCODING 35 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +FE +6C +6C +6C +6C +FE +6C +6C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOLLAR SIGN +ENCODING 36 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +7E +D0 +D0 +D0 +7C +16 +16 +16 +16 +FC +10 +00 +00 +00 +ENDCHAR +STARTCHAR PERCENT SIGN +ENCODING 37 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +06 +66 +6C +0C +18 +18 +30 +36 +66 +60 +00 +00 +00 +00 +ENDCHAR +STARTCHAR AMPERSAND +ENCODING 38 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +38 +6C +6C +6C +38 +70 +DA +CC +CC +7A +00 +00 +00 +00 +ENDCHAR +STARTCHAR APOSTROPHE +ENCODING 39 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +18 +18 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT PARENTHESIS +ENCODING 40 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0E +18 +30 +30 +60 +60 +60 +60 +30 +30 +18 +0E +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT PARENTHESIS +ENCODING 41 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +70 +18 +0C +0C +06 +06 +06 +06 +0C +0C +18 +70 +00 +00 +00 +ENDCHAR +STARTCHAR ASTERISK +ENCODING 42 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +66 +3C +18 +FF +18 +3C +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR PLUS SIGN +ENCODING 43 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +18 +18 +7E +18 +18 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR COMMA +ENCODING 44 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +18 +18 +30 +00 +00 +00 +ENDCHAR +STARTCHAR HYPHEN-MINUS +ENCODING 45 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +7E +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR FULL STOP +ENCODING 46 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SOLIDUS +ENCODING 47 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +0C +0C +18 +18 +30 +30 +60 +60 +C0 +C0 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT ZERO +ENCODING 48 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +CE +DE +F6 +E6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT ONE +ENCODING 49 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +38 +78 +58 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT TWO +ENCODING 50 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +06 +06 +0C +18 +30 +60 +C6 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT THREE +ENCODING 51 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +06 +06 +3C +06 +06 +06 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT FOUR +ENCODING 52 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +CC +CC +CC +CC +FE +0C +0C +0C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT FIVE +ENCODING 53 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FE +C6 +C0 +C0 +FC +06 +06 +06 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT SIX +ENCODING 54 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C0 +C0 +FC +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT SEVEN +ENCODING 55 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FE +C6 +06 +06 +0C +18 +30 +30 +30 +30 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT EIGHT +ENCODING 56 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +7C +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIGIT NINE +ENCODING 57 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +C6 +7E +06 +06 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR COLON +ENCODING 58 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +18 +18 +00 +00 +00 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SEMICOLON +ENCODING 59 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +18 +18 +00 +00 +00 +18 +18 +30 +00 +00 +00 +ENDCHAR +STARTCHAR LESS-THAN SIGN +ENCODING 60 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +06 +0C +18 +30 +60 +60 +30 +18 +0C +06 +00 +00 +00 +00 +ENDCHAR +STARTCHAR EQUALS SIGN +ENCODING 61 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +00 +00 +7E +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREATER-THAN SIGN +ENCODING 62 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +60 +30 +18 +0C +06 +06 +0C +18 +30 +60 +00 +00 +00 +00 +ENDCHAR +STARTCHAR QUESTION MARK +ENCODING 63 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +06 +0C +18 +30 +30 +00 +30 +30 +00 +00 +00 +00 +ENDCHAR +STARTCHAR COMMERCIAL AT +ENCODING 64 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +C2 +DA +DA +DA +DA +DE +C0 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A +ENCODING 65 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER B +ENCODING 66 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER C +ENCODING 67 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER D +ENCODING 68 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E +ENCODING 69 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER F +ENCODING 70 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G +ENCODING 71 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER H +ENCODING 72 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I +ENCODING 73 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +18 +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER J +ENCODING 74 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +18 +18 +18 +18 +18 +18 +18 +18 +F0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER K +ENCODING 75 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +CC +F8 +CC +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER L +ENCODING 76 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER M +ENCODING 77 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +EE +FE +D6 +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER N +ENCODING 78 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +E6 +E6 +D6 +D6 +CE +CE +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O +ENCODING 79 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER P +ENCODING 80 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +FC +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Q +ENCODING 81 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +D6 +D6 +7C +18 +0C +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER R +ENCODING 82 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER S +ENCODING 83 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +7C +06 +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER T +ENCODING 84 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FF +18 +18 +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U +ENCODING 85 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER V +ENCODING 86 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +6C +38 +10 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER W +ENCODING 87 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +D6 +FE +EE +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER X +ENCODING 88 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +6C +38 +6C +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Y +ENCODING 89 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Z +ENCODING 90 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FE +06 +06 +0C +18 +30 +60 +C0 +C0 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT SQUARE BRACKET +ENCODING 91 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +3E +30 +30 +30 +30 +30 +30 +30 +30 +30 +30 +3E +00 +00 +00 +ENDCHAR +STARTCHAR REVERSE SOLIDUS +ENCODING 92 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +C0 +C0 +60 +60 +30 +30 +18 +18 +0C +0C +06 +06 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT SQUARE BRACKET +ENCODING 93 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +0C +0C +0C +0C +0C +0C +0C +0C +0C +0C +7C +00 +00 +00 +ENDCHAR +STARTCHAR CIRCUMFLEX ACCENT +ENCODING 94 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +C6 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LOW LINE +ENCODING 95 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +FE +00 +ENDCHAR +STARTCHAR GRAVE ACCENT +ENCODING 96 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +30 +18 +0C +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A +ENCODING 97 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER B +ENCODING 98 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER C +ENCODING 99 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER D +ENCODING 100 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +06 +06 +06 +7E +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E +ENCODING 101 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER F +ENCODING 102 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +1E +30 +30 +30 +7C +30 +30 +30 +30 +30 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G +ENCODING 103 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER H +ENCODING 104 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I +ENCODING 105 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER J +ENCODING 106 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +18 +18 +18 +18 +18 +18 +18 +18 +18 +70 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER K +ENCODING 107 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +CC +D8 +F0 +F0 +D8 +CC +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER L +ENCODING 108 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +30 +30 +30 +30 +30 +30 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER M +ENCODING 109 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +EC +D6 +D6 +D6 +D6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER N +ENCODING 110 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O +ENCODING 111 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER P +ENCODING 112 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +FC +C0 +C0 +C0 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Q +ENCODING 113 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER R +ENCODING 114 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER S +ENCODING 115 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C0 +C0 +7C +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER T +ENCODING 116 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +7C +30 +30 +30 +30 +30 +1E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U +ENCODING 117 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER V +ENCODING 118 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +6C +38 +10 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER W +ENCODING 119 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +D6 +D6 +D6 +D6 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER X +ENCODING 120 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +6C +38 +38 +6C +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Y +ENCODING 121 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Z +ENCODING 122 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FE +06 +0C +18 +30 +60 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT CURLY BRACKET +ENCODING 123 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0E +18 +18 +18 +18 +70 +70 +18 +18 +18 +18 +0E +00 +00 +00 +ENDCHAR +STARTCHAR VERTICAL LINE +ENCODING 124 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT CURLY BRACKET +ENCODING 125 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +70 +18 +18 +18 +18 +0E +0E +18 +18 +18 +18 +70 +00 +00 +00 +ENDCHAR +STARTCHAR TILDE +ENCODING 126 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +32 +7E +4C +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR +ENCODING 127 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR NO-BREAK SPACE +ENCODING 160 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR INVERTED EXCLAMATION MARK +ENCODING 161 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CENT SIGN +ENCODING 162 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +08 +7E +C8 +C8 +C8 +C8 +C8 +7E +08 +00 +00 +00 +ENDCHAR +STARTCHAR POUND SIGN +ENCODING 163 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +38 +6C +60 +60 +60 +F8 +60 +60 +C0 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR CURRENCY SIGN +ENCODING 164 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +3C +66 +66 +66 +3C +66 +00 +00 +00 +00 +ENDCHAR +STARTCHAR YEN SIGN +ENCODING 165 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C3 +C3 +66 +3C +18 +3C +18 +3C +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BROKEN BAR +ENCODING 166 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +18 +18 +18 +00 +00 +18 +18 +18 +18 +18 +00 +00 +ENDCHAR +STARTCHAR SECTION SIGN +ENCODING 167 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +3C +66 +60 +30 +3C +66 +66 +66 +66 +3C +0C +06 +66 +3C +00 +ENDCHAR +STARTCHAR DIAERESIS +ENCODING 168 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR COPYRIGHT SIGN +ENCODING 169 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +82 +9A +A2 +A2 +A2 +9A +82 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR FEMININE ORDINAL INDICATOR +ENCODING 170 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +0C +3C +4C +3C +00 +7C +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT-POINTING DOUBLE ANGLE QUOTATION MARK +ENCODING 171 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +33 +66 +CC +66 +33 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR NOT SIGN +ENCODING 172 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FE +06 +06 +06 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SOFT HYPHEN +ENCODING 173 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +3C +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR REGISTERED SIGN +ENCODING 174 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +82 +BA +AA +B2 +AA +AA +82 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR MACRON +ENCODING 175 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DEGREE SIGN +ENCODING 176 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +38 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR PLUS-MINUS SIGN +ENCODING 177 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +18 +18 +7E +18 +18 +00 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR SUPERSCRIPT TWO +ENCODING 178 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +4C +0C +38 +60 +7C +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SUPERSCRIPT THREE +ENCODING 179 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +1C +26 +0C +06 +26 +1C +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR ACUTE ACCENT +ENCODING 180 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR MICRO SIGN +ENCODING 181 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +CC +CC +CC +CC +CC +CC +F6 +C0 +C0 +C0 +00 +ENDCHAR +STARTCHAR PILCROW SIGN +ENCODING 182 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +D6 +D6 +D6 +76 +16 +16 +16 +16 +16 +00 +00 +00 +00 +ENDCHAR +STARTCHAR MIDDLE DOT +ENCODING 183 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +18 +18 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CEDILLA +ENCODING 184 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +18 +18 +30 +00 +ENDCHAR +STARTCHAR SUPERSCRIPT ONE +ENCODING 185 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +38 +18 +18 +18 +3C +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR MASCULINE ORDINAL INDICATOR +ENCODING 186 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +38 +00 +00 +7C +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK +ENCODING 187 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +CC +66 +33 +66 +CC +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR VULGAR FRACTION ONE QUARTER +ENCODING 188 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +40 +C0 +40 +42 +46 +EC +18 +30 +70 +D4 +94 +1E +04 +04 +00 +ENDCHAR +STARTCHAR VULGAR FRACTION ONE HALF +ENCODING 189 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +40 +C0 +40 +42 +46 +EC +18 +30 +6C +D2 +82 +0C +10 +1E +00 +ENDCHAR +STARTCHAR VULGAR FRACTION THREE QUARTERS +ENCODING 190 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +90 +20 +12 +96 +6C +18 +30 +70 +D4 +94 +1E +04 +04 +00 +ENDCHAR +STARTCHAR INVERTED QUESTION MARK +ENCODING 191 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +18 +18 +30 +60 +C0 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH GRAVE +ENCODING 192 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +30 +18 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH ACUTE +ENCODING 193 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH CIRCUMFLEX +ENCODING 194 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH TILDE +ENCODING 195 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +32 +4C +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH DIAERESIS +ENCODING 196 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH RING ABOVE +ENCODING 197 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +38 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER AE +ENCODING 198 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +D8 +D8 +D8 +FE +D8 +D8 +D8 +D8 +DE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER C WITH CEDILLA +ENCODING 199 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH GRAVE +ENCODING 200 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +30 +18 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH ACUTE +ENCODING 201 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH CIRCUMFLEX +ENCODING 202 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH DIAERESIS +ENCODING 203 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH GRAVE +ENCODING 204 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +30 +18 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH ACUTE +ENCODING 205 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +0C +18 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH CIRCUMFLEX +ENCODING 206 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH DIAERESIS +ENCODING 207 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +66 +66 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER ETH +ENCODING 208 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +66 +66 +66 +F6 +66 +66 +66 +66 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER N WITH TILDE +ENCODING 209 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +32 +4C +00 +C6 +E6 +E6 +D6 +D6 +CE +CE +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH GRAVE +ENCODING 210 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +30 +18 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH ACUTE +ENCODING 211 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH CIRCUMFLEX +ENCODING 212 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH TILDE +ENCODING 213 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +32 +4C +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH DIAERESIS +ENCODING 214 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR MULTIPLICATION SIGN +ENCODING 215 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +6C +38 +38 +6C +C6 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH STROKE +ENCODING 216 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +02 +7C +C6 +CE +CE +D6 +D6 +E6 +E6 +C6 +7C +80 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH GRAVE +ENCODING 217 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +30 +18 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH ACUTE +ENCODING 218 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH CIRCUMFLEX +ENCODING 219 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH DIAERESIS +ENCODING 220 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Y WITH ACUTE +ENCODING 221 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER THORN +ENCODING 222 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +C0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +FC +C0 +C0 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER SHARP S +ENCODING 223 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +78 +CC +CC +CC +D8 +CC +C6 +C6 +D6 +DC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH GRAVE +ENCODING 224 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +30 +18 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH ACUTE +ENCODING 225 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH CIRCUMFLEX +ENCODING 226 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH TILDE +ENCODING 227 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +32 +7E +4C +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH DIAERESIS +ENCODING 228 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH RING ABOVE +ENCODING 229 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +38 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER AE +ENCODING 230 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +6E +16 +16 +7E +D0 +D0 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER C WITH CEDILLA +ENCODING 231 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH GRAVE +ENCODING 232 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +30 +18 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH ACUTE +ENCODING 233 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH CIRCUMFLEX +ENCODING 234 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH DIAERESIS +ENCODING 235 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH GRAVE +ENCODING 236 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +30 +18 +0C +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH ACUTE +ENCODING 237 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH CIRCUMFLEX +ENCODING 238 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +3C +66 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH DIAERESIS +ENCODING 239 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +66 +66 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER ETH +ENCODING 240 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +70 +D8 +0C +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER N WITH TILDE +ENCODING 241 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +32 +7E +4C +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH GRAVE +ENCODING 242 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +30 +18 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH ACUTE +ENCODING 243 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH CIRCUMFLEX +ENCODING 244 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH TILDE +ENCODING 245 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +32 +7E +4C +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH DIAERESIS +ENCODING 246 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DIVISION SIGN +ENCODING 247 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +18 +18 +00 +7E +00 +18 +18 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH STROKE +ENCODING 248 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +02 +7C +C6 +CE +D6 +E6 +C6 +7C +80 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH GRAVE +ENCODING 249 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +30 +18 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH ACUTE +ENCODING 250 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH CIRCUMFLEX +ENCODING 251 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH DIAERESIS +ENCODING 252 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Y WITH ACUTE +ENCODING 253 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER THORN +ENCODING 254 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +FC +C0 +C0 +C0 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Y WITH DIAERESIS +ENCODING 255 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH MACRON +ENCODING 256 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH MACRON +ENCODING 257 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH BREVE +ENCODING 258 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH BREVE +ENCODING 259 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH OGONEK +ENCODING 260 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +C6 +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH OGONEK +ENCODING 261 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +06 +7E +C6 +C6 +C6 +7E +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER C WITH ACUTE +ENCODING 262 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER C WITH ACUTE +ENCODING 263 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER C WITH CIRCUMFLEX +ENCODING 264 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER C WITH CIRCUMFLEX +ENCODING 265 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER C WITH DOT ABOVE +ENCODING 266 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER C WITH DOT ABOVE +ENCODING 267 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER C WITH CARON +ENCODING 268 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER C WITH CARON +ENCODING 269 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER D WITH CARON +ENCODING 270 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +FC +C6 +C6 +C6 +C6 +C6 +C6 +C6 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER D WITH CARON +ENCODING 271 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +16 +06 +06 +7E +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER D WITH STROKE +ENCODING 272 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +66 +66 +66 +F6 +66 +66 +66 +66 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER D WITH STROKE +ENCODING 273 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +06 +1F +06 +7E +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH MACRON +ENCODING 274 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH MACRON +ENCODING 275 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH BREVE +ENCODING 276 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH BREVE +ENCODING 277 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH DOT ABOVE +ENCODING 278 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH DOT ABOVE +ENCODING 279 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH OGONEK +ENCODING 280 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +C0 +7E +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH OGONEK +ENCODING 281 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH CARON +ENCODING 282 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH CARON +ENCODING 283 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G WITH CIRCUMFLEX +ENCODING 284 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G WITH CIRCUMFLEX +ENCODING 285 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G WITH BREVE +ENCODING 286 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G WITH BREVE +ENCODING 287 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G WITH DOT ABOVE +ENCODING 288 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G WITH DOT ABOVE +ENCODING 289 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G WITH CEDILLA +ENCODING 290 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +C6 +7E +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G WITH CEDILLA +ENCODING 291 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +30 +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER H WITH CIRCUMFLEX +ENCODING 292 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +C6 +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER H WITH CIRCUMFLEX +ENCODING 293 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +C0 +C0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER H WITH STROKE +ENCODING 294 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +FF +C6 +C6 +FE +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER H WITH STROKE +ENCODING 295 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +F0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH TILDE +ENCODING 296 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +32 +4C +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH TILDE +ENCODING 297 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +32 +7E +4C +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH MACRON +ENCODING 298 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7E +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH MACRON +ENCODING 299 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7E +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH BREVE +ENCODING 300 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +66 +3C +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH BREVE +ENCODING 301 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +3C +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH OGONEK +ENCODING 302 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +18 +18 +18 +18 +18 +18 +18 +18 +7E +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH OGONEK +ENCODING 303 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +38 +18 +18 +18 +18 +18 +1C +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH DOT ABOVE +ENCODING 304 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER DOTLESS I +ENCODING 305 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LIGATURE IJ +ENCODING 306 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +F7 +66 +66 +66 +66 +66 +66 +66 +66 +EC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LIGATURE IJ +ENCODING 307 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +66 +66 +00 +E6 +66 +66 +66 +66 +66 +76 +06 +06 +1C +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER J WITH CIRCUMFLEX +ENCODING 308 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +18 +18 +18 +18 +18 +18 +18 +F0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER J WITH CIRCUMFLEX +ENCODING 309 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +3C +66 +00 +18 +18 +18 +18 +18 +18 +18 +18 +18 +70 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER K WITH CEDILLA +ENCODING 310 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +CC +F8 +CC +C6 +C6 +C6 +C6 +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER K WITH CEDILLA +ENCODING 311 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +CC +D8 +F0 +F0 +D8 +CC +C6 +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER KRA +ENCODING 312 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +CC +D8 +F0 +D8 +CC +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER L WITH ACUTE +ENCODING 313 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER L WITH ACUTE +ENCODING 314 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +30 +30 +30 +30 +30 +30 +30 +30 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER L WITH CEDILLA +ENCODING 315 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER L WITH CEDILLA +ENCODING 316 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +30 +30 +30 +30 +30 +30 +1C +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER L WITH CARON +ENCODING 317 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER L WITH CARON +ENCODING 318 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +30 +30 +30 +30 +30 +30 +30 +30 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER L WITH MIDDLE DOT +ENCODING 319 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +C0 +C0 +C0 +CC +CC +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER L WITH MIDDLE DOT +ENCODING 320 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +30 +36 +36 +30 +30 +30 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER L WITH STROKE +ENCODING 321 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +60 +60 +68 +78 +70 +E0 +E0 +60 +60 +3E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER L WITH STROKE +ENCODING 322 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +34 +3C +38 +70 +70 +30 +30 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER N WITH ACUTE +ENCODING 323 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +C6 +E6 +E6 +D6 +D6 +CE +CE +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER N WITH ACUTE +ENCODING 324 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER N WITH CEDILLA +ENCODING 325 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +E6 +E6 +D6 +D6 +CE +CE +C6 +C6 +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER N WITH CEDILLA +ENCODING 326 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER N WITH CARON +ENCODING 327 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C6 +E6 +E6 +D6 +D6 +CE +CE +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER N WITH CARON +ENCODING 328 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER N PRECEDED BY APOSTROPHE +ENCODING 329 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +C0 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER ENG +ENCODING 330 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +E6 +E6 +D6 +D6 +CE +CE +C6 +C6 +06 +06 +0C +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER ENG +ENCODING 331 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +06 +06 +0C +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH MACRON +ENCODING 332 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH MACRON +ENCODING 333 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH BREVE +ENCODING 334 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH BREVE +ENCODING 335 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH DOUBLE ACUTE +ENCODING 336 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +66 +CC +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH DOUBLE ACUTE +ENCODING 337 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +36 +6C +D8 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LIGATURE OE +ENCODING 338 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +D8 +D8 +D8 +DE +D8 +D8 +D8 +D8 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LIGATURE OE +ENCODING 339 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +6E +D6 +D6 +DE +D0 +D0 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER R WITH ACUTE +ENCODING 340 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER R WITH ACUTE +ENCODING 341 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7E +C6 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER R WITH CEDILLA +ENCODING 342 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +C6 +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER R WITH CEDILLA +ENCODING 343 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C0 +C0 +C0 +C0 +C0 +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER R WITH CARON +ENCODING 344 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER R WITH CARON +ENCODING 345 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7E +C6 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER S WITH ACUTE +ENCODING 346 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7E +C0 +C0 +C0 +7C +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER S WITH ACUTE +ENCODING 347 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7E +C0 +C0 +7C +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER S WITH CIRCUMFLEX +ENCODING 348 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +C0 +C0 +C0 +7C +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER S WITH CIRCUMFLEX +ENCODING 349 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +7E +C0 +C0 +7C +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER S WITH CEDILLA +ENCODING 350 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +7C +06 +06 +06 +06 +FC +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER S WITH CEDILLA +ENCODING 351 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C0 +C0 +7C +06 +06 +FC +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER S WITH CARON +ENCODING 352 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7E +C0 +C0 +C0 +7C +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER S WITH CARON +ENCODING 353 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7E +C0 +C0 +7C +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER T WITH CEDILLA +ENCODING 354 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FF +18 +18 +18 +18 +18 +18 +18 +18 +18 +0C +0C +18 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER T WITH CEDILLA +ENCODING 355 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +7C +30 +30 +30 +30 +30 +1E +0C +0C +18 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER T WITH CARON +ENCODING 356 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +FF +18 +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER T WITH CARON +ENCODING 357 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +30 +30 +7C +30 +30 +30 +30 +30 +1E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER T WITH STROKE +ENCODING 358 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FF +18 +18 +18 +18 +7E +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER T WITH STROKE +ENCODING 359 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +7C +30 +7C +30 +30 +30 +1E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH TILDE +ENCODING 360 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +32 +4C +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH TILDE +ENCODING 361 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +32 +7E +4C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH MACRON +ENCODING 362 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH MACRON +ENCODING 363 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH BREVE +ENCODING 364 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH BREVE +ENCODING 365 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH RING ABOVE +ENCODING 366 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +38 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH RING ABOVE +ENCODING 367 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +38 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH DOUBLE ACUTE +ENCODING 368 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +66 +CC +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH DOUBLE ACUTE +ENCODING 369 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +36 +6C +D8 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH OGONEK +ENCODING 370 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH OGONEK +ENCODING 371 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +0C +08 +06 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER W WITH CIRCUMFLEX +ENCODING 372 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +C6 +C6 +C6 +C6 +C6 +D6 +FE +EE +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER W WITH CIRCUMFLEX +ENCODING 373 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +C6 +C6 +D6 +D6 +D6 +D6 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Y WITH CIRCUMFLEX +ENCODING 374 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Y WITH CIRCUMFLEX +ENCODING 375 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +10 +38 +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Y WITH DIAERESIS +ENCODING 376 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Z WITH ACUTE +ENCODING 377 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +FE +06 +0C +18 +30 +60 +C0 +C0 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Z WITH ACUTE +ENCODING 378 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +FE +06 +0C +18 +30 +60 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Z WITH DOT ABOVE +ENCODING 379 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +FE +06 +0C +18 +30 +60 +C0 +C0 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Z WITH DOT ABOVE +ENCODING 380 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +FE +06 +0C +18 +30 +60 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER Z WITH CARON +ENCODING 381 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +FE +06 +0C +18 +30 +60 +C0 +C0 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER Z WITH CARON +ENCODING 382 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +FE +06 +0C +18 +30 +60 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER LONG S +ENCODING 383 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +1E +30 +30 +70 +30 +30 +30 +30 +30 +30 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER F WITH HOOK +ENCODING 402 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +0E +1B +1B +18 +18 +18 +7E +18 +18 +18 +D8 +D8 +70 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH CARON +ENCODING 461 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH CARON +ENCODING 462 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH CARON +ENCODING 463 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH CARON +ENCODING 464 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH CARON +ENCODING 465 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH CARON +ENCODING 466 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH CARON +ENCODING 467 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH CARON +ENCODING 468 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER AE WITH MACRON +ENCODING 482 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +7E +D8 +D8 +D8 +FE +D8 +D8 +D8 +DE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER AE WITH MACRON +ENCODING 483 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +00 +6E +16 +16 +7E +D0 +D0 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G WITH CARON +ENCODING 486 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G WITH CARON +ENCODING 487 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER K WITH CARON +ENCODING 488 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C6 +C6 +C6 +CC +F8 +CC +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER K WITH CARON +ENCODING 489 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C0 +C0 +CC +D8 +F0 +F0 +D8 +CC +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH OGONEK +ENCODING 490 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +18 +10 +0C +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH OGONEK +ENCODING 491 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +18 +10 +0C +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH OGONEK AND MACRON +ENCODING 492 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +7C +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +18 +10 +0C +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH OGONEK AND MACRON +ENCODING 493 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +18 +10 +0C +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER J WITH CARON +ENCODING 496 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +38 +10 +00 +18 +18 +18 +18 +18 +18 +18 +18 +18 +70 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER G WITH ACUTE +ENCODING 500 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7E +C0 +C0 +C0 +DE +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER G WITH ACUTE +ENCODING 501 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +7E +C6 +C6 +C6 +C6 +C6 +7C +06 +06 +FC +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER AE WITH ACUTE +ENCODING 508 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +00 +7E +D8 +D8 +D8 +FE +D8 +D8 +D8 +DE +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER AE WITH ACUTE +ENCODING 509 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +00 +6E +16 +16 +7E +D0 +D0 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH STROKE AND ACUTE +ENCODING 510 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +30 +02 +7C +CE +CE +D6 +D6 +E6 +E6 +C6 +7C +80 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH STROKE AND ACUTE +ENCODING 511 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0C +18 +30 +02 +7C +C6 +CE +D6 +E6 +C6 +7C +80 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH DOUBLE GRAVE +ENCODING 512 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +CC +66 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH DOUBLE GRAVE +ENCODING 513 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +6C +36 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH INVERTED BREVE +ENCODING 514 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH INVERTED BREVE +ENCODING 515 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH DOUBLE GRAVE +ENCODING 516 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +CC +66 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH DOUBLE GRAVE +ENCODING 517 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +6C +36 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH INVERTED BREVE +ENCODING 518 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH INVERTED BREVE +ENCODING 519 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH DOUBLE GRAVE +ENCODING 520 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +CC +66 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH DOUBLE GRAVE +ENCODING 521 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +6C +36 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER I WITH INVERTED BREVE +ENCODING 522 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +3C +66 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER I WITH INVERTED BREVE +ENCODING 523 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +3C +66 +66 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH DOUBLE GRAVE +ENCODING 524 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +CC +66 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH DOUBLE GRAVE +ENCODING 525 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +6C +36 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH INVERTED BREVE +ENCODING 526 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH INVERTED BREVE +ENCODING 527 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER R WITH DOUBLE GRAVE +ENCODING 528 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +CC +66 +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER R WITH DOUBLE GRAVE +ENCODING 529 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +6C +36 +00 +7E +C6 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER R WITH INVERTED BREVE +ENCODING 530 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER R WITH INVERTED BREVE +ENCODING 531 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +00 +7E +C6 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH DOUBLE GRAVE +ENCODING 532 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +CC +66 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH DOUBLE GRAVE +ENCODING 533 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +D8 +6C +36 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER U WITH INVERTED BREVE +ENCODING 534 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +38 +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER U WITH INVERTED BREVE +ENCODING 535 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +38 +6C +6C +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER H WITH CARON +ENCODING 542 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C6 +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER H WITH CARON +ENCODING 543 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +10 +C0 +C0 +C0 +FC +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER A WITH DOT ABOVE +ENCODING 550 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER A WITH DOT ABOVE +ENCODING 551 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER E WITH CEDILLA +ENCODING 552 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +C0 +7E +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER E WITH CEDILLA +ENCODING 553 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +18 +18 +30 +00 +ENDCHAR +STARTCHAR LATIN CAPITAL LETTER O WITH DOT ABOVE +ENCODING 558 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LATIN SMALL LETTER O WITH DOT ABOVE +ENCODING 559 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR BREVE +ENCODING 728 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOT ABOVE +ENCODING 729 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR OGONEK +ENCODING 731 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +0C +08 +06 +00 +ENDCHAR +STARTCHAR SMALL TILDE +ENCODING 732 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +32 +4C +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOUBLE ACUTE ACCENT +ENCODING 733 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +66 +CC +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR COMBINING BREVE +ENCODING 774 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR COMBINING DIAERESIS +ENCODING 776 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK CAPITAL LETTER GAMMA +ENCODING 915 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FE +C6 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK CAPITAL LETTER THETA +ENCODING 920 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +38 +6C +C6 +C6 +FE +C6 +C6 +C6 +6C +38 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK CAPITAL LETTER SIGMA +ENCODING 931 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FE +C6 +60 +30 +18 +18 +30 +60 +C6 +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK CAPITAL LETTER PHI +ENCODING 934 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +10 +7C +D6 +D6 +D6 +D6 +D6 +D6 +7C +10 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK CAPITAL LETTER OMEGA +ENCODING 937 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +38 +6C +C6 +C6 +C6 +C6 +C6 +6C +6C +EE +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER ALPHA +ENCODING 945 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +76 +DC +D8 +D8 +D8 +DC +76 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER DELTA +ENCODING 948 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +1E +30 +18 +0C +3C +66 +66 +66 +66 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER EPSILON +ENCODING 949 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +C6 +C0 +70 +C0 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER PI +ENCODING 960 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FE +6C +6C +6C +6C +6C +6C +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER SIGMA +ENCODING 963 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +D8 +D8 +D8 +D8 +D8 +70 +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER TAU +ENCODING 964 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +18 +18 +18 +18 +18 +0C +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREEK SMALL LETTER PHI +ENCODING 966 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +4C +D6 +D6 +D6 +D6 +D6 +7C +10 +10 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER IO +ENCODING 1025 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +6C +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER BYELORUSSIAN-UKRAINIAN I +ENCODING 1030 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +18 +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER YI +ENCODING 1031 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +66 +66 +00 +7E +18 +18 +18 +18 +18 +18 +18 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER SHORT U +ENCODING 1038 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER A +ENCODING 1040 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER BE +ENCODING 1041 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C0 +C0 +C0 +FC +C6 +C6 +C6 +C6 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER VE +ENCODING 1042 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +FC +C6 +C6 +C6 +C6 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER GHE +ENCODING 1043 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER DE +ENCODING 1044 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +1C +3C +6C +6C +6C +6C +6C +6C +FE +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER IE +ENCODING 1045 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +F8 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER ZHE +ENCODING 1046 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +D6 +D6 +D6 +7C +38 +7C +D6 +D6 +D6 +D6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER ZE +ENCODING 1047 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +06 +06 +06 +3C +06 +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER I +ENCODING 1048 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +CE +CE +D6 +D6 +E6 +E6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER SHORT I +ENCODING 1049 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +6C +38 +00 +C6 +CE +CE +D6 +D6 +E6 +E6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER KA +ENCODING 1050 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +CC +F8 +CC +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER EL +ENCODING 1051 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER EM +ENCODING 1052 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +EE +FE +D6 +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER EN +ENCODING 1053 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +FE +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER O +ENCODING 1054 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER PE +ENCODING 1055 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FE +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER ER +ENCODING 1056 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +C6 +C6 +C6 +FC +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER ES +ENCODING 1057 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER TE +ENCODING 1058 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FF +18 +18 +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER U +ENCODING 1059 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER EF +ENCODING 1060 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +10 +7C +D6 +D6 +D6 +D6 +D6 +D6 +7C +10 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER HA +ENCODING 1061 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +6C +38 +6C +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER TSE +ENCODING 1062 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7F +03 +03 +06 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER CHE +ENCODING 1063 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +7E +06 +06 +06 +06 +06 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER SHA +ENCODING 1064 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +D6 +D6 +D6 +D6 +D6 +D6 +D6 +D6 +D6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER SHCHA +ENCODING 1065 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +D6 +D6 +D6 +D6 +D6 +D6 +D6 +D6 +D6 +7F +03 +03 +06 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER HARD SIGN +ENCODING 1066 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +F0 +F0 +30 +30 +3C +36 +36 +36 +36 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER YERU +ENCODING 1067 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C6 +C6 +C6 +C6 +F6 +DE +DE +DE +DE +F6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER SOFT SIGN +ENCODING 1068 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +30 +30 +30 +30 +3C +36 +36 +36 +36 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER E +ENCODING 1069 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FC +06 +06 +06 +3E +06 +06 +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER YU +ENCODING 1070 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +CC +D6 +D6 +D6 +F6 +F6 +D6 +D6 +D6 +CC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER YA +ENCODING 1071 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +C6 +C6 +C6 +7E +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER A +ENCODING 1072 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +06 +7E +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER BE +ENCODING 1073 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +C0 +FC +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER VE +ENCODING 1074 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +70 +D8 +D8 +D8 +FC +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER GHE +ENCODING 1075 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +06 +06 +7C +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER DE +ENCODING 1076 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER IE +ENCODING 1077 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER ZHE +ENCODING 1078 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +D6 +7C +38 +38 +7C +D6 +D6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER ZE +ENCODING 1079 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +06 +06 +3C +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER I +ENCODING 1080 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER SHORT I +ENCODING 1081 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER KA +ENCODING 1082 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +CC +CC +D8 +F0 +F0 +D8 +CC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER EL +ENCODING 1083 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER EM +ENCODING 1084 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +EE +FE +D6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER EN +ENCODING 1085 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +FE +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER O +ENCODING 1086 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER PE +ENCODING 1087 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER ER +ENCODING 1088 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +C6 +C6 +C6 +C6 +C6 +FC +C0 +C0 +C0 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER ES +ENCODING 1089 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C0 +C0 +C0 +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER TE +ENCODING 1090 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +EC +D6 +D6 +D6 +D6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER U +ENCODING 1091 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER EF +ENCODING 1092 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7C +D6 +D6 +D6 +D6 +D6 +7C +10 +10 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER HA +ENCODING 1093 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +6C +38 +38 +6C +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER TSE +ENCODING 1094 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7F +03 +03 +06 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER CHE +ENCODING 1095 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +C6 +7E +06 +06 +06 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER SHA +ENCODING 1096 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +D6 +D6 +D6 +D6 +6E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER SHCHA +ENCODING 1097 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +D6 +D6 +D6 +D6 +6F +03 +03 +06 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER HARD SIGN +ENCODING 1098 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +F0 +30 +3C +36 +36 +36 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER YERU +ENCODING 1099 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +C6 +C6 +F6 +DE +DE +DE +F6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER SOFT SIGN +ENCODING 1100 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +30 +30 +3C +36 +36 +36 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER E +ENCODING 1101 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FC +06 +06 +3E +06 +06 +FC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER YU +ENCODING 1102 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +CC +D6 +D6 +F6 +D6 +D6 +CC +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER YA +ENCODING 1103 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +C6 +C6 +7E +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER IO +ENCODING 1105 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +6C +6C +00 +7E +C6 +C6 +FE +C0 +C0 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I +ENCODING 1110 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER YI +ENCODING 1111 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +66 +66 +00 +38 +18 +18 +18 +18 +18 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER SHORT U +ENCODING 1118 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +6C +6C +38 +00 +C6 +C6 +C6 +C6 +C6 +C6 +7E +06 +06 +FC +00 +ENDCHAR +STARTCHAR CYRILLIC CAPITAL LETTER GHE WITH UPTURN +ENCODING 1168 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +06 +06 +7C +C0 +C0 +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR CYRILLIC SMALL LETTER GHE WITH UPTURN +ENCODING 1169 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +06 +06 +7C +C0 +C0 +C0 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOUBLE VERTICAL LINE +ENCODING 8214 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +66 +66 +66 +66 +66 +66 +66 +66 +66 +66 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT SINGLE QUOTATION MARK +ENCODING 8216 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +18 +18 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT SINGLE QUOTATION MARK +ENCODING 8217 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +18 +18 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT DOUBLE QUOTATION MARK +ENCODING 8220 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT DOUBLE QUOTATION MARK +ENCODING 8221 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BULLET +ENCODING 8226 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +18 +3C +3C +18 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR HORIZONTAL ELLIPSIS +ENCODING 8230 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +DB +DB +00 +00 +00 +00 +ENDCHAR +STARTCHAR SINGLE LEFT-POINTING ANGLE QUOTATION MARK +ENCODING 8249 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +0C +18 +30 +18 +0C +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SINGLE RIGHT-POINTING ANGLE QUOTATION MARK +ENCODING 8250 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +30 +18 +0C +18 +30 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOUBLE EXCLAMATION MARK +ENCODING 8252 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +66 +66 +66 +66 +66 +66 +66 +00 +66 +66 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SUPERSCRIPT LATIN SMALL LETTER N +ENCODING 8319 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +78 +6C +6C +6C +6C +6C +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR PESETA SIGN +ENCODING 8359 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +F8 +CC +CC +F8 +C0 +CC +DE +CC +CC +CC +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR EURO SIGN +ENCODING 8364 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +1C +36 +60 +F8 +60 +F8 +60 +36 +1C +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFTWARDS ARROW +ENCODING 8592 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +20 +40 +FE +40 +20 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR UPWARDS ARROW +ENCODING 8593 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +3C +5A +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHTWARDS ARROW +ENCODING 8594 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +08 +04 +FE +04 +08 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOWNWARDS ARROW +ENCODING 8595 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +18 +18 +18 +18 +18 +5A +3C +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT RIGHT ARROW +ENCODING 8596 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +28 +44 +FE +44 +28 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR UP DOWN ARROW +ENCODING 8597 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +3C +5A +18 +18 +18 +18 +5A +3C +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR UP DOWN ARROW WITH BASE +ENCODING 8616 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +3C +5A +18 +18 +18 +18 +5A +3C +18 +7E +00 +00 +00 +ENDCHAR +STARTCHAR BULLET OPERATOR +ENCODING 8729 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +38 +38 +38 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR SQUARE ROOT +ENCODING 8730 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +0F +0C +0C +0C +0C +0C +CC +6C +3C +1C +0C +00 +00 +00 +00 +ENDCHAR +STARTCHAR INFINITY +ENCODING 8734 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +7E +DB +DB +DB +7E +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR INTERSECTION +ENCODING 8745 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +7C +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +00 +00 +00 +00 +ENDCHAR +STARTCHAR UNION +ENCODING 8746 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +C6 +7C +00 +00 +00 +00 +ENDCHAR +STARTCHAR ALMOST EQUAL TO +ENCODING 8776 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +32 +4C +00 +32 +4C +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR IDENTICAL TO +ENCODING 8801 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +7E +00 +00 +7E +00 +00 +7E +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LESS-THAN OR EQUAL TO +ENCODING 8804 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +0C +18 +30 +18 +0C +00 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR GREATER-THAN OR EQUAL TO +ENCODING 8805 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +30 +18 +0C +18 +30 +00 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR HOUSE +ENCODING 8962 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +10 +38 +6C +C6 +C6 +C6 +FE +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR REVERSED NOT SIGN +ENCODING 8976 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FE +C0 +C0 +C0 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TURNED NOT SIGN +ENCODING 8985 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +C0 +C0 +C0 +FE +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TOP HALF INTEGRAL +ENCODING 8992 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +0E +1B +1B +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOTTOM HALF INTEGRAL +ENCODING 8993 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +18 +18 +D8 +D8 +70 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT HORIZONTAL +ENCODING 9472 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY HORIZONTAL +ENCODING 9473 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +FF +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT VERTICAL +ENCODING 9474 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY VERTICAL +ENCODING 9475 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT TRIPLE DASH HORIZONTAL +ENCODING 9476 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +DB +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY TRIPLE DASH HORIZONTAL +ENCODING 9477 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +DB +DB +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT TRIPLE DASH VERTICAL +ENCODING 9478 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +18 +00 +00 +18 +18 +18 +00 +00 +18 +18 +18 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY TRIPLE DASH VERTICAL +ENCODING 9479 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +1C +1C +1C +00 +00 +1C +1C +1C +00 +00 +1C +1C +1C +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT QUADRUPLE DASH HORIZONTAL +ENCODING 9480 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +AA +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY QUADRUPLE DASH HORIZONTAL +ENCODING 9481 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +AA +AA +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT QUADRUPLE DASH VERTICAL +ENCODING 9482 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +00 +00 +18 +18 +00 +00 +18 +18 +00 +00 +18 +18 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY QUADRUPLE DASH VERTICAL +ENCODING 9483 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +1C +1C +00 +00 +1C +1C +00 +00 +1C +1C +00 +00 +1C +1C +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DOWN AND RIGHT +ENCODING 9484 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +1F +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN LIGHT AND RIGHT HEAVY +ENCODING 9485 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +1F +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN HEAVY AND RIGHT LIGHT +ENCODING 9486 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +1F +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY DOWN AND RIGHT +ENCODING 9487 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +1F +1F +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DOWN AND LEFT +ENCODING 9488 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +F8 +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN LIGHT AND LEFT HEAVY +ENCODING 9489 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +F8 +F8 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN HEAVY AND LEFT LIGHT +ENCODING 9490 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FC +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY DOWN AND LEFT +ENCODING 9491 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FC +FC +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT UP AND RIGHT +ENCODING 9492 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +1F +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP LIGHT AND RIGHT HEAVY +ENCODING 9493 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +1F +1F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP HEAVY AND RIGHT LIGHT +ENCODING 9494 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1F +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY UP AND RIGHT +ENCODING 9495 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1F +1F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT UP AND LEFT +ENCODING 9496 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +F8 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP LIGHT AND LEFT HEAVY +ENCODING 9497 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +F8 +F8 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP HEAVY AND LEFT LIGHT +ENCODING 9498 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FC +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY UP AND LEFT +ENCODING 9499 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FC +FC +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT VERTICAL AND RIGHT +ENCODING 9500 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +1F +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL LIGHT AND RIGHT HEAVY +ENCODING 9501 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +1F +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS UP HEAVY AND RIGHT DOWN LIGHT +ENCODING 9502 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1F +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN HEAVY AND RIGHT UP LIGHT +ENCODING 9503 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +1F +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL HEAVY AND RIGHT LIGHT +ENCODING 9504 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1F +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN LIGHT AND RIGHT UP HEAVY +ENCODING 9505 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1F +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS UP LIGHT AND RIGHT DOWN HEAVY +ENCODING 9506 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +1F +1F +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY VERTICAL AND RIGHT +ENCODING 9507 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1F +1F +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT VERTICAL AND LEFT +ENCODING 9508 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +F8 +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL LIGHT AND LEFT HEAVY +ENCODING 9509 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +F8 +F8 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS UP HEAVY AND LEFT DOWN LIGHT +ENCODING 9510 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FC +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN HEAVY AND LEFT UP LIGHT +ENCODING 9511 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FC +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL HEAVY AND LEFT LIGHT +ENCODING 9512 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FC +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN LIGHT AND LEFT UP HEAVY +ENCODING 9513 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FC +FC +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS UP LIGHT AND LEFT DOWN HEAVY +ENCODING 9514 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FC +FC +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY VERTICAL AND LEFT +ENCODING 9515 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FC +FC +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DOWN AND HORIZONTAL +ENCODING 9516 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT HEAVY AND RIGHT DOWN LIGHT +ENCODING 9517 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +F8 +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT HEAVY AND LEFT DOWN LIGHT +ENCODING 9518 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +1F +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN LIGHT AND HORIZONTAL HEAVY +ENCODING 9519 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN HEAVY AND HORIZONTAL LIGHT +ENCODING 9520 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT LIGHT AND LEFT DOWN HEAVY +ENCODING 9521 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +FC +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT LIGHT AND RIGHT DOWN HEAVY +ENCODING 9522 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +1F +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY DOWN AND HORIZONTAL +ENCODING 9523 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +FF +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT UP AND HORIZONTAL +ENCODING 9524 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT HEAVY AND RIGHT UP LIGHT +ENCODING 9525 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +F8 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT HEAVY AND LEFT UP LIGHT +ENCODING 9526 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +1F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP LIGHT AND HORIZONTAL HEAVY +ENCODING 9527 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +FF +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP HEAVY AND HORIZONTAL LIGHT +ENCODING 9528 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT LIGHT AND LEFT UP HEAVY +ENCODING 9529 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +FC +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT LIGHT AND RIGHT UP HEAVY +ENCODING 9530 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +1F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY UP AND HORIZONTAL +ENCODING 9531 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +FF +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL +ENCODING 9532 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT HEAVY AND RIGHT VERTICAL LIGHT +ENCODING 9533 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +F8 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT HEAVY AND LEFT VERTICAL LIGHT +ENCODING 9534 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL LIGHT AND HORIZONTAL HEAVY +ENCODING 9535 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS UP HEAVY AND DOWN HORIZONTAL LIGHT +ENCODING 9536 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN HEAVY AND UP HORIZONTAL LIGHT +ENCODING 9537 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL HEAVY AND HORIZONTAL LIGHT +ENCODING 9538 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT UP HEAVY AND RIGHT DOWN LIGHT +ENCODING 9539 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +F8 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT UP HEAVY AND LEFT DOWN LIGHT +ENCODING 9540 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT DOWN HEAVY AND RIGHT UP LIGHT +ENCODING 9541 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +FC +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT DOWN HEAVY AND LEFT UP LIGHT +ENCODING 9542 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +1F +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN LIGHT AND UP HORIZONTAL HEAVY +ENCODING 9543 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS UP LIGHT AND DOWN HORIZONTAL HEAVY +ENCODING 9544 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +FF +FF +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS RIGHT LIGHT AND LEFT VERTICAL HEAVY +ENCODING 9545 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +FC +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LEFT LIGHT AND RIGHT VERTICAL HEAVY +ENCODING 9546 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +1F +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY VERTICAL AND HORIZONTAL +ENCODING 9547 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +FF +FF +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DOUBLE DASH HORIZONTAL +ENCODING 9548 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +EE +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY DOUBLE DASH HORIZONTAL +ENCODING 9549 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +EE +EE +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DOUBLE DASH VERTICAL +ENCODING 9550 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +18 +18 +18 +18 +18 +18 +00 +00 +18 +18 +18 +18 +18 +18 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY DOUBLE DASH VERTICAL +ENCODING 9551 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +1C +1C +1C +1C +1C +1C +00 +00 +1C +1C +1C +1C +1C +1C +00 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE HORIZONTAL +ENCODING 9552 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FF +00 +FF +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE VERTICAL +ENCODING 9553 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN SINGLE AND RIGHT DOUBLE +ENCODING 9554 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +1F +18 +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN DOUBLE AND RIGHT SINGLE +ENCODING 9555 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +3F +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE DOWN AND RIGHT +ENCODING 9556 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +3F +30 +37 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN SINGLE AND LEFT DOUBLE +ENCODING 9557 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +F8 +18 +F8 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN DOUBLE AND LEFT SINGLE +ENCODING 9558 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FE +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE DOWN AND LEFT +ENCODING 9559 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FE +06 +F6 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS UP SINGLE AND RIGHT DOUBLE +ENCODING 9560 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +1F +18 +1F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP DOUBLE AND RIGHT SINGLE +ENCODING 9561 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +3F +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE UP AND RIGHT +ENCODING 9562 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +37 +30 +3F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP SINGLE AND LEFT DOUBLE +ENCODING 9563 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +F8 +18 +F8 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP DOUBLE AND LEFT SINGLE +ENCODING 9564 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +FE +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE UP AND LEFT +ENCODING 9565 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +F6 +06 +FE +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL SINGLE AND RIGHT DOUBLE +ENCODING 9566 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +1F +18 +1F +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL DOUBLE AND RIGHT SINGLE +ENCODING 9567 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +37 +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE VERTICAL AND RIGHT +ENCODING 9568 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +37 +30 +37 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL SINGLE AND LEFT DOUBLE +ENCODING 9569 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +F8 +18 +F8 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL DOUBLE AND LEFT SINGLE +ENCODING 9570 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +F6 +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE VERTICAL AND LEFT +ENCODING 9571 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +F6 +06 +F6 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN SINGLE AND HORIZONTAL DOUBLE +ENCODING 9572 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FF +00 +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS DOWN DOUBLE AND HORIZONTAL SINGLE +ENCODING 9573 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE DOWN AND HORIZONTAL +ENCODING 9574 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FF +00 +F7 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS UP SINGLE AND HORIZONTAL DOUBLE +ENCODING 9575 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +FF +00 +FF +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS UP DOUBLE AND HORIZONTAL SINGLE +ENCODING 9576 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +FF +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE UP AND HORIZONTAL +ENCODING 9577 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +F7 +00 +FF +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL SINGLE AND HORIZONTAL DOUBLE +ENCODING 9578 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +FF +18 +FF +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS VERTICAL DOUBLE AND HORIZONTAL SINGLE +ENCODING 9579 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +36 +FF +36 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS DOUBLE VERTICAL AND HORIZONTAL +ENCODING 9580 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +36 +36 +36 +36 +36 +36 +F7 +00 +F7 +36 +36 +36 +36 +36 +36 +36 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT ARC DOWN AND RIGHT +ENCODING 9581 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +0F +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT ARC DOWN AND LEFT +ENCODING 9582 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +F0 +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT ARC UP AND LEFT +ENCODING 9583 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +F0 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT ARC UP AND RIGHT +ENCODING 9584 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +0F +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DIAGONAL UPPER RIGHT TO LOWER LEFT +ENCODING 9585 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +01 +03 +03 +06 +06 +0C +0C +18 +18 +30 +30 +60 +60 +C0 +C0 +80 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DIAGONAL UPPER LEFT TO LOWER RIGHT +ENCODING 9586 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +80 +C0 +C0 +60 +60 +30 +30 +18 +18 +0C +0C +06 +06 +03 +03 +01 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DIAGONAL CROSS +ENCODING 9587 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +81 +C3 +C3 +66 +66 +3C +3C +18 +18 +3C +3C +66 +66 +C3 +C3 +81 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT LEFT +ENCODING 9588 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +F0 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT UP +ENCODING 9589 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT RIGHT +ENCODING 9590 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +0F +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT DOWN +ENCODING 9591 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY LEFT +ENCODING 9592 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +F0 +F0 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY UP +ENCODING 9593 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1C +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY RIGHT +ENCODING 9594 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +0F +0F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY DOWN +ENCODING 9595 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT LEFT AND HEAVY RIGHT +ENCODING 9596 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +0F +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS LIGHT UP AND HEAVY DOWN +ENCODING 9597 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +18 +18 +18 +18 +18 +18 +18 +18 +1C +1C +1C +1C +1C +1C +1C +1C +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY LEFT AND LIGHT RIGHT +ENCODING 9598 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +FF +F0 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BOX DRAWINGS HEAVY UP AND LIGHT DOWN +ENCODING 9599 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +1C +1C +1C +1C +1C +1C +1C +1C +18 +18 +18 +18 +18 +18 +18 +18 +ENDCHAR +STARTCHAR UPPER HALF BLOCK +ENCODING 9600 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +FF +FF +FF +FF +FF +FF +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LOWER ONE EIGHTH BLOCK +ENCODING 9601 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +FF +FF +ENDCHAR +STARTCHAR LOWER ONE QUARTER BLOCK +ENCODING 9602 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +FF +FF +FF +FF +ENDCHAR +STARTCHAR LOWER THREE EIGHTHS BLOCK +ENCODING 9603 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR LOWER HALF BLOCK +ENCODING 9604 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR LOWER FIVE EIGHTHS BLOCK +ENCODING 9605 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR LOWER THREE QUARTERS BLOCK +ENCODING 9606 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR LOWER SEVEN EIGHTHS BLOCK +ENCODING 9607 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR FULL BLOCK +ENCODING 9608 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR LEFT SEVEN EIGHTHS BLOCK +ENCODING 9609 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +FE +ENDCHAR +STARTCHAR LEFT THREE QUARTERS BLOCK +ENCODING 9610 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +FC +ENDCHAR +STARTCHAR LEFT FIVE EIGHTHS BLOCK +ENCODING 9611 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +F8 +ENDCHAR +STARTCHAR LEFT HALF BLOCK +ENCODING 9612 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +ENDCHAR +STARTCHAR LEFT THREE EIGHTHS BLOCK +ENCODING 9613 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +E0 +ENDCHAR +STARTCHAR LEFT ONE QUARTER BLOCK +ENCODING 9614 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +C0 +ENDCHAR +STARTCHAR LEFT ONE EIGHTH BLOCK +ENCODING 9615 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +80 +ENDCHAR +STARTCHAR RIGHT HALF BLOCK +ENCODING 9616 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +0F +ENDCHAR +STARTCHAR LIGHT SHADE +ENCODING 9617 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +11 +44 +11 +44 +11 +44 +11 +44 +11 +44 +11 +44 +11 +44 +11 +44 +ENDCHAR +STARTCHAR MEDIUM SHADE +ENCODING 9618 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +55 +AA +55 +AA +55 +AA +55 +AA +55 +AA +55 +AA +55 +AA +55 +AA +ENDCHAR +STARTCHAR DARK SHADE +ENCODING 9619 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +DD +77 +DD +77 +DD +77 +DD +77 +DD +77 +DD +77 +DD +77 +DD +77 +ENDCHAR +STARTCHAR UPPER ONE EIGHTH BLOCK +ENCODING 9620 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHT ONE EIGHTH BLOCK +ENCODING 9621 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +01 +ENDCHAR +STARTCHAR QUADRANT LOWER LEFT +ENCODING 9622 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +ENDCHAR +STARTCHAR QUADRANT LOWER RIGHT +ENCODING 9623 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +0F +0F +0F +0F +0F +0F +0F +0F +ENDCHAR +STARTCHAR QUADRANT UPPER LEFT +ENCODING 9624 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR QUADRANT UPPER LEFT AND LOWER LEFT AND LOWER RIGHT +ENCODING 9625 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR QUADRANT UPPER LEFT AND LOWER RIGHT +ENCODING 9626 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +0F +0F +0F +0F +0F +0F +0F +0F +ENDCHAR +STARTCHAR QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER LEFT +ENCODING 9627 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +FF +FF +FF +FF +FF +FF +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +ENDCHAR +STARTCHAR QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER RIGHT +ENCODING 9628 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +FF +FF +FF +FF +FF +FF +0F +0F +0F +0F +0F +0F +0F +0F +ENDCHAR +STARTCHAR QUADRANT UPPER RIGHT +ENCODING 9629 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +0F +0F +0F +0F +0F +0F +0F +0F +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR QUADRANT UPPER RIGHT AND LOWER LEFT +ENCODING 9630 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +0F +0F +0F +0F +0F +0F +0F +0F +F0 +F0 +F0 +F0 +F0 +F0 +F0 +F0 +ENDCHAR +STARTCHAR QUADRANT UPPER RIGHT AND LOWER LEFT AND LOWER RIGHT +ENCODING 9631 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +0F +0F +0F +0F +0F +0F +0F +0F +FF +FF +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR BLACK SQUARE +ENCODING 9632 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +7C +7C +7C +7C +7C +7C +7C +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK RECTANGLE +ENCODING 9644 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +FE +FE +FE +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK UP-POINTING TRIANGLE +ENCODING 9650 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +10 +38 +38 +7C +7C +FE +FE +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK DOWN-POINTING TRIANGLE +ENCODING 9660 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +FE +FE +7C +7C +38 +38 +10 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK DIAMOND +ENCODING 9670 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +10 +38 +7C +FE +7C +38 +10 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LOZENGE +ENCODING 9674 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +10 +38 +6C +C6 +6C +38 +10 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR WHITE CIRCLE +ENCODING 9675 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +3C +66 +66 +66 +66 +3C +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK CIRCLE +ENCODING 9679 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +3C +7E +7E +7E +7E +3C +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR INVERSE BULLET +ENCODING 9688 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +FF +FF +FF +FF +E7 +C3 +C3 +E7 +FF +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR INVERSE WHITE CIRCLE +ENCODING 9689 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FF +FF +FF +FF +C3 +99 +99 +99 +99 +C3 +FF +FF +FF +FF +FF +ENDCHAR +STARTCHAR BLACK LOWER RIGHT TRIANGLE +ENCODING 9698 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +80 +C0 +E0 +F0 +F8 +FC +FE +FF +ENDCHAR +STARTCHAR BLACK LOWER LEFT TRIANGLE +ENCODING 9699 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +01 +03 +07 +0F +1F +3F +7F +FF +ENDCHAR +STARTCHAR BLACK UPPER LEFT TRIANGLE +ENCODING 9700 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +FE +FC +F8 +F0 +E0 +C0 +80 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK UPPER RIGHT TRIANGLE +ENCODING 9701 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +FF +7F +3F +1F +0F +07 +03 +01 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR HEAVEN +ENCODING 9776 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +7E +00 +00 +7E +00 +00 +7E +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR LAKE +ENCODING 9777 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +66 +00 +00 +7E +00 +00 +7E +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR FIRE +ENCODING 9778 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +7E +00 +00 +66 +00 +00 +7E +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR THUNDER +ENCODING 9779 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +66 +00 +00 +66 +00 +00 +7E +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR WIND +ENCODING 9780 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +7E +00 +00 +7E +00 +00 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR WATER +ENCODING 9781 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +66 +00 +00 +7E +00 +00 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR MOUNTAIN +ENCODING 9782 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +7E +00 +00 +66 +00 +00 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR TRIGRAM FOR EARTH +ENCODING 9783 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +66 +00 +00 +66 +00 +00 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR WHITE SMILING FACE +ENCODING 9786 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +81 +A5 +81 +81 +A5 +99 +81 +81 +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK SMILING FACE +ENCODING 9787 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +FF +DB +FF +FF +DB +E7 +FF +FF +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR WHITE SUN WITH RAYS +ENCODING 9788 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +18 +18 +DB +3C +E7 +3C +DB +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR FEMALE SIGN +ENCODING 9792 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +3C +66 +66 +66 +66 +3C +18 +3C +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR MALE SIGN +ENCODING 9794 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +0F +07 +0D +19 +3C +66 +66 +66 +66 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK SPADE SUIT +ENCODING 9824 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +18 +3C +7E +FF +FF +7E +18 +18 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK CLUB SUIT +ENCODING 9827 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +18 +3C +3C +E7 +E7 +E7 +18 +18 +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK HEART SUIT +ENCODING 9829 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +6C +FE +FE +FE +7C +38 +10 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BLACK DIAMOND SUIT +ENCODING 9830 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +10 +38 +7C +FE +7C +38 +10 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR EIGHTH NOTE +ENCODING 9834 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +66 +7E +60 +60 +60 +60 +60 +C0 +C0 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BEAMED EIGHTH NOTES +ENCODING 9835 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +7E +66 +7E +66 +66 +66 +66 +66 +6E +CC +C0 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN BLANK +ENCODING 10240 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1 +ENCODING 10241 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2 +ENCODING 10242 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12 +ENCODING 10243 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3 +ENCODING 10244 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13 +ENCODING 10245 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23 +ENCODING 10246 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123 +ENCODING 10247 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-4 +ENCODING 10248 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-14 +ENCODING 10249 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-24 +ENCODING 10250 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-124 +ENCODING 10251 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-34 +ENCODING 10252 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-134 +ENCODING 10253 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-234 +ENCODING 10254 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1234 +ENCODING 10255 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-5 +ENCODING 10256 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-15 +ENCODING 10257 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-25 +ENCODING 10258 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-125 +ENCODING 10259 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-35 +ENCODING 10260 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-135 +ENCODING 10261 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-235 +ENCODING 10262 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1235 +ENCODING 10263 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-45 +ENCODING 10264 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-145 +ENCODING 10265 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-245 +ENCODING 10266 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1245 +ENCODING 10267 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-345 +ENCODING 10268 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1345 +ENCODING 10269 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2345 +ENCODING 10270 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12345 +ENCODING 10271 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-6 +ENCODING 10272 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-16 +ENCODING 10273 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-26 +ENCODING 10274 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-126 +ENCODING 10275 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-36 +ENCODING 10276 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-136 +ENCODING 10277 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-236 +ENCODING 10278 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1236 +ENCODING 10279 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-46 +ENCODING 10280 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-146 +ENCODING 10281 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-246 +ENCODING 10282 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1246 +ENCODING 10283 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-346 +ENCODING 10284 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1346 +ENCODING 10285 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2346 +ENCODING 10286 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12346 +ENCODING 10287 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-56 +ENCODING 10288 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-156 +ENCODING 10289 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-256 +ENCODING 10290 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1256 +ENCODING 10291 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-356 +ENCODING 10292 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1356 +ENCODING 10293 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2356 +ENCODING 10294 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12356 +ENCODING 10295 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-456 +ENCODING 10296 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1456 +ENCODING 10297 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2456 +ENCODING 10298 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12456 +ENCODING 10299 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3456 +ENCODING 10300 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13456 +ENCODING 10301 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23456 +ENCODING 10302 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123456 +ENCODING 10303 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-7 +ENCODING 10304 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-17 +ENCODING 10305 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-27 +ENCODING 10306 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-127 +ENCODING 10307 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-37 +ENCODING 10308 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-137 +ENCODING 10309 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-237 +ENCODING 10310 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1237 +ENCODING 10311 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-47 +ENCODING 10312 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-147 +ENCODING 10313 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-247 +ENCODING 10314 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1247 +ENCODING 10315 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-347 +ENCODING 10316 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1347 +ENCODING 10317 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2347 +ENCODING 10318 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12347 +ENCODING 10319 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-57 +ENCODING 10320 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-157 +ENCODING 10321 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-257 +ENCODING 10322 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1257 +ENCODING 10323 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-357 +ENCODING 10324 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1357 +ENCODING 10325 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2357 +ENCODING 10326 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12357 +ENCODING 10327 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-457 +ENCODING 10328 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1457 +ENCODING 10329 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2457 +ENCODING 10330 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12457 +ENCODING 10331 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3457 +ENCODING 10332 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13457 +ENCODING 10333 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23457 +ENCODING 10334 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123457 +ENCODING 10335 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-67 +ENCODING 10336 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-167 +ENCODING 10337 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-267 +ENCODING 10338 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1267 +ENCODING 10339 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-367 +ENCODING 10340 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1367 +ENCODING 10341 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2367 +ENCODING 10342 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12367 +ENCODING 10343 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-467 +ENCODING 10344 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1467 +ENCODING 10345 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2467 +ENCODING 10346 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12467 +ENCODING 10347 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3467 +ENCODING 10348 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13467 +ENCODING 10349 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23467 +ENCODING 10350 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123467 +ENCODING 10351 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-567 +ENCODING 10352 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1567 +ENCODING 10353 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2567 +ENCODING 10354 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12567 +ENCODING 10355 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3567 +ENCODING 10356 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13567 +ENCODING 10357 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23567 +ENCODING 10358 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123567 +ENCODING 10359 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-4567 +ENCODING 10360 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-14567 +ENCODING 10361 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-24567 +ENCODING 10362 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-124567 +ENCODING 10363 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-34567 +ENCODING 10364 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-134567 +ENCODING 10365 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-234567 +ENCODING 10366 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1234567 +ENCODING 10367 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-8 +ENCODING 10368 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-18 +ENCODING 10369 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-28 +ENCODING 10370 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-128 +ENCODING 10371 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-38 +ENCODING 10372 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-138 +ENCODING 10373 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-238 +ENCODING 10374 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1238 +ENCODING 10375 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-48 +ENCODING 10376 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-148 +ENCODING 10377 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-248 +ENCODING 10378 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1248 +ENCODING 10379 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-348 +ENCODING 10380 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1348 +ENCODING 10381 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2348 +ENCODING 10382 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12348 +ENCODING 10383 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-58 +ENCODING 10384 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-158 +ENCODING 10385 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-258 +ENCODING 10386 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1258 +ENCODING 10387 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-358 +ENCODING 10388 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1358 +ENCODING 10389 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2358 +ENCODING 10390 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12358 +ENCODING 10391 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-458 +ENCODING 10392 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1458 +ENCODING 10393 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2458 +ENCODING 10394 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12458 +ENCODING 10395 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3458 +ENCODING 10396 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13458 +ENCODING 10397 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23458 +ENCODING 10398 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123458 +ENCODING 10399 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-68 +ENCODING 10400 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-168 +ENCODING 10401 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-268 +ENCODING 10402 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1268 +ENCODING 10403 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-368 +ENCODING 10404 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1368 +ENCODING 10405 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2368 +ENCODING 10406 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12368 +ENCODING 10407 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-468 +ENCODING 10408 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1468 +ENCODING 10409 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2468 +ENCODING 10410 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12468 +ENCODING 10411 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3468 +ENCODING 10412 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13468 +ENCODING 10413 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23468 +ENCODING 10414 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123468 +ENCODING 10415 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-568 +ENCODING 10416 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1568 +ENCODING 10417 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2568 +ENCODING 10418 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12568 +ENCODING 10419 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3568 +ENCODING 10420 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13568 +ENCODING 10421 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23568 +ENCODING 10422 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123568 +ENCODING 10423 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-4568 +ENCODING 10424 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-14568 +ENCODING 10425 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-24568 +ENCODING 10426 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-124568 +ENCODING 10427 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-34568 +ENCODING 10428 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-134568 +ENCODING 10429 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-234568 +ENCODING 10430 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1234568 +ENCODING 10431 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-78 +ENCODING 10432 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-178 +ENCODING 10433 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-278 +ENCODING 10434 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1278 +ENCODING 10435 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-378 +ENCODING 10436 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1378 +ENCODING 10437 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2378 +ENCODING 10438 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12378 +ENCODING 10439 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-478 +ENCODING 10440 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1478 +ENCODING 10441 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2478 +ENCODING 10442 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12478 +ENCODING 10443 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3478 +ENCODING 10444 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13478 +ENCODING 10445 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23478 +ENCODING 10446 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123478 +ENCODING 10447 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-578 +ENCODING 10448 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1578 +ENCODING 10449 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2578 +ENCODING 10450 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12578 +ENCODING 10451 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3578 +ENCODING 10452 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13578 +ENCODING 10453 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23578 +ENCODING 10454 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123578 +ENCODING 10455 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-4578 +ENCODING 10456 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-14578 +ENCODING 10457 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-24578 +ENCODING 10458 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-124578 +ENCODING 10459 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-34578 +ENCODING 10460 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-134578 +ENCODING 10461 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-234578 +ENCODING 10462 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1234578 +ENCODING 10463 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-678 +ENCODING 10464 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1678 +ENCODING 10465 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2678 +ENCODING 10466 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12678 +ENCODING 10467 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-3678 +ENCODING 10468 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-13678 +ENCODING 10469 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-23678 +ENCODING 10470 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-123678 +ENCODING 10471 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-4678 +ENCODING 10472 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-14678 +ENCODING 10473 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-24678 +ENCODING 10474 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-124678 +ENCODING 10475 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-34678 +ENCODING 10476 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-134678 +ENCODING 10477 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-234678 +ENCODING 10478 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1234678 +ENCODING 10479 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-5678 +ENCODING 10480 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-15678 +ENCODING 10481 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-25678 +ENCODING 10482 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-125678 +ENCODING 10483 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-35678 +ENCODING 10484 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-135678 +ENCODING 10485 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-235678 +ENCODING 10486 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1235678 +ENCODING 10487 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +60 +60 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-45678 +ENCODING 10488 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-145678 +ENCODING 10489 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-245678 +ENCODING 10490 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1245678 +ENCODING 10491 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-345678 +ENCODING 10492 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-1345678 +ENCODING 10493 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-2345678 +ENCODING 10494 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +06 +06 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR BRAILLE PATTERN DOTS-12345678 +ENCODING 10495 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +00 +66 +66 +00 +ENDCHAR +STARTCHAR UPWARDS BLACK ARROW +ENCODING 11014 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +3C +7E +FF +3C +3C +3C +3C +3C +3C +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOWNWARDS BLACK ARROW +ENCODING 11015 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +3C +3C +3C +3C +3C +3C +FF +7E +3C +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFTWARDS TRIANGLE-HEADED ARROW +ENCODING 11104 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +20 +60 +FE +60 +20 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR UPWARDS TRIANGLE-HEADED ARROW +ENCODING 11105 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +3C +7E +18 +18 +18 +18 +18 +18 +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR RIGHTWARDS TRIANGLE-HEADED ARROW +ENCODING 11106 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +08 +0C +FE +0C +08 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR DOWNWARDS TRIANGLE-HEADED ARROW +ENCODING 11107 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +18 +18 +18 +18 +18 +18 +7E +3C +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR LEFT RIGHT TRIANGLE-HEADED ARROW +ENCODING 11108 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +00 +00 +00 +28 +6C +FE +6C +28 +00 +00 +00 +00 +00 +00 +ENDCHAR +STARTCHAR UP DOWN TRIANGLE-HEADED ARROW +ENCODING 11109 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +3C +7E +18 +18 +18 +18 +7E +3C +18 +00 +00 +00 +00 +ENDCHAR +STARTCHAR char57504 +ENCODING 57504 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +C0 +CC +DE +CC +CC +CC +98 +30 +60 +C0 +C0 +C0 +00 +00 +ENDCHAR +STARTCHAR char57505 +ENCODING 57505 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +40 +40 +40 +40 +40 +38 +00 +12 +1A +1A +16 +16 +12 +00 +00 +ENDCHAR +STARTCHAR char57506 +ENCODING 57506 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +00 +00 +18 +24 +24 +24 +7E +7E +66 +66 +7E +7E +00 +00 +00 +00 +ENDCHAR +STARTCHAR char57520 +ENCODING 57520 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +80 +C0 +E0 +F0 +F8 +FC +FE +FF +FF +FE +FC +F8 +F0 +E0 +C0 +80 +ENDCHAR +STARTCHAR char57521 +ENCODING 57521 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +80 +C0 +60 +30 +18 +0C +06 +03 +03 +06 +0C +18 +30 +60 +C0 +80 +ENDCHAR +STARTCHAR char57522 +ENCODING 57522 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +01 +03 +07 +0F +1F +3F +7F +FF +FF +7F +3F +1F +0F +07 +03 +01 +ENDCHAR +STARTCHAR char57523 +ENCODING 57523 +SWIDTH 500 0 +DWIDTH 8 0 +BBX 8 16 0 -4 +BITMAP +01 +03 +06 +0C +18 +30 +60 +C0 +C0 +60 +30 +18 +0C +06 +03 +01 +ENDCHAR +ENDFONT diff --git a/src/font/res/spleen-8x16.otb b/src/font/res/spleen-8x16.otb new file mode 100644 index 0000000000000000000000000000000000000000..229b7ab02a40482f9f9a1ec332a38ada6806a267 GIT binary patch literal 25912 zcmZQzWME+6WoTevW(aU~atY~6%>2o~z;K6w!GD^olaI5DsG>OoLqG`w14D>^u)b0M z&8Fa)MBFfh$Ypf0D}-P znUR{9qR!#M!@vOMn`LCACUP+`F)=WNSTHa!sAS}pRIq?mhQu&1u+PZJPfi4L8KS>1 zFtAU{O{^$jJ`Hklat{LogF;?nZmP7S`2z-qkdEx*a1*?f>nXEqpSP@QjDw; zq#Y~>ieXsLfdub?v?Hqo@iClK2aa?J36yw;3w}Uy=dokQ&ipt7G6YEwjV)fjt2=GJC zAQGY-#HwMdJ9g|CvfmFJH~)K z_A1CugzX@^j~%OFWBUVjCj;Deu*aY(B_tRuz;P#oq+LRS0W1b03Xrs)x$)t{jWfvF z!4`uEWbF(L3~#PoyLJsFDKaoHK>UuZoq+*d3ZWKOaFw7$3bP&JcTjk;v9X~Dg2N8U z@gS844uCz5h&Xs4$)TzQB`1tP1_`33PX-2tW5+;V%0tthV*yH`Xo4X9pfVI5@(c_N z|3C#>0#f?q0|#yah(uIpARY|E9S>0hQ4bdcS;=5j=L4qU85YWeQqU-d@}Lw{5D~>7 zai|~z14rEn5U2yS6`+C^ISB~~KN1p1~1V5n)DwP?|zSxum<2~}yqW8-7P02TnL zhN)y=U@%TQlV%JOgQ^5G|AA5rn1!U0fx!kWgb2Etrd_Kxty+a_5!?!hQiyhooE%Uy z4I+fl4)P+Xb%B&OEqr(^5)fuUw9BaRu&IG_3)lpRAP>0t0Tuue5J3@c6h91rmk+@TPaV4Y{ujML75 zSr9>o0}f7ZZzK!~u1t|*VSzdcCYW=niAO*VDwr@qL1S~qi%KvP z>?8)Jnq%kAUAy+|*|C}iSfVwMfHxWtewQ%7B509=MKA}OAXGc3(SY1yVSv}gptd{2 zNemTd&YZb%=Gd`ocR&t>2qu8S30xZ^oWuaiuo(5b04SW$Rf5WB43!L^a6(sUkz>Jt zsgi+#Ax})K?!pDMst1xp5$TfwoVhDnqgdqrO+ki?82?=nC1ql$4 znHYkg&K8CsLs}YU>*7Yi4IWTNM5**ap@h*q0Qm?*CCGLRL6DCy1VLtE2!gzcDF`k+ z>p;a5BHkGoz)AEPa{2@ZGAMDQv>8B7!Vm;G2}2NMJBA>Vc5JOR+of!85a)?g_B zLG3Dxv;<0C$BrFCD*!>Y8D^>j1rb=`F&+-&<`z^XTo76Sg33y?+yPCW@bCncU?61& zP@1oxP(oKJ0V!!gijm9&MGm^k918)65Q>?grW-^Up*_b!0Ghmz+TJ-9V%VD5SjtZZ z1~z$cc14Nz8Z6~JIONguQH}*Rm7tJER|)kyYApjvo#vfyNc!=@aTBG?n1kL^Bgo?Sd2@ zLn_ul6)<{V23q)o6DY{bU?*ARAbL1(K?a6n|Dj19x!-Z%1GFQC5H#|^R+)mz5R782 z4x38Qh!MKVG#hLx)6#4(OGUUs3FO#B^v>Y12?|e)mH^1VSj_wmY6XDs8;}BUC^3M_ z~qRha+Kn8A6j)ef&K$L8rr8;z#7C9CWA(RRl)S`w6Bg{-YgUw7( z`b1X=8uNjO9YZdYLB$$a7$FF)kB*@ZV1nWuebfl560I`=Q3)~>(XN9w^-xtx7(fS- zP=-1|NfCW~NkRgfN>H_nt}@3$03w89CMbbHgptOq7~~8L3LxbwB8nOKq@_XQ+Y%BI zH*P?yi5v?7P`Q9wL4!*$Xaf?i9hAJGl?)<|z$(!zQ>dAsP(n&eAa{bSLM^NWK&n6( z%}kIGN(!lg_y3W*{|q%4eJ+qY4;(lEYMr2&iQYH?mEN$Sd}MFJt4O#YZ15A5Wh4ZU z3u>s35Q0brHA0Yq0VZ675CjDXG(h3CHnapob37>7pgSI<5;`D)6d)j#=$%jr1Ev~` z{yV5&4C>dTbehhjfs1~$#Bd|W45Nnv>OW)lP%N-?md~Vt3d#d$ZCa3-(AFGLPHKIia}&-K1*h)o^nINYlXBlV-1`V@L2d*ag9nMiv_nTtp+eAsR;(7Gj(S5?GBAMVcEM4EJnaaY zYXD^uGLt8qXbD1G<9!-K9zY4HYkcP6XYXkNsAB!H%>6; ziy`GXDA6NXbPW4^F_wNYBx```BP8vRU_&z#6l|d20}Y-+BM#zsP#7U~?w}1waJh

3W5+hkp(VVBIj=C2XQV{s&LABRighVBrZe{~u`T9mGHuJOJ$~!$S$u1%?K`1lV>M2iz$}@+Qcv{~!W{!74!v z1_p-zFj24|TsxEj7L-8)U_q#MaM}d1Q3PS45J7OtKoAf?85tP{1_S{S1R2D@unI!K zy$R;7f{8*@f*Q(57$OKU5@akGLj=LP8RVfJf`k%8j6ohIilP$cTNFWgs3@}F4{T=s zfQcfj`~%f3gCYnM1q(vWl7~7C67MiUm?&5!)GU}7L?uiRCJI&w)&2vUnLl8nV3knq z;I=17HHsii6f6ie6Bcri@PrA%M8PVdX2Qf!RYD~p0RrW~!WdN$rXOr3+#;wJuppEz z57Q48gsOza28!*daRk-=2dV=_B`k_ioP=EvrXSh%KQN_WL6}7-f}qd_WkVF(p@Kia zqo5KJ5GO$e<*^C=$0`W55aM{K_WxLIM+rKZ%KuRPV8=t<2P-WgsSYNHl9phC*peb@ zX#+JM)l8TmN~(hiqL>L2gy~0dC)5>?po4P$!%_o^N^A)XH9TP!L5)E*6DkP_WGDv~ za;SnRw!>6nwH+mnptk-;i6fYHlz4}!gjo;vCQK!YnJ_`@WghW3M;6}|0pV9w!+Lt*8U%>?Jyl+mH(jtYBt30Fb;(N{~xRp z!hw(=O=yB3AvBc`ArwKF5RxE52rh^$1QkRTf(rga17IipK?7jHA7}t9_yG-o1s|XR zupqcgibQ|~8_)n)kRJ_z1qEGDAy_bU6)FS^-YrLkU_p4z3<-Ql95JE+uu7-`2AIcC z1Ys@%3&K>ubf5^r0vkmTMLSFd%owmrm>`Nu7zf27m>`OFm>|p;u$drjH4p$|Li`S9 zftJw!`v)$T;VKyfEG%kj&;|eftEsU-*o%)?+NSP*152sa#f@Zrawf2f&@k(r%e z-o73p`2XLZA0HkZ0Ih`uX@FW(Z!gc!&J5a}h!6y^>ag{8)36EFF)%>HkvpMj*aRVF zqHUUk=tUERn29C`F%w0wu5oE&OlJq6Hte^)9S$EIeBM56EiT2KrVq}h+qPElOviSh>IkFP>CT3QVGIP34}@vL6`uB zN{|2u!{eO|Jn#ro55^EPA)``Y0T2NZ1gV5-MF@gaVhDm%f|NidAlgAHF$6&>L8?F) z?j(>(kN^n71wmRtszEed5TpV_5TpWR1c*jai6ID50n!dK21O-?AV?)hJBCUOL6BAu z2I+vC2~ve22oeBEV5r0p1gQjJ6x%_nFa$vYAPEeW7=ln+89+MV?u2SX6$GgSNnohN z5Co|NVUT4QW}*m!RDrak2!aGa7)20jE2@*ADo_PMDnZ&oG~9NON(@1$3ec)YkV8<@ zCrAfeJ4hu+JBlEPgCPhq2&5W>;o3o}Kms5P7X+yU8G#}QQi&l5QVCK8l7MRmsl*Tj zsRXG4VHA}h0T6}@g0#XgTo9xJLl7hYlR#04AqY|l!XOD0l^B9B0SuKO0T7032l)tw z6A~a59|Oqm7=j=Hm;{PS3_*}e5C%!0sKgLN5&&5WNg+^eAOR4D2!ebBQVqgzGod~K ziJ__l837VSQHdc4GZQ3%q7teV(@YGNPyvKWkX9sfA&v*B!VmqWK>{EN43!vy zAX`Bgqyz3wkTwiKkN`*mLnVeFNF@kEEknc+h9F1)q#Z*gh9JmRkZKSOcPB_2h9Fc0 zxKKq=2{IEZ32_pL1JVvM21O-?AV?)hJBCUOL6BCEY7mB-2~q_T0AaWwNF~TT6hV+m z3_*}ekSdS_Tsuf5h9F2KNEHa9s00asFkBF%6^7x0AQc#bAOV;Jib@PYkV+5+Nua32 z5QGU}s00asFkCyxM=%WE91Ze2h9F1)CV`?7LlC4Agh3K0Dlr6M0vIYm0w4_64)PI5 zH3-86K|TQqfG}JTq!OeRMG&MCLlC4AqzWVf*A7yNAqY|lQU$^&DnSAu3>O4x1*wM8 zkcqWK>{EN43!vyAX`Bgqyz3wkTwiKkN`*mLnVeFNF@k^EWNBmlxFf*@N#szEf|Oppo;L68cNb`XuC5CT3vK^!fgyB8{*$xr_VYnbjD-wp7 z2~`2I8H6E%AXP{b5J8Xt2!kXs%!EoHRDx8Y2*LzV1VJ1ahMNiU5e%oLVQGKD1wpDn zUIo!`L686lqX>dHAPkZ~QHdc46Tnaj5&&Vic94%iszDeo2=Wa`0EFR!AeA7kD1soB z7=j>`AXOjDnZ&YRALB%w1O~H2gFQ}Dhxr807wEuC59kKB?zO~4pN072oeBE zV5r0p1lbD0P#q9=g0x`>f&@Sk7%DLYK`KEQWEnh=L8>qWK>{EN43!vyAX`DIK{VV< zkTwiKkP47?5RIY|LlC3_q#a}oib@PYkV=qt43!vyAgv(PAPhGXqzWVe!f-*5N|1Rd zf*_R`f*_SpRj4T$LlC4Aq#cS8u?ZDGXa{M9VYrh(sxSmW0x$^_l^B8`l^_g~Kv9Vy zh$Mh;5>y*V0E8iq2M@m5fC0#JP(d)?=6@af8Z(82e+lU8)*uVRQHFUL7#KKs{=p8d zgPIAQ%)&5JBj;ZZ`nol+nW!oui|b|>B!CxnLCkaj0Sg8pDJd~AA^6C%go=tvU0qsQ z9b$|cHi8curw40qH~>D!jF}mHyw3s1TqJB*|37r_9;}iPssyS5G@{Q03N6q%ehi>h zbD$M^AW;UMKOA5|kSsErf#LsO0Rcuv1_tJnpwMAp0xJU@6(|a}f`NyDft7`UaRLJ? z0|Vns#+eWjLNh#pSO+o{fmD7c(zoUdgA|Spr$YS>jkGDu^jaD#$7*DX1w}DcC5~ zD=b%7qc~M@g_4w#oRXH3hf<DfL&+_ci{xZ6-tsya!Q(D7p6d6 zsG!OKci}13^Qt#gAE_CrS;1YHs+Og}pv|V8@L<7%4Imf(fB664|MmY%|7ZM9`=9bZ z@qhIH(EtAbz5gx#x9DHhzskSw|GxhF@bCSSl+Daw#gGMMvoP>5 zEP=9F8N?ViK-nA&91O>xY%T^fhI0(g4EYQN43!K;44Dk+3>ge13FN!V3rYs34;!U0)rbv5ko3N3PUPbPcnl7gEK=SLoP!SLn%WsLoh=DLk>eK zLn=caLl8qMLpnn#Lk>eCLlKIM0z(9Y9)mwa5koqI0z(o*CPN8BE<++i0fPcVF+(Op z6+M#SFC#2 zKzs%Uh8jjjh7>Ud1_2PCk%0ju|3I9PK>@^PU|?(i;=+r%Gcp%V0iL|iJ<|? z|D(acAn=C?>;OKPeX7i0w=gg;h%rFj#>2qCAPjP(JOcxR8i--cz`$S$VmLA|Ft~vj zJ`4;Dejr8+0|P@cNGHfuc_79D1_p*o5W|CkfuRk?m<&=pn}LC0E=VMRfq`K)NMsKK z1H(}e;~E14!wrxW$hVI`B5xQN7)2Qw7#Nip7#OWU40{F!#ww5+E(Qk1Mi3*Jfq}7y z;s5{t{}>n;r-DR4+8G!ZxH^bGY37~K3r{ak_~8FDj=lNo}29Q{HVf*68bL&7~>LOdXv{ql=) z6LS~>JR@9v;)6Y-Tp0rVJ^ex;%plibf1l70Pk+Do2nHlhBtvk3qqC=St09B0r(ZnCAXf%w z|A5FKkdGLg^9w4AGSf3k6f}}G6^slFEp&|xj7)SC+=^0DQj0Q^6`T`ulS+#j;@zEG z++1DaJ^kGL8C+8`OHxx5$}>wc6w;GY(o$0t4E4-GepFDfPS<8=V*~?Gq%nY! z94Og?I2;Td3_RdW0807@HUk64|9}6#F)%Q&Ga#!0>A{LYb}*l8=;-Ka86`%;U^E?! zW`oh9V6-G0EeS_U!qJj&v?LrY2}eu9(UNeqBpfXXM@z!dl5n&n91bNRbS#(oBy>Ow zG!zUP{yhL5^kralU|?WuU|?W802y^=a$sO!YG7bsI>5ld%)r3F?7+al+`z!Vd;l^E z&Emkoz|z3Lz;b|rft7)Qfz^S5fwh5wf%O0b0~-Sa1DgW_16uyUco`TNcpVrRcpDfPcn?5E(D@t~ z82B0(82An_Fz_=lFz`DtFz`1pFz_FMjFJmDFfa%-Ffa%lU|fDpw7U+pzgrHpx(g1pniaXL4$#TLBoN8L8F0zLE``e zgC+w5gQf!mgJuH*gXRGS1}z2#1}z5$2CW7L2CV}O4B89~4B8G14B8D04B7`67<3pI z7<3#M7<3vK7<3LWFz7NcFz7lkFz7ZgFz6m&V9;Y=V9;}5V9;w|V9-0jz@X2-z@YEI zz@Xp2z@UGCfx&=*fx*Cmfx)1Gfx+Ma1A`$01B0Oh1A}1$1B2lK1_mPr1_mPs1_q-B z1_q-83=E(-4r2!f2IB?>2IB(^3?>W=3?>c?3?>Z>3?>H{7)%)$7)%`)7)%=&7)%c^ zFqkngFqkqfPuk9fq@~=fq@~gfq^0L00To10|P^l0|P@)0|P_Q0S1O(1_p*;2L^`V z1_p-U0}Koy3=9k*4h#$-4Gat+2N)Pa85kHs9T*rw8yFZu4=^xTLBFWG^CA-REpA^8Ag zHZsM5fgz=Vfg$Ap14AkU14F6<14C*914HTo28J{S28J{T28Of-28Of)3=HWE3=HWG z3=HWF3=HW97#K1b7#K1f7#K1d7#K1RFfe2?Ffe2~Ffe2`Ffe2uU|`5%U|`5{U|`5< zU|`5Pz`&5rz`&60z`&5*z`&4wfPo>0fq@~%fq@~Xfq^0C00ToV0|P^@0|P^D0|P_u z0R{%pTw|UC14CW|14G^c28MhF28MhG28R3w28R3t3=9Pf3=9Ph3=9Pg3=9Pa7#Io} z7#Ip27#Ip07#IoE1q28J08 z3=A_4FfhzyU|^W(z`!uGfq`M>0S1Oy3=9mj92gj8H83#DI>5jHnj4?(z`!uOfq`N6 z0S1OS3=9l&92gkpG%zsCIRKeApXq$VeSD2hItGO4D%cq80IxFFw8r^z%ZYI zfnmM_1H=3V28Q_u7#Kj)@CzIm7#1`zFf2I0z_5^kfnlKo1H-}w28M+P7#J2YFfc4~ zU|?9(z`(HR00YBf1_p-34h#&78yFZCA7Efu!oa|=#DRffNdp7Jk^>A3OBom#mO3yn zENx(5SbBhgVHpDh!!id3hGh*549gBMFf3Hq`7YS5AZ2L^`K4Gavc z4=^ySVPIfbf36wG0dlYaJLE);2IOtUbWMu#SO&VVwg5!@33rhII!R z7}hf|FsyfAU|8S4z_9)R1H%Re28Im|3=A6@7#KDjU|`tDz`(H4fq`LT0|Udx0}KqC z7#J8fIWRD6YG7d4bbx_jGXn#|W(Nj_%?%6;n-4HBY++zv*y6yzu%&^4VaovqhOG}p_O*mZz`VK)N~UaV*wetku;%~+!(IjkhP@6940{_G81^1uVA#jNz_8DOfni?* z1H--p3=I1j7#Q|DFfi+P7FdT7UU^vpiz;NUM z1H)0!N(~1FhNBG(3`Y+zFdSoGU^wQ$z;LXAf#KKz28QDd3=GE|7#NN>FfbfHz`$^V zfq~(K0|Uc}1_p)`2N)PYU6PXy3=Ah57#L0-U|=}Kz`$_Ifq~&v0|UdU0}KqO85kH& zJ1{VuZeU{f#JLZ1H<_S28Qzo7#J=vFfd$jU|_h=z`$_f00YBC1_p+U4h#$z z8yFZa9$;X&#K6FC$$^34QUe3Sr2`BMml+rsE;}$VTy9`sxO{+t;R*u-!xaYxhARyW z3|9^?FkEF|V7ThQz;LyJf#K=_1_scAm1_)V7Sh}z;NAxf#G@s1H<(L zki{%F92gjGG%zsSIKaSglYxQZrUL`R%?1XBn+F&eZZR-0+;U)GxYfYGaO(gA!)*oz zhT9Gd47VE?7;Yb6V7SA;z;MTbf#FUA1H+vI3=DS}7#Qw4FfiP0U|_g=fPvv20|Uc7 z2L^_F4GawT4lpp>XJBBs@4&!tzkz|_{s9Jt2Mi1h4;&a69yBm8JUGC>@Q{Il;h_Tq z!@~v!hKC0j7#=Y&Fg$W#V0hHP!0_k*1H)qm28PED3=EGO7#JQOU|@K{z`*dtfq~&k z0|Ud80}KpL85kIzIxsLiZD3${dVqo983O~uGY1BSXAKMt&kis!JZE5Fc<#W!@VtS6 z;rRguh8GMB3@;oQ7+y3mFuXXx!0?iRf#IbC1H;P(28Ndh7#LnLFfhDwU|@LFz`*e8 z00YBo1_p-L4h#&h8yFZ~A7Eg3!@$7s#({z1O#=hNn*$6CZy6XE-a0Taylr4$czb|> z;T;15!#f8ChIb7N4DSvwFuZ4AV0iDq!0^6-f#LlD28Is|3=AI}7#Kb@Ffe>Lz`*d4 zfq~(p0|Udy1_p+Y2N)PWf!fy-7#J!Q7#Kb|FffAr3GFpgNP&BPpxz%A3^E!f4`QQZ zaH|T`vVvlCd1CnBmJ4HFO>BnHADH6XR< z`jPpdaI%4>q5n`C#0QBZtHZ|z*#p9$I0exlj88o=av(E67!(e~s0XP5*$<+rfkAeI zFgCM5VGP2cFa}|eIuHiA6@+1N39cQn830Z1;MxsT!+=Uz1_lPuvNKSL2NSQsp%)|! zvJaGIK|KIaFA&5>$N0hnWH!hikUu~e<`0;$<5hCynvVURkI9WZl1Wd+P`5Dk*UXFo^|qy||(h>wgxYOvW25`)N55mY|xY!_dAdD;z;)5_KERe-vY>*p~!wi;ZKd!pzsIL$l@S2F8!deC4}+0A7nPDbq)$QP%jC@$A)p)4^s=OZ$TJj1_*qw9n5VeZ36gWL#Hk51#$2kH}m+E$=` z3c5ZRAD4bmxP#IpC~bn&z|^DDAUPO@*$1LwVlWzAAB+!^htVLvB8NSQjW7H`ZUm_T zVNm)2#Q~_k0@Zo2cm=6}*@sMnW`#g%iNPSX$a+C+nE4>PKp18Y$PFM2at{cD+y=rR zJ;)f8)A3PTthnZ~6bSsY{!$Zm8DG85)U^st1v5k!OJ(DlOj$o3$!LFosC zL1Lh^3}T~W(Cj`)9vgsBZo0A zHa_ur9dtmxNeg@eE!XWn}t3zfZ$2mw3G9ShU&Ime$S!nqL2A+UW8;HX>VU!vv?2tX{9qG<`4i+W7#kZ6lE;TZTbV)r2G68{X48-` zNE{@OO+PH1g3<{LgZh9lHmE$nm44981C2F<#t%U>48!>7YGHivOd@m+0HhY=cXS#g z22+ntgXGXLObxm?h!2wk(I9(3?gaT2lviMUkQfYu#6bB8l&?TyAU;Sh41@AG2*dc~ z(4cY?w88>3%MYGu2hFyFX8b{`E}&z~*vtjR9ms7UH-XYShz6+xnFpfD!Jw6GAb*1D zeGm=elVT1it$@M`hDp(jtQO`MWHDT9kbgnp2bv`ZtrP&w)`Mse28n~@vFXJnj!i8# zF;Mt};vR%SY;0;s5d)=95C-K}5Dmg0{UAPwM#msKLGcbs10Xgi9h2fGP`eDYG83d9 z6ki}VNDSQ`Tzrr|kQ+gH2bUUB#6f8e8wSm%fXX2lMm7@^HlR8hhCwT>VDpa1G_oE- zY>=Non2G0M2l)%M@&c5H zL1_)tjsuwk(+^7Tpp{#oGfhA%v_Y$^KzfL^A7lZR#)i?LF@2C)Y#5{;6wmlDNDU~yKo}NhAR5F6nF*3d)(>hwA@f1< zAa%$Xq!(lcNDL&8tezPCpm2kQKRNnAdO?_6`$298`2nUM#Kwg|?gObq#-RKRQV$XX z$%ELqFi0Mx4jH5SAD3Q`I5z#DvJHemW`g8F>TqF@JV+fj3`)Bob6^-$SHajY8l(nY zFLM3^l@Xx13lN6YV}#}}ki#EjKFADQ7$lEwKgh2jGeF`Xd0cuy;vjjDI%JF-{vdgf zI$RhekE|EOM)p5Q9+zH_I7l9(2N{FXE(n9fK=L4UxG+c_q>c~<=?9q!@*4<)*dQ@n z`aoxKfy@S}fyEar{y=dF!XP=28qmfnWOG1a4~hej7)T91j4l0v^n%PFS3k^uqKTJP}2Ju1i=onT8g3JKPk*Xim9zu>sQuTxKAjoaV z?Eq5T4{|do4S+C+O{)Dcw}WVM^n>&uW6(Gqtj!Pd7i?W3NE}%mE;dLHES(_JAaPI} zgD@+gdSQHYy&yh%`UBYmQ;$r8#6jxOG04rJFocCaNFB01 z7#k#xt{22dPk$hFFg?gLNF1ad9i#gnqz+jhj13Y;*9+r=(k=*t_#kyKJ;*dj93+nq zgTeuXL25wx07QfMAPiy?!su-QP`!b#{s)B8ffVnT<<7Deec=OCSu&BOtqA7*@uDXpkJTy)ZUN93&6JFufoe z`bX^{Itc7rg;ERb3dhNUYIjjRV$b|dpaY>;|f7^EL02ckiCfb0Y5fng9I zB#(?iZU&WCATf|Wm|74`toFUZ{>3=#*)BdbSegXBT#uwjrHAPkZNsYBNb;)BFN^2iwEcTgCB`~Z>% zsl$aq^2mB&Y-BS)Vjy+c%m9glQhmNt?4^j`pAUz;I!!Wwv zL1ux}fb0NaSe*&d3*v(?ObnzBq#hk(vmX>@ps)j(gRUM_cA$%a)FESJ{V=zKXi)s3 z>jm+V^&+!Da-cQHAR2_R*$om$)`QFj*$G2?7K`%dH`eEuoe0=t!>qpiHG9SjrMuX%*<`csB?8l|| zJ9O;~C>(KN(3~tT{V+Wsc~E~9#z&?>bLyb77ldJH5#&}_{s;LFo4;XVp!@|gA030_ zL2(bV3nT~P6T+bJ7LZyP2IWl{294W->OK$+6NAw(b75s2Og|{!!!Sr6Xew#KM%E892OoyT8Au;G#$_k6 zILLmGeIN|72iXh|pAZJw57G<5AhjU*5(mj6V^Ez1 zDknfEN0A7no^`;g55$>XyhB#(@d-2&2w9A2RG z0?Ml}3~~>$UKkr&_`}RWHUpb}kl#V>2bo2#{h+YJWey?xLH2?$A-h3g0CGPlyg(Qh ze;~c+7}+dz`$2j^7^WZOcaYmben8d-3O{7MF#FN%0`ZZ}0P&H{K{f;Aevmvi4C?!W z+(ih3;u?fO>X6L=`3HtUcEZ@mG-%BwNG}M(^dq~S7&jo>31TDbhuMYCevlq)7@OUo za0Iy%8DrB65`)_avK#D}Rzr?KfruK&^1qw}%p2ib>S|D)@J@p0(~nE_LSj|S-n-mm7Unk0qI4?pmqt!9%PJ7AI$wA_kzk@7{*n8;<6uR4va=N4`vo9 z?SU}JO)zyZ8d*Ka4KO_*wJ;1Sn?Pj?hz|-UkUCHt!Ng(x0#JPdk_V|n$H?kIav*is z%mImmadvu5(mj6V^G+E;t&)EAbD*1 zKz4&LNFG@)jE!stNDQP7n>iqHkUTaFD%(Nv0K&+9W)L44W77)~1MO7=pT-V7a}5+0 zAPh!f#MUJer)3p$a+EUMs_bc8zc`h9~~o`0g?l$L)Q!9gTz7d z$QW5KNDV$rtp9PDflEKkT_AZ_`T)_O_y^H24C;#^yB(bkGY3>og66Y97-S~C{u{FY zL25zy42D7WA^Qa+k8UT3k8B2r4^oG$55@+$50oxJ7*-Y{>xHQU(V#vxwlQ3g`N$Yq zKg``Ay`V4yjlF^}hz$}0nTxC!nGJFqNFE;ssR5;HP+0{kbCLBUvq9>xnFA8XrXM5@ z(hs7s>4S-Z=hsJdB3vL8d|S*z_ZZAxJ++4IvCGzd-6hFgD2jptJ+Ru=Inj4;0rhOlF(|x1X#ms?1<8TT z0fjLrK4JPmG>8w9N5-JC9;6o}2GR#o2gAhL4>JSV9AfQ9_CLsdpmYbrpz;I82GJmS zkUP;ay8l7yko^GC3&J3AbiFXQgTz7dAayWIto<-EVEzZ0L#+Mi{s*N;To`07$erjI z-Txr>U^4?-{SBIHfTcf>`PlTq#6a@sc7ymZGhpVBVn4F~(bFF+3}G}#9%LUnM)yBR z9ZU~04H8G!3vw$g-azum>e1OCd360CKFkb4=3vv0?0-<1h-@c1A0!8|4;`cX9~4g@ zwXk%IOrz_Cxg8{qZXY@yWF|-*I)<46^FJx}Bl{m@K0XXG7u{|UAKm}B%)q7}=5~-d zxb(rqvFQitgP8&IKTJJ3jZHsD4@f_#O@gkTnDQ4{KXN={i#w3{Aon6;Sl$MSfzl^P z4Ga@wKS&=4qnkr)`47?y!l1B0c0YRA3z7q=f!PCVvw-xXV~{#zKY;XtFi0F-Ka3AD z1Ks~HGhj4G9;6N%CdM3W`eE^h%}ip%knJT#J*8?uZ5`zCC(zg@Y}}I^vq5zRs0<)S zFGvri;Rn(W!XUFK!LYmnN@w^mE;~SF0LUG%v<5Q|#s|rP)Zo$&Qx6(%0_6=@x&X03 zVjy!sYH;ZXl|P{L32Lum!yq}38eIB8ZUeayWDhz9iGwg`3=x-pWO2|sCD2|?P`M7n zAU;SOSsgMPHvR+hHzFa^dO6a!WraW zY#3P`E;gu~g83Vn2Dt^79%ONleo#1|V^FvttHZ?xLm& z2g!rfVZ$IfboZk3L2A(Tg5ni~LE<2JbUny?kUU5oG6uOHly*U4pg2axAoZYn42D5< zDo7nR3`!fwdO>LbnGa)Q(+d&4#_ATf|% zLG}{DAhp;qsBZzvU!d^BhGF4>O)p3c-G21619BHg4M;6EjGSIUaRG``5REJjVq?<_ z5(9-7ES^C$$S)uo8H3b-)MCS+@C9K|pC5!lc7ZUc?+?NtIS@wI3koZcTS4L=d5~TZ z2I&Q1kX{f5$$>CP9X1S-gJDp83JO0E4HJXW*z|(LKw%40gO3KO#fCw8K;=6qo37YC-0pi-Gtcwb(Eyu0itnFh~tHy&y4M_Mw{xQi~0P(gw0x5FeCg(J?l? zATdz*qpL;dgVcc3V#C+Yk#9%Z?EjA33$47&1O9bC|485BN*&K9z*!&L)17tnOY;1l; z7XyU>vL0kMx>{^}eC`L0lY=m*y$8ae@(VN`h>SsE$aW&L(ftqF{{|ZWMaCd8WIf1i zke$dFWEZmCAT~%0Sr0NBl(sp^CN z;u|>((D^VqWIedppm+wk8QlyJAD13vagcsc-Us;?W-o{ai6g5+W`o*>$ZbV*y@d25 zn+4Jfaz6-z>;$<5go$-ONFB&NbPN&)nF+!mJCN-}W`oqh^n%J|kli2*5=T~tiw%l% zkh?+kDhPwvATbcer3YCYR`~b=mAT~%0 zghBFzFi0IXjIaJgHWOJdvY$czMplQ54buTt0^X$utAAdK7&0Ey$$ zgDegz=U`zD@&k+w5=T~tiw%l%SQsF)L1MV{fYK-kgXEF*fXXje9tMen_JFGvhD1_Ht$HmUkS?gn9yK9GKpKe1tu`$2I6!su}U5(8mS9*6lC8x1la z9|oBL!nn-ArWfRIQZTw1$YBg(qstTGgY+O{Lh8}w(cO+NPl!*cer)E0`~%9bAPiz- zQ$vav_@-kLG=Rn`LH2^m5fBaHlc1deSszG0h>uS{dKe<}LHb~P5E~tX_WObI4?0Fy zhl>vzs{xfyAT}u9fY_k27evD_C{N-t10)VAA3)_SXuKa}4l)fA2g!rf5yGHzm_cVc zgUT)t4dN5h0}5kMT8TniKM4{?#)QlP$%E#XK<7(?><7^xK1duNMz#Z;4RRC6Z6I-c zX29e$7#J8_pm!;QXb>MJhl>W84YC7d7l@6FL1G{|kQk-AR5F+SA&fYD)T{U93RG}4l zK1?k>8hK6=nT<~mOb*$vxY#hYxM)x~gVGA9%mUFMK1eMn9zZl9jI0l&AH)agAx1x_ zZ4OE|pm+y`84QE+BFGG4F-SeA9D`wE^}*D^^nqxQSuk;2G_LjAApcW}L49Ws292@c zvLBl`XiOHHII&_N{UD4_J*9pFqw7KDgY<(ix%xqUG>{wd zVPyNz*&zKOj88v4w}HYA-Ar^oA^n8Rz~?qXdO>a>q@R!(_}mYQJCGgtFd_Yf%)sY< zP~73O8ze_aKOr;lxgQjE_~Z%6f%JngK6UuqPe?C5b3t+-{rJo$Bu5SXATvO2AqRu> zf-oU>;*tmH1z}wBAaQ)|0;OGi@`U6-`au|!k+hF3j^n%1eaZkt$kUCr#ly)i657G<5 zFt=i(aoG(L2kC`{6D}I04i^ULr!@RQVSvkSkT@>8LE<3$K^UYC7seJQFfou`kU97; zOf5DVq#u+9DA5n{KPCD>ZosD>SGa)03F!ry3sQ#*gY<*qAEXW!#-$e|4$@D^{~&d^ zFi1ZL9)Ekc+9IpHTSYa~sHhO2Z$N{_)uX3MX6`W^S0gVqn=GXvxXY#0{qAR6QrTo|9(pzz0~4abx@xP!u6oxQ1sWeD0DEvV*KE2r71QG*A4#%4E645SyvCY6S{A4KER3-Ti_44Rk5=4NCu zTzWy`pmlJ_YDr~-^n=X7hw+&Saz8%3AUQ?`1_tCceV{xCn(qZ+nAxCm1cX8BIY1aR zCkn!#vq(S~#0FuI90-Hdf-p=!$Se>BnGM1)`#^R#GB7Y4hGN(n7tlO02*dP)*dPp( zhp7kY1^EFNrZoIO_Tw^}7;$34pO85qH{iqg^n?5l!uZtSYFom@@#!Tb2l77%6Hhs$1=I3fEf4S$du@L`Z2@nL-KqeKp7C#hz@^ucIi^~2Po z(}eV+t4HVK(vPkV7av_eE_JxXVft~&(^?$VzNG|%#s?_DpmruD7?i&#!LU0L$fZH` z9wiu5e^P=$@)4;ts0~61CS)I> zv`I)mp}3{gd_wwR;fsqVq@R%cahVGfCuBZM9WI)X{eMMcR z=<@jZAU!h~7#NT-NDP}EboZe1L3JCbegxI8pu7yK>p|@WP~3pbLsyT^2bImB{vtaA z0|O|pfX356V~U`?bRhj83^EI3Cdh1%9U!|vc7p6iHxHc;(ho8VWG2XLkR2eqKz4%c zMpsXW4~jFG8eD9cJT@AXUSQ(n=ttIr%Pg4r*l1)u$ZTYDvFU?}A*(}XBbxe_gL28ioptE89M36pEc>&T3qCtF^9J(GDAEXZCUt|mt1L=d&AT~@rIt|M= zybKHsk_-$CAU()tf!H81bbZKtkoh1rAb*1V55gceNF0Pga>(k@*)TOAbs)dPFi0GP zLE`9o(D|V99#o#dFuC;yNFT@y5KT<|0rDp(j6gJK3=JKF$}f5Joqb zSUxPR5vv|u9msAFhKUn$FH9YbMz^0-{UEzR7-k+iO~^cS`$^RgvKxe9`q1q|=fl*& zXmtD0)uZ!a`f$-8w}3D%d314f^Few+7+oE)e02X1(uZz8sru2)2e}=DVdCiK!^B}U zx;#iP2;-86iQ}Tt-H&cQx;}JyQuTw}3c@gRL2M9)@d?rB_JQ<*Fd=;~d5~HVhVjwO zgNfs!(bc2#ap{AJgX{)jTzvHKg{gzl==Ook0%4duA^jk=APiHFZXO~1=<3n=g!IGg z#YKbM2Ew@H37HSl3&Obcql=@P2ht0|=;~m6boJD8k4{tDe02AN>;_?UdtrQZ`$^RgG8=?p=AqN* z=2Kf9WH$)I%td!UsrHkqA7(F%2Dt@iOboJh|h4^s!D(d7y8Vfx6W(bF3`pOCqP)RQWY zt{$C_?jAz&gw&(!N9U8OpH%he_EE|Qr9BWvcLy#$x_#*B4VONcIJ$n2-5`uh9wrW> z(e2cyyDL3V>MOdg#^SC7s|x1Ut~AiF^rrVrhGLVR@l38_byhuI6GL2dzI z7$2QR*N@Hz=>=hQeK0<{dYCwjM)wa)97dz-N9U8OA7(C$2Du%CVSIG+3GqRCL6}(m zFm*5*-Tj2@LzgF2Kgg{h46`4^24NTx1#p^@G%cFialZ{V;JDjjkVE zJvtwz4@RTw2iXn6FnJIggkgMi_2_(b`#^d@m{|QFwIB>rk8U2iedv62_2_(b_2_(< zy|`$Q+dvp5kFFn`4^s!DL3%+L#wVm6T^^(tgmLLdSC7sI=>=h2`eEX@XiCGAkbZP~ z(cMo-Ke~E!J|X>}umNFocfj}{IS_{N37HR52ctoHK^VqI*AJ2dVRGe3^&cVoVeWv@ zgv>{mhv|dS=<qj>qq!)yV)elk&!Z7uO%p+t!sro^7gD}i~5F3PHe01{&*$>hS!o=zasRdzN z>PfX9T|GJ<-Tg3oiKRhq1!0(aQq2SD1!0(ebefQPAiW?A(}%7eoext7qd|H>7{(_= zqniiP3&Jpc=<+ae7>zCu(hI^cd2||GJvtwx7ldK@(B%p7L3%+LrXR!xVHlqf4N?oj zFm>qZ3nq?>2I&Q1m^_FL!Z1F%`(ffR8l)D4VSIG^N!1TB3xr|jq0{K*fz*O9OdY!Y zFmV`7NI%Fd5XPmRkbZRg(bc2#L3V*K%szA)BnQIi;xIl)4uoNRYMYPlADBHb8e}&J z!}#d-ld2zP4vYqw4Z<)!y7?eE5T=$q%p4ev?tW73CsjYlFCYxFAH)V>7$2Pm$$>CT z9Nj*U90R>cTF9;LMClvnZ_JPa> zVVL=Z^n=ubFibtVd4%+%t4HS((hsv27Y%Y72*cz-Y!HU=(and6!)TCN5XQxaiNk1g z^FVq*7$y&5gD{Mbt{$BaQVYT`^&mC~!}x?~^za1f1!0&zbo*f9Fq)8lm^v7ZZXO~1 z=<3n=g!F^_0>Uu+(P@wz2*bodY!HU=(and6!)TCN5GIz7?tYM75XPk+-F{N_gUkkD znEB{5y7?frAPiH7E)Nrj(dhCZy&w#e2eCmI#z%KQA^qs`r0R#+3!_190byeK==Op1 zf-tfAVd`Ksx_>}sfiO%S#0Fs)AKiXZ^~218(IB%y7{(`L9=iP?y&z1iewaEKjcy-0 zA7nNN!_=eOkFFk_57G<5F#RAl2$RdFw)v!*hprx-5Aqub)5?E@>?0%(avKQ4+=s3n zoexqA!o;d4q#tGuu{64Q=;@D;Iq3S))e|xgT|c^dbUwQI==#y+(fR1+q3cJNCsjWo z_2}jkvL9VNIv-s>C@q07x;t?3(eM z%zShj-8^*r(D|h52iXn6Fn#Fuq4Ux0L+7KbN9UufhuI6GL2dzI7$2QR*N<*KNG}MZ z>x1#p^~1zrG^z6F_LHg~W-p8exgCUId_v}<%Y*cSFtPe!>R>dw`_av(wmitKAPln~ z#0Fs)ADu?GADs`<3&QC7V0=RA(dA+KU^KcsAwEnWj7C=vaw`bKb!Z7>L%_F2AT|GLVkbaoGxM+~uKo}-Z$b6VO7!A@3!Z1F%ewa9nMt3hrF9^fr z(e!boUdo4_%&A{V;oBG{`L=jEhgmK9F7zCRRU89gIddA7mB?S8K^VqIcMm!r-F}c>5GGbXOdX6yH=ht6-F`yq(d9vI1!0)|AT|i&;-l+F z=Y#ZuFtPeUYC#yLo{)Ly@`U&xvq6|x^FeAs7?*lV?L*fOvI~UCwGZ7qQu!czVdo=& z&Jh5ey8t>X0UHLX#fGt|1&M*o0+|V7gY3Yj7hMc=-zz9hgU(L?(I7s$8gxF$Y>*uw zyFhGQ7+oJWKIpzz(0QdG`$05_k4+u27%uxj;vhGH+y-JJ>%qka-S-MRhXO={__)-< z#6f0*>;TyXVk2Xa7)TBz22+ntgU*EjoezP2=kE*#2Jl_Rpm+eOfnj9*pgVfeG3d+- zkQyNd1_mi87Gq#w0AY|ANDdi;+@`|7zyQM_H-j)NErZw~jI0O52A%N(auevxAJEx9 zpfi9#X90oY4wUvl7{msNf#g7HKx#pHKzczK#0L2fc4jRo&O!MRbOsOzgV-Q3kQ+g2 zKx#pHKzc!E1cB6m&I|&bfdV>12y~VZ=u9Dy-5?BNgTz2`AT=PhAUz=8U6GR)>0EAvJ7eCS5+R@(7)Y{PwG79XB z_F5DJIzc9bT}6mM(MCXV6OyYsAfCpfqX`rwkZ@`1=xzkDKw(wa+|<_5UD43q(GGVc zG7ILQ_GYkcO>LlXZtdto;==TTwLu7wELaDG3yK-A)1X{b9bm;ECEbn9t&n&FM_yYk zIBLNJIDQ-2J32rPhXip$1IW`Li6&6gHaE6)bbE=6bq>%~TZqo)JpK5%TIDS;ROvJD(0U;?ZJoJqi*0dv6;;538~Lt}xB zLK6dXyC7);l*U0mLNghrq#crqvDyK0A6OkYrok+*gTMqrtOsrp*wf%>>;?rBnCJwt z5Mo`;O&}}5ao*Gh%5R_q42o`uZJ;0qvA}kK32@BxfFc0u0#Nn?Dd_>FU5Ho%$WV|k zyFg-SK>{%aq5~EhXrT+{VhbU#1lT;NGhhZ_GZgF=unq(ZQ(Z?#S2L(UX>RC%rF(ED z28n^ZfY1vz3a6_O>M-qqW*n3_YVQD>2i6Am5ts#zBS=y~3L$XxfO0&lgP=NKVSwZ` zxL&XlgoD5?=mZr@U=~;wi3K(uRGJ|&F~X6ASP(}ddy9|`SfPMT9oT7L0vtF9FF?f_ z+d6v6np-=1%39hx8bC4FQwA~*RFw8W2v8~p`3PbFDytoo4ZsrMgajo(^)Eu9TM=;&wyl{szoATFq(1TI>-t5FhvdmSv-Vz59GjZLi`ZM6*@ zpq$sy(Ow6xf58MO;z4>lK*qO&ECPEPTw%0<)V0+$cXV{MG_-bri`Q0A)!SMNN`EcQ zZ5=%=4ILc_vF@hkwvKL4Ftye+w0D5jbvJ{=nwvX1T3gVFhSrW&kjbqr4Q(J6h-mKU z=&1n_wIBk*0$E-IB5E7jI(lkA1XvbiUJn5Rqzz<856BLD1jv0L&wzb|m*@e7K@TW6 zkqB&JAXC60L=B=H6wat&3Tjo=Hnn$v!=SCD3FL*Qj*d2vf7@C>5db3Ez#&xI1PWLX zQ40zZP!p^N6h}RfkOWD94Cn#r?Ez_n5n%H`CWCAP`v~lFgnz+e5CR;(uvmpeBUl|o z7F5r)G_-Vp+xI;+EufUs+}hCs7OMrNCa@TY)dJGd3SxnS4#5J4eH}=)0Yo%`2(aQd z5UT@3bb$z%>%mF16_id}KtwZ$XaW(99X%~A9UVO_jUWQzG#IO_9n5O!XaOk!yANzH z*e%Ee$N(e{Hnw$uGfoS*$bb`IF-RW=lz-dnQEUUd9!x;&sA+76bcnz@@DSk28b*Ly zH(d>l;PSMiqZ3q0fvZKRocQiv-pt1?h z!Vm+Q2bV>55W*-h3*M}0FWtUyFNi0B3p%^(8If{C?vbc1SV zB8YalI{Yjmi~=iZ#$p~eF|cAH2>h->$P!^xcXMkyDEy#-gV2lNK2UtX{ej11Q2fHx zALtFvLhO55p~>^oQw3;K1O>;Kbm};KJa_;Ktz2;KAU@;Kkt0;KSg{;K$(4 z5Wo<~5X2D75W*135XKPB5Wx`15XBJ95W^755XTTtq4)nYBrqg0Brzm2q%fp1q%ov3 zWH4khWHDqjbTM=@^f2@?^fB}^OkkMEFo|I@!xV<84AU5loHEY+%^Pu!&(a z!xo0E4BHsCGwfj4$*_xIH^Ux=y$t&p_A?w{ILL5_;V{DyhNBF}7>+ZXU^vNeis3ZF z8HTeA=NQg2Twu7!aEakE!xe_B4A&U0Gu&Xf$#9F|Hp3l;yA1aj?lU}Kc*yXG;W5J# zhNleA7@jk{V0g*!is3cG8-}+G?-<@Qd|>#<@QL9w!xx6H4Br^OGyGuq$?%KeH^U!> zzYPBv{xdQ#GBPqTGBdI;vNEzUvNLipax!u;ax?NU@-p%<@-qrB3Ni{Y3Nwl@iZY5Z ziZeN4sv>N6TJ z8ZsI&8Z(+OnlhR(nloB3S~6NOS~J=(+A`WP+7lE1j1I)8qP7}FM@A<`XGRxBS4KBR zcSaA~<}-RSdVypay%~KNeHr~2{TTxo0~v$BV!fND z3$u~2kFg)d2GNWY{{Ls3$T*2{GUF7+sf^PYr!&rAoXI$gaW>-|#<`6180RxCU|h(! zh;cFF62_&B%NUn4u3%irxQcN#;~K`bjO!TJGj3qq$he7dGvgMpE5pUe9riS@g*_-V0=Z4s-dig@ipTci2E7eLTFTy@g3uP zR1s2mj2}o*4N}Ya5lnt!{0yQ;G3g<|_yxBf#;@pdjNgz&7{4?AK<1CKM?+vV1cpus zF#crx#rT`?5943Pe~kZ`7?>EDn3$NESeRIu*qGRvIG8w@xR|(^c$j#Z_?Y;a1egSw zgqVbxM3_XG#F)gHB$yOqfiW%$UrXESM~rteC8sY?y4B?3nDC9GD!LoS2-MT$o&$ z+?d>%JeWM0yqLV1e3*Qh{FwZi0+<4sf|!DtLYP9C!kEIDBA6nXqL`wYVwhr?;+W!@ z5||R1l9-a2QkYVi(wNejGMF-%vY4`&a+q?N@|g0O3YZF+ikOO-N|;KS%9zTTDwryn zs+g*oYM5%7>X_=88kicHnwXlIT9{gy+L+pzI+!|{x|q6|dYF2d`k4BeCNNE8n#44j zX$sR+rfE#mnPxD}#2(j7vk>A;vk`02b+Rt===^)b~ zro&7}n2s_XV>-@sg6SmFDW=m*XPC}1ont!Bbb;w2(ZXM^nvLk(+|Jy=+{xU<+|As>+{@g@ z+|N9Lc_Q;9=E=-cn5QyNW1h}DgLx+NEautFbC~Bc&tsm?ynuNj^CITO%uAS;GB0CZ z&b)$oCG#rg)y!*{*D|kTUeCONc_Z^C=FQAon71--W8TiZgL$-^#Fsfn3IXO_ z%)5u0cbWGv?`7V{yr201^TDC!_EGnahQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1J zhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kin zXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeD zjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mk zz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDjE2By z2#kinXb6mkz-S1JhQMeDjE2A{7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c z4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C z(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R z7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7 zfzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c1*0J_8UmvsFd71*Aut*OqaiRF0;3@?8Umvs zFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF z0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71* zAut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@? z8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O zqaiRF0;3@?8UmvsFd71*Aut*OqaiRF0;3@?8UiCV1egyoA7(y++xN^znU66aXFh>j z#lXriFz_*e0P{%(2F5R=cr*+~)4*sN7)=ACX<#%BjHZFnG%%V5M$^D(8W>FjqiJ9? z4KRK|^p!zN3iT2?GP86$1mK0|Nu28v_HQ4+8^Z5Ca2a1Oo$O90LPm z3IhXU76Sug0RsbL83O}j4FdyX69WTd2Ll6R9|Hs96b1&ySquz}^BEWzmoqRhu4Q0g z+`+)WxQBs(@el(8;|T@^#&Zk|j8_;K7;iB!Fg{>lV0^~F!1#uNf$HUk5*Ap--mIRgW;Jp%)?8v_Hg9|Hq(6axcu3IhXkF#`j06$1lvBLf3- z2Ll81L%F4jN%FV#QD#*aV zD$c;bD$BsYs?5N^s>#5>s?Wf{YRbUCYR$mF>d3&r>dwHx>dU~u8qC1J8p*)G8qdJM zn##byn$5t#TFAh_TF$`0TFbz|+RVVf+R4Dc+RwniI+cNebv6S7>p}(w*5wQgtZNw< zST{2;uB z*c%xb*xMNx*n1fm*e5eEu+L;*V4u&xz`m4$fqgXt1N%k>2KMa?4D5Ru7}yUpFtDFw zU|>Jbz`%Z$fr0%t0|Wa*1_t)$3=HgV85r0$iTo+&cMJ?%fP_V%)r3W$-uzT&%nSjm4Sg{HUk64LIwto||iz*w4Vgag>39<1_;U$3+GPj_V8z9CsNQI36=FaJ*z-;CRo#!10xVf#Wv= z11BQ`11CEJ11B#71E(+p1E(Yd1E)L#1E(qj1E)3v1E(Pa1E)Cy1E(zm1E(_s1E(hg z1E)U&17|1$17|b?17{)w17|t|17|J+17|S<17{@z17|%017|Y>17{Zl1Lp(=2F~dW z44exY7&uomFmP^UVBp-&z`(hefr0Zd0|Vzt1_sXa3=EuC85lTkGca&IWMJTY&cML= zmVtruGXn$X9|i_4Rt5$xJ_ZIZaRvr11qKE#O$G)oBL)U8YX$}`7X}6{Uj_!QFa`#$ zcm@Wp3ofxc z*A)f^uDc8jT+bL7xZX1`aQ$Fl;AUiC;O1gr;1*_J;Fe)v;8tZ|;MQYc;5KJq;C5hO z;Pzx-;0|J7;ErZs;7(y+;Lc@W;4Wie;I3z2;O<~x;GW39z&(qBfqO9n1NRyR2JWp4 z4BYz|7`Tr!FmPXBVBo&Vz`*^8fr0xq0|WOL1_tiG3=BMM3=BN{3=BLH3=BMq3=BM4 z3=BNR3=BLr3=BN33=BMe3=BNs3=BL83=BM(3=BL)3=BNg3=BLi3=BNI3=BL|7#MhF zGBEJWXJFu2!oa|@ih+S=BLf4^9tH-U!wd{Orx+M`E;2Ci++|?kdBVWJ^OAvq=Q9HX z&u<0>UN!~>UI7LMUQq@HUI_*UUTFpfUL^(wUQGrDULytuUV8=xURMSNULOVq-Y^CR z-UJ2)-dqL--eLv@-Wmo5-ev{{-gX8C-aZBf-boA$yweyMcxN#%@Xljk;9bPPz`Klr zfp--H1MfNp2Hs5!47}SI7ao z<6~gp6JlWC6JucDlVV`tlVf1uQ(|D?Q)6J@(_&!Y(_>)ZGh$%iGh<-jvtnT2vtwZ3 zb7ElNb7NrO^I~A&^J8G(3u0j43u9p5i(+8li(_EmOJZQ)OJiW*%VJ>Q%VS{RD`H^a zD`Q~bt72f_t7Bl`YhqyFYhz&G>tbNw>tkTxo5aAtH;sXTZx#as-#i8ezC{cSe9IUZ z_*OA6@U3HD;M>H&z_*Qofo~TB1K&Od2EIcK41C8J82C;xFz}sYVBovNz`%Enfr0N9 z0|VbZ1_r)I3=DkF7#R3oF);AGV_@L>#K6G!je&vh7Xt&|KL!SVCI$w6HUyU=WaDU=UDcU=Yw_U=T28U=VO%U=Z+RU=RpmU=WCAU=T=Q zU=YY+U=S!^U=S!{U=XNbU=V0xU=ZkHU=WzVz#uSazA-Qe{9|AcWMNu`)MH=}G+|&6v}RxsbYfr-^kQHT3}RpqjACFAOk!XV%wk{= zEMi~~tYTmgY+_&#>|$ULoW#H&IE#Tna1jH8;3@_N!A%Sdg1Zj-o(Hlyo-TB_z(kw@F@lc;Y$n* z!nYV0gdZ_52)|-r5dOr#ApDDgL4=8cL4=EeK}3jwK}3pyK}3mxK}3szLBxoGLBxuI zLBxrHLBxxJK_rNQK_rTSK_rQRK_rWTL8OR*L8OX-L8OU+L8Oa;L1YpGgUBof29ZS! z3?i!-7(_NPFo^78U=TUPz#wvpfkEUF1B1vd1_qHw3=AT#7#Ku8F))byVqg$uVqg&E zVqg#zVqg%JVqg$eVqg%}Vqg$8Vqg%pVqg$;Vqg&UVqg#rVqg%BVqg$WVqg%>Vqg$0 zVqg%hVqg$$Vqg&MVqg%R#K0gri-AFO5d(whDh3A8O$-d8yBHWm4>2%^o?>7Sy~MyE zdW(TU^brGt=qm;W(N7EvqQ4jz#F!Wu#JCt3#Do|a#H1J)#FQ8q#IzU~#Ecji#H<(? z#GDuy#Jm_7#DW+Y#G)7&#F7{o#IhI|#EKXg#Htt=#F`iw#JU(5#3nH?h|OYP5L?8+ zAhwEuL2MHPgV-(x2C+j73}UAk7{o3yFo@k^U=Vx6z##UDfkEsO1B2Kv1_p5^1_p61 z1_p5<1_p5{1_p5@1_p601_p5>1_p5}1_p5_1_p621_tpU1_tpc1_tpY1_tpg1_tpW z1_tpe1_tpa1_tpi1_tp-3=HD47#PGCF))a)Vqg&8#K0iFi-AG>5CenwDFz1dOAHL+ zw-^}2A2Be9zhYnz|HQx`{)>S@f{B4af{TGcLWqGuLW+SwLWzMvLW_Yx!ia%E!is@G z!ij-F!i#}HB8Y)OB8q`QB8h=PB8!1RqKJV(qKbh*qKSb)qKkn+ViE&`#4H8|iA4+y z5~~;(BsMWHNbF)@kT}G^AaRO;LE;hvgTyTc28l-u3=*#x7$iP1Fi8AjV31^DV36cu zV2~7IV33qzV31T|V35>eV30IoV34$8V32fTV371;V2})9V33SsV315>V2~_iV34e4 zV32HJV32HPV36!(V33^7z#zGhfkARL1B2vN1_sIf3=EPdg999$T^U>>oP8X99YZ|* z{TKp6{X-xuzffN%*B}NLe;*&mAcg?fAZJ&<5C%uz0N0>kM?V(^#{mD}kRbm64_5{s zSGN#`AWwIX5C+HK5Z55jU~h&1pU_|iXMbN`M+T3`01sC`U0+YX&|n6)P#+(LV1FM^ zm(XAa7f*N35Hd+;e;ED#5D*a78vRp z4E7+%Nv=W8o{l~YK8_)teuI?+2dQhAYf!K&G-#bYgPcQs-F#dl7<~N08Qg;$!(0ce z>meZv@83qqYu;*A)Y=it_(KG`FSNp`8jqBe*U^nL9UM844!@hHtucLF2zaN8(t9y{ED>$)2(`RrfD1kc% zc?N_qAhR8vLql8{d_A3m{22l~eVl{*!x?-%U0i%z8Jt~RJbio|QIz{S20MrPfZPIh z8p1JQp+4@8K@6xA-1CDcN&1HRgm?z{csfH$gTdo4QeEQW8Ri)bD%wcZII4a$1V%$( zGz4f70!~4$VXh1={vizh?*4wR-VDLMjy^sNF8-lTpbEv`*9laSxq})|P!_13@&>hO zTqz@+BZEABd_0{QMj4}lFsM=jH1Q*KCXkp;p*}vYAq*b=L7q|mej$!N48fj$?mn(C z3aS~o#TMWi>>A?8;2Ij_5ALCaI|jJ~GlT{}XmGO(!gTQu_k*y&7KR2ega$A`Ees9} zbqsQ4@bq)@^z#gfWbpJ00X4}XZO~9ZP`kp>$JakNgu&Cr)eq9rMf5~ax*#6@p`f-r zq)qR_5E2^X=jy@`;vc}^Jx#Nu9w5 z+&}kqb@2@KWpHr}@@9b82ayd7b#w`G^b292I>X7w(b<~;7wO|4Nt1BoRJs6x^9erJ07@UF} zJ$-y!N9oaU7)=MG`Czmh7%c}z%YhM84#2t-sC@`XZvrL6I2nHY?0}lfO0}GhNz`()4!N3FMA+s46 QIR5|p|BZoxfgPa+06 Date: Tue, 6 Jan 2026 14:08:34 -0800 Subject: [PATCH 385/605] rename the selection search binding, unify into start_search action --- include/ghostty.h | 7 ---- macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Ghostty/Ghostty.Action.swift | 12 ------- macos/Sources/Ghostty/Ghostty.App.swift | 35 ------------------- .../Surface View/SurfaceView_AppKit.swift | 2 +- src/Surface.zig | 6 ++-- src/apprt/action.zig | 17 --------- src/config/Config.zig | 2 +- src/input/Binding.zig | 8 +++-- src/input/command.zig | 8 ++--- 10 files changed, 15 insertions(+), 84 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 726b368e7..5fc3a7433 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -810,11 +810,6 @@ typedef struct { ssize_t selected; } ghostty_action_search_selected_s; -// apprt.action.SelectionForSearch -typedef struct { - const char* text; -} ghostty_action_selection_for_search_s; - // terminal.Scrollbar typedef struct { uint64_t total; @@ -883,7 +878,6 @@ typedef enum { GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, - GHOSTTY_ACTION_SELECTION_FOR_SEARCH, GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, @@ -925,7 +919,6 @@ typedef union { ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; ghostty_action_start_search_s start_search; - ghostty_action_selection_for_search_s selection_for_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c365fb935..c0886607c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -616,7 +616,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) - syncMenuShortcut(config, action: "selection_for_search", menuItem: self.menuSelectionForFind) + syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index c04c7d958..91f1491dd 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -128,18 +128,6 @@ extension Ghostty.Action { } } - struct SelectionForSearch { - let text: String? - - init(c: ghostty_action_selection_for_search_s) { - if let contentCString = c.text { - self.text = String(cString: contentCString) - } else { - self.text = nil - } - } - } - enum PromptTitle { case surface case tab diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 69788c194..4e9166168 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -621,9 +621,6 @@ extension Ghostty { case GHOSTTY_ACTION_START_SEARCH: startSearch(app, target: target, v: action.action.start_search) - case GHOSTTY_ACTION_SELECTION_FOR_SEARCH: - selectionForSearch(app, target: target, v: action.action.selection_for_search) - case GHOSTTY_ACTION_END_SEARCH: endSearch(app, target: target) @@ -1884,38 +1881,6 @@ extension Ghostty { } } - private static func selectionForSearch( - _ app: ghostty_app_t, - target: ghostty_target_s, - v: ghostty_action_selection_for_search_s - ) { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("selection_for_search does nothing with an app target") - return - - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return } - guard let surfaceView = self.surfaceView(from: surface) else { return } - - let selectionForSearch = Ghostty.Action.SelectionForSearch(c: v) - DispatchQueue.main.async { - if surfaceView.searchState != nil, let text = selectionForSearch.text { - NotificationCenter.default.post( - name: .ghosttySelectionForSearch, - object: surfaceView, - userInfo: [ - "text": text - ] - ) - } - } - - default: - assertionFailure() - } - } - private static func endSearch( _ app: ghostty_app_t, target: ghostty_target_s) { diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 1fc43ac82..a5ba62571 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1521,7 +1521,7 @@ extension Ghostty { @IBAction func selectionForFind(_ sender: Any?) { guard let surface = self.surface else { return } - let action = "selection_for_search" + let action = "search_selection" if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } diff --git a/src/Surface.zig b/src/Surface.zig index 68cf46045..1f3e4da8b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5163,12 +5163,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, - .selection_for_search => { + .search_selection => { const selection = try self.selectionString(self.alloc) orelse return false; return try self.rt_app.performAction( .{ .surface = self }, - .selection_for_search, - .{ .text = selection }, + .start_search, + .{ .needle = selection }, ); }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 7fdaabf08..f00ab16ef 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -316,9 +316,6 @@ pub const Action = union(Key) { /// Start the search overlay with an optional initial needle. start_search: StartSearch, - /// Input the selected text into the search field. - selection_for_search: SelectionForSearch, - /// End the search overlay, clearing the search state and hiding it. end_search, @@ -392,7 +389,6 @@ pub const Action = union(Key) { show_on_screen_keyboard, command_finished, start_search, - selection_for_search, end_search, search_total, search_selected, @@ -919,17 +915,4 @@ pub const SearchSelected = struct { } }; -pub const SelectionForSearch = struct { - text: [:0]const u8, - // Sync with: ghostty_action_selection_for_search_s - pub const C = extern struct { - text: [*:0]const u8, - }; - - pub fn cval(self: SelectionForSearch) C { - return .{ - .text = self.text.ptr, - }; - } -}; diff --git a/src/config/Config.zig b/src/config/Config.zig index 698831ec1..ef6132912 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6588,7 +6588,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'e' }, .mods = .{ .super = true } }, - .selection_for_search, + .search_selection, .{ .performable = true }, ); try self.set.putFlags( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0ef5208bc..08f5fdf7c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -368,8 +368,10 @@ pub const Action = union(enum) { /// If a previous search is active, it is replaced. search: []const u8, - /// Input the selected text into the search field. - selection_for_search, + /// Start a search for the current text selection. If there is no + /// selection, this does nothing. If a search is already active, this + /// changes the search terms. + search_selection, /// Navigate the search results. If there is no active search, this /// is not performed. @@ -1287,7 +1289,7 @@ pub const Action = union(enum) { .cursor_key, .search, .navigate_search, - .selection_for_search, + .search_selection, .start_search, .end_search, .reset, diff --git a/src/input/command.zig b/src/input/command.zig index 3fc7b29f6..d6d2b0247 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -189,10 +189,10 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Start a search if one isn't already active.", }}, - .selection_for_search => comptime &.{.{ - .action = .selection_for_search, - .title = "Selection for Search", - .description = "Input the selected text into the search field.", + .search_selection => comptime &.{.{ + .action = .search_selection, + .title = "Search Selection", + .description = "Start a search for the current text selection.", }}, .end_search => comptime &.{.{ From f07d600e43d10b67b596b2739440d2679a8754b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 6 Jan 2026 14:12:26 -0800 Subject: [PATCH 386/605] macos: start_search with needle changes needle --- macos/Sources/Ghostty/Ghostty.App.swift | 8 ++++++-- src/apprt/action.zig | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9166168..959f78197 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1869,11 +1869,15 @@ extension Ghostty { let startSearch = Ghostty.Action.StartSearch(c: v) DispatchQueue.main.async { - if surfaceView.searchState != nil { - NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) + if let searchState = surfaceView.searchState { + if let needle = startSearch.needle, !needle.isEmpty { + searchState.needle = needle + } } else { surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) } + + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) } default: diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f00ab16ef..4dd9d2994 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -313,7 +313,9 @@ pub const Action = union(Key) { /// A command has finished, command_finished: CommandFinished, - /// Start the search overlay with an optional initial needle. + /// Start the search overlay with an optional initial needle. If the + /// search is already active and the needle is non-empty, update the + /// current search needle and focus the search input. start_search: StartSearch, /// End the search overlay, clearing the search state and hiding it. From 05a41c77726bea3b3bb6271d60b006ad4ec02733 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 6 Jan 2026 14:20:30 -0800 Subject: [PATCH 387/605] macos: clean up menu --- macos/Sources/App/macOS/MainMenu.xib | 13 +++++++------ macos/Sources/Ghostty/Package.swift | 1 - .../Sources/Ghostty/Surface View/SurfaceView.swift | 7 ------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 248063f89..aa9aca952 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -263,12 +263,6 @@ - - - - - - @@ -288,6 +282,13 @@ + + + + + + + diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index dbe9c173b..aa62c16f7 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -406,7 +406,6 @@ extension Notification.Name { /// Focus the search field static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") - static let ghosttySelectionForSearch = Notification.Name("com.mitchellh.ghostty.selectionForSearch") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index b3717d4c5..5609f36b7 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -479,13 +479,6 @@ extension Ghostty { isSearchFieldFocused = true } } - .onReceive(NotificationCenter.default.publisher(for: .ghosttySelectionForSearch)) { notification in - guard notification.object as? SurfaceView === surfaceView else { return } - if let userInfo = notification.userInfo, let text = userInfo["text"] as? String { - searchState.needle = text - // We do not focus the textfield after the action to match macOS behavior - } - } .background( GeometryReader { barGeo in Color.clear.onAppear { From 3ba4f17f0df0b284016470ac7635a289b95aef5a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 6 Jan 2026 14:21:39 -0800 Subject: [PATCH 388/605] zig fmt --- src/apprt/action.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4dd9d2994..78f4bef54 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -916,5 +916,3 @@ pub const SearchSelected = struct { }; } }; - - From 7d0157e69a7b8082b4c56baa466304768f68cbc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 6 Jan 2026 14:28:54 -0800 Subject: [PATCH 389/605] macOS: add Cmd+J "Jump to Selection" menu item and default binding This matches other built-in macOS apps like Terminal, Notes, Safari. We already had the binding, just needed to create the menu. https://ampcode.com/threads/T-019b956a-f4e6-71b4-87fa-4162258d33ff --- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ macos/Sources/App/macOS/MainMenu.xib | 6 ++++++ .../Features/Terminal/BaseTerminalController.swift | 4 ++++ .../Sources/Ghostty/Surface View/SurfaceView_AppKit.swift | 8 ++++++++ src/config/Config.zig | 6 ++++++ 5 files changed, 26 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c0886607c..2e62d033b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -47,6 +47,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuFindParent: NSMenuItem? @IBOutlet private var menuFind: NSMenuItem? @IBOutlet private var menuSelectionForFind: NSMenuItem? + @IBOutlet private var menuScrollToSelection: NSMenuItem? @IBOutlet private var menuFindNext: NSMenuItem? @IBOutlet private var menuFindPrevious: NSMenuItem? @IBOutlet private var menuHideFindBar: NSMenuItem? @@ -617,6 +618,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) + syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index aa9aca952..e28344098 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -289,6 +289,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index a4e0da7ee..98175eabc 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1388,6 +1388,10 @@ class BaseTerminalController: NSWindowController, focusedSurface?.selectionForFind(sender) } + @IBAction func scrollToSelection(_ sender: Any) { + focusedSurface?.scrollToSelection(sender) + } + @IBAction func findNext(_ sender: Any) { focusedSurface?.findNext(sender) } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index a5ba62571..ca74c7815 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1527,6 +1527,14 @@ extension Ghostty { } } + @IBAction func scrollToSelection(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "scroll_to_selection" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" diff --git a/src/config/Config.zig b/src/config/Config.zig index ef6132912..1aacf78fd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6446,6 +6446,12 @@ pub const Keybinds = struct { .{ .key = .{ .physical = .page_down }, .mods = .{ .super = true } }, .{ .scroll_page_down = {} }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = .{ .super = true } }, + .{ .scroll_to_selection = {} }, + .{ .performable = true }, + ); // Semantic prompts try self.set.put( From 61394d5213ab23a815df50b49b4c407cc4f107c2 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Wed, 7 Jan 2026 00:53:59 +0100 Subject: [PATCH 390/605] build: add -fPIC for musl targets in C++ dependencies --- .github/workflows/test.yml | 1 + pkg/dcimgui/build.zig | 2 +- pkg/freetype/build.zig | 2 +- pkg/glslang/build.zig | 2 +- pkg/highway/build.zig | 2 +- pkg/simdutf/build.zig | 2 +- pkg/spirv-cross/build.zig | 2 +- 7 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b91555f2f..45127e032 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -217,6 +217,7 @@ jobs: x86_64-macos, aarch64-linux, x86_64-linux, + x86_64-linux-musl, x86_64-windows, wasm32-freestanding, ] diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 683f0be92..95c4af303 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -64,7 +64,7 @@ pub fn build(b: *std.Build) !void { "-DIMGUI_IMPL_API=extern\t\"C\"", }); } - if (target.result.os.tag == .freebsd) { + if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); } diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index e0a041be7..ecb22cb6c 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -90,7 +90,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu "-fno-sanitize=undefined", }); - if (target.result.os.tag == .freebsd) { + if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); } diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index da9a82e31..c41e05217 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -66,7 +66,7 @@ fn buildGlslang( "-fno-sanitize-trap=undefined", }); - if (target.result.os.tag == .freebsd) { + if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); } diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 04fe70853..3715baf4a 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -73,7 +73,7 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize-trap=undefined", }); - if (target.result.os.tag == .freebsd) { + if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); } diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 2b157d1a9..3123cab21 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -32,7 +32,7 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize-trap=undefined", }); - if (target.result.os.tag == .freebsd) { + if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); } diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index 31af1974e..f85e74adf 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -74,7 +74,7 @@ fn buildSpirvCross( "-fno-sanitize-trap=undefined", }); - if (target.result.os.tag == .freebsd) { + if (target.result.os.tag == .freebsd or target.result.abi == .musl) { try flags.append(b.allocator, "-fPIC"); } From 48dd6314dc1470805d319b8d015d69c6a74ff078 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 7 Jan 2026 09:57:16 -0500 Subject: [PATCH 391/605] Use Python syntax for easier debugging --- src/font/shaper/coretext.zig | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 6d17fc014..5d6cdb00e 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -681,11 +681,8 @@ pub const Shaper = struct { const codepoints = state.codepoints.items; var last_cluster: ?u32 = null; for (codepoints, 0..) |cp, i| { - if ((cp.cluster == cluster - 3 or - cp.cluster == cluster - 2 or - cp.cluster == cluster - 1 or - cp.cluster == cluster or - cp.cluster == cluster + 1) and + if ((cp.cluster >= cell_offset.cluster - 1 and + cp.cluster <= cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { if (last_cluster) |last| { @@ -696,17 +693,19 @@ pub const Shaper = struct { if (i == index) { try writer.writeAll("▸"); } - try writer.print("\\u{{{x}}}", .{cp.codepoint}); + // Using Python syntax for easier debugging + if (cp.codepoint > 0xFFFF) { + try writer.print("\\U{x:0>8}", .{cp.codepoint}); + } else { + try writer.print("\\u{x:0>4}", .{cp.codepoint}); + } last_cluster = cp.cluster; } } try writer.writeAll(" → "); for (codepoints) |cp| { - if ((cp.cluster == cluster - 3 or - cp.cluster == cluster - 2 or - cp.cluster == cluster - 1 or - cp.cluster == cluster or - cp.cluster == cluster + 1) and + if ((cp.cluster >= cell_offset.cluster - 1 and + cp.cluster <= cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); From 13e125a05755a0ccf312006b89d72ce93d5d682b Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 7 Jan 2026 10:13:33 -0500 Subject: [PATCH 392/605] Add a big comment for the heuristic to detect ligatures. --- src/font/shaper/coretext.zig | 38 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 5d6cdb00e..c292104e2 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -389,8 +389,8 @@ pub const Shaper = struct { var cell_offset: CellOffset = .{}; // For debugging positions, turn this on: - var run_offset_y: f64 = 0.0; - var cell_offset_y: f64 = 0.0; + //var run_offset_y: f64 = 0.0; + //var cell_offset_y: f64 = 0.0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -437,13 +437,13 @@ pub const Shaper = struct { if (cell_offset.cluster != cluster) { // We previously asserted that the new cluster is greater // than cell_offset.cluster, but for rtl text this is not - // true. We then used to break out of this block if cluster - // was less than cell_offset.cluster, but now this would - // fail to reset cell_offset.x and cell_offset.cluster and - // lead to incorrect shape.Cell `x` and `x_offset`. We - // don't have a test case for RTL, yet. + // true. We then used to break out of this block if the new + // cluster was less than cell_offset.cluster, but now this + // would fail to reset cell_offset.x and + // cell_offset.cluster and lead to incorrect shape.Cell `x` + // and `x_offset`. We don't have a test case for RTL, yet. - const is_codepoint_first_in_cluster = blk: { + const is_first_codepoint_in_cluster = blk: { var i = index; while (i > 0) { i -= 1; @@ -455,19 +455,33 @@ pub const Shaper = struct { } else break :blk true; }; - if (is_codepoint_first_in_cluster) { + // We need to reset the `cell_offset` at the start of a new + // cluster, but we do that conditionally if the codepoint + // `is_first_codepoint_in_cluster`, which is a heuristic to + // detect ligatures and avoid positioning glyphs that mark + // ligatures incorrectly. The idea is that if the first + // codepoint in a cluster doesn't appear in the stream, + // it's very likely that it combined with codepoints from a + // previous cluster into a ligature. Then, the subsequent + // codepoints are very likely marking glyphs that are + // placed relative to that ligature, so if we were to reset + // the `cell_offset` to align it with the grid, the + // positions would be off. (TBD if there are exceptions to + // this heuristic, but using the logging below seems to + // show it works well.) + if (is_first_codepoint_in_cluster) { cell_offset = .{ .cluster = cluster, .x = run_offset_x, }; // For debugging positions, turn this on: - cell_offset_y = run_offset_y; + //cell_offset_y = run_offset_y; } } // For debugging positions, turn this on: - try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); + //try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); const x_offset = position.x - cell_offset.x; @@ -483,7 +497,7 @@ pub const Shaper = struct { run_offset_x += advance.width; // For debugging positions, turn this on: - run_offset_y += advance.height; + //run_offset_y += advance.height; } } From 8ebb8470b73521a1d3ce5aa5716d68b5c1ee3bd1 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 7 Jan 2026 10:46:01 -0500 Subject: [PATCH 393/605] Fix unsigned subtraction from zero --- src/font/shaper/coretext.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index c292104e2..b61c8a62d 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -695,7 +695,7 @@ pub const Shaper = struct { const codepoints = state.codepoints.items; var last_cluster: ?u32 = null; for (codepoints, 0..) |cp, i| { - if ((cp.cluster >= cell_offset.cluster - 1 and + if ((@as(i32, @intCast(cp.cluster)) >= @as(i32, @intCast(cell_offset.cluster)) - 1 and cp.cluster <= cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { @@ -718,7 +718,7 @@ pub const Shaper = struct { } try writer.writeAll(" → "); for (codepoints) |cp| { - if ((cp.cluster >= cell_offset.cluster - 1 and + if ((@as(i32, @intCast(cp.cluster)) >= @as(i32, @intCast(cell_offset.cluster)) - 1 and cp.cluster <= cluster + 1) and cp.codepoint != 0 // Skip surrogate pair padding ) { From 02fc0f502f8634c64037bb03d1d5c99e3272e2eb Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:56:12 +0100 Subject: [PATCH 394/605] macOS: rename function to avoid mutating misunderstanding --- macos/Sources/Features/Splits/SplitTree.swift | 24 +++++++++---------- .../Terminal/BaseTerminalController.swift | 22 ++++++++--------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 23b597591..2fb83e64c 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -121,10 +121,10 @@ 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 { + func inserting(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), + root: try root.inserting(view: view, at: at, direction: direction), zoomed: nil) } /// Find a node containing a view with the specified ID. @@ -137,7 +137,7 @@ extension SplitTree { /// 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 { + func removing(_ target: Node) -> Self { guard let root else { return self } // If we're removing the root itself, return an empty tree @@ -155,7 +155,7 @@ extension SplitTree { } /// Replace a node in the tree with a new node. - func replace(node: Node, with newNode: Node) throws -> Self { + func replacing(node: Node, with newNode: Node) throws -> Self { guard let root else { throw SplitError.viewNotFound } // Get the path to the node we want to replace @@ -164,7 +164,7 @@ extension SplitTree { } // Replace the node - let newRoot = try root.replaceNode(at: path, with: newNode) + let newRoot = try root.replacingNode(at: path, with: newNode) // Update zoomed if it was the replaced node let newZoomed = (zoomed == node) ? newNode : zoomed @@ -232,7 +232,7 @@ extension SplitTree { /// 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 { + func equalized() -> Self { guard let root else { return self } let newRoot = root.equalize() return .init(root: newRoot, zoomed: zoomed) @@ -255,7 +255,7 @@ extension SplitTree { /// - 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 { + func resizing(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 @@ -327,7 +327,7 @@ extension SplitTree { ) // Replace the split node with the new one - let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) + let newRoot = try root.replacingNode(at: splitPath, with: .split(newSplit)) return .init(root: newRoot, zoomed: nil) } @@ -508,7 +508,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: ViewType, at: ViewType, direction: NewDirection) throws -> Self { + func inserting(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 { @@ -544,11 +544,11 @@ extension SplitTree.Node { )) // Replace the node at the path with the new split - return try replaceNode(at: path, with: newSplit) + return try replacingNode(at: path, with: newSplit) } /// Helper function to replace a node at the given path from the root - func replaceNode(at path: Path, with newNode: Self) throws -> Self { + func replacingNode(at path: Path, with newNode: Self) throws -> Self { // If path is empty, replace the root if path.isEmpty { return newNode @@ -635,7 +635,7 @@ 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 { + func resizing(to ratio: Double) -> Self { switch self { case .leaf: // Leaf nodes don't have a ratio to resize diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 98175eabc..ecf72dd34 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -240,7 +240,7 @@ class BaseTerminalController: NSWindowController, // Do the split let newTree: SplitTree do { - newTree = try surfaceTree.insert( + newTree = try surfaceTree.inserting( view: newView, at: oldView, direction: direction) @@ -450,7 +450,7 @@ class BaseTerminalController: NSWindowController, } replaceSurfaceTree( - surfaceTree.remove(node), + surfaceTree.removing(node), moveFocusTo: nextFocus, moveFocusFrom: focusedSurface, undoAction: "Close Terminal" @@ -614,7 +614,7 @@ class BaseTerminalController: NSWindowController, guard surfaceTree.contains(target) else { return } // Equalize the splits - surfaceTree = surfaceTree.equalize() + surfaceTree = surfaceTree.equalized() } @objc private func ghosttyDidFocusSplit(_ notification: Notification) { @@ -704,7 +704,7 @@ class BaseTerminalController: NSWindowController, // Perform the resize using the new SplitTree resize method do { - surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds) + surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds) } catch { Ghostty.logger.warning("failed to resize split: \(error)") } @@ -742,7 +742,7 @@ class BaseTerminalController: NSWindowController, } // Remove the surface from our tree - let removedTree = surfaceTree.remove(targetNode) + let removedTree = surfaceTree.removing(targetNode) // Create a new tree with the dragged surface and open a new window let newTree = SplitTree(view: target) @@ -868,9 +868,9 @@ class BaseTerminalController: NSWindowController, } private func splitDidResize(node: SplitTree.Node, to newRatio: Double) { - let resizedNode = node.resize(to: newRatio) + let resizedNode = node.resizing(to: newRatio) do { - surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) + surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode) } catch { Ghostty.logger.warning("failed to replace node during split resize: \(error)") } @@ -892,10 +892,10 @@ class BaseTerminalController: NSWindowController, // Check if source is in our tree if let sourceNode = surfaceTree.root?.node(view: source) { // Source is in our tree - same window move - let treeWithoutSource = surfaceTree.remove(sourceNode) + let treeWithoutSource = surfaceTree.removing(sourceNode) let newTree: SplitTree do { - newTree = try treeWithoutSource.insert(view: source, at: destination, direction: direction) + newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction) } catch { Ghostty.logger.warning("failed to insert surface during drop: \(error)") return @@ -930,10 +930,10 @@ class BaseTerminalController: NSWindowController, // Remove from source controller's tree and add it to our tree. // We do this first because if there is an error then we can // abort. - let sourceTreeWithoutNode = sourceController.surfaceTree.remove(sourceNode) + let sourceTreeWithoutNode = sourceController.surfaceTree.removing(sourceNode) let newTree: SplitTree do { - newTree = try surfaceTree.insert(view: source, at: destination, direction: direction) + newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction) } catch { Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)") return From a265462aa65976e5d858ad13ca6fba7bf155be28 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:57:15 +0100 Subject: [PATCH 395/605] macOS: moving a focused split to another tab should also update the previous tab --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ecf72dd34..71daafaeb 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -961,10 +961,9 @@ class BaseTerminalController: NSWindowController, confirmUndo: false) } } else { - // The source isn't empty so we can do a simple replace which will handle + // The source isn't empty so we can do a simple remove which will handle // the undo properly. - sourceController.replaceSurfaceTree( - sourceTreeWithoutNode) + sourceController.removeSurfaceNode(sourceNode) } // Add in the surface to our tree From 323d362bc18003ef94a1b69ce27b9833573a2aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 7 Jan 2026 09:33:32 -0800 Subject: [PATCH 396/605] macos: dragging last window out of quick terminal works --- .../Terminal/BaseTerminalController.swift | 24 +------ .../Terminal/TerminalController.swift | 64 ++++++++++++------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 71daafaeb..88d8e39d8 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -457,7 +457,7 @@ class BaseTerminalController: NSWindowController, ) } - private func replaceSurfaceTree( + func replaceSurfaceTree( _ newTree: SplitTree, moveFocusTo newView: Ghostty.SurfaceView? = nil, moveFocusFrom oldView: Ghostty.SurfaceView? = nil, @@ -930,7 +930,6 @@ class BaseTerminalController: NSWindowController, // Remove from source controller's tree and add it to our tree. // We do this first because if there is an error then we can // abort. - let sourceTreeWithoutNode = sourceController.surfaceTree.removing(sourceNode) let newTree: SplitTree do { newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction) @@ -946,25 +945,8 @@ class BaseTerminalController: NSWindowController, undoManager?.endUndoGrouping() } - if sourceTreeWithoutNode.isEmpty { - // If our source tree is becoming empty, then we're closing this terminal. - // We need to handle this carefully to get undo to work properly. If the - // controller is a TerminalController this is easy because it has a way - // to do this. - if let c = sourceController as? TerminalController { - c.closeTabImmediately() - } else { - // Not a TerminalController so we always undo into a new window. - _ = TerminalController.newWindow( - sourceController.ghostty, - tree: sourceController.surfaceTree, - confirmUndo: false) - } - } else { - // The source isn't empty so we can do a simple remove which will handle - // the undo properly. - sourceController.removeSurfaceNode(sourceNode) - } + // Remove the node from the source. + sourceController.removeSurfaceNode(sourceNode) // Add in the surface to our tree replaceSurfaceTree( diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index abaedbe41..c7f9fe086 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -8,16 +8,16 @@ import GhosttyKit class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller { override var windowNibName: NSNib.Name? { let defaultValue = "Terminal" - + guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config - + // If we have no window decorations, there's no reason to do anything but // the default titlebar (because there will be no titlebar). if !config.windowDecorations { return defaultValue } - + let nib = switch config.macosTitlebarStyle { case "native": "Terminal" case "hidden": "TerminalHiddenTitlebar" @@ -34,33 +34,33 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr #endif 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 /// early if we don't care. private var tabListenForFrame: Bool = false - + /// This is the hash value of the last tabGroup.windows array. We use this to detect order /// changes in the list. private var tabWindowsHash: Int = 0 - + /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true - + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig - - + + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] - + /// This will be set to the initial frame of the window from the xib on load. private var initialFrame: NSRect? = nil - + init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: SplitTree? = nil, @@ -72,12 +72,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // as the script. We may want to revisit this behavior when we have scrollback // restoration. self.restorable = (base?.command ?? "") == "" - + // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - + super.init(ghostty, baseConfig: base, surfaceTree: tree) - + // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( @@ -134,36 +134,56 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr object: nil ) } - + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } - + deinit { // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) } - + // MARK: Base Controller Overrides - + 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.isEmpty) { self.window?.close() } } + + override func replaceSurfaceTree( + _ newTree: SplitTree, + moveFocusTo newView: Ghostty.SurfaceView? = nil, + moveFocusFrom oldView: Ghostty.SurfaceView? = nil, + undoAction: String? = nil + ) { + // We have a special case if our tree is empty to close our tab immediately. + // This makes it so that undo is handled properly. + if newTree.isEmpty { + closeTabImmediately() + return + } + + super.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: undoAction) + } // MARK: Terminal Creation From 5a042570c84c759d1ab653d2376ddc53297fdf0f Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:40:36 -0500 Subject: [PATCH 397/605] feat: select entire URL on double-click When double-clicking text, first check if the position is part of a URL using the default URL regex pattern. If a URL is detected, select the entire URL instead of just the word. This follows the feedback from PR #2324 to modify the selection behavior rather than introducing a separate selectLink function. The implementation uses the existing URL regex from config/url.zig which already handles various URL schemes (http, https, ftp, ssh, etc.) and file paths. The URL detection runs before the normal word selection, falling back to selectWord if no URL is found at the clicked position. --- src/Surface.zig | 44 ++++++++++--- src/terminal/StringMap.zig | 128 +++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1f3e4da8b..11fa42e35 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4141,9 +4141,18 @@ pub fn mouseButtonCallback( } }, - // Double click, select the word under our mouse + // Double click, select the word under our mouse. + // First try to detect if we're clicking on a URL to select the entire URL. 2 => { - const sel_ = self.io.terminal.screens.active.selectWord(pin.*); + const sel_ = sel: { + // Try link detection without requiring modifier keys + const link_result = self.linkAtPin(pin.*, null) catch null; + if (link_result) |result| { + // Only select URLs (links with .open action) + if (result[0] == .open) break :sel result[1]; + } + break :sel self.io.terminal.screens.active.selectWord(pin.*); + }; if (sel_) |sel| { try self.io.terminal.screens.active.select(sel); try self.queueRender(); @@ -4364,11 +4373,26 @@ fn linkAtPos( return .{ ._open_osc8, sel }; } - // If we have no OSC8 links then we fallback to regex-based URL detection. - // If we have no configured links we can save a lot of work going forward. + // Fall back to regex-based link detection + return self.linkAtPin(mouse_pin, mouse_mods); +} + +/// Core link detection at a pin position using regex patterns. +/// When mouse_mods is null, skips highlight/modifier checks (for double-click). +/// +/// Requires the renderer state mutex is held. +fn linkAtPin( + self: *Surface, + mouse_pin: terminal.Pin, + mouse_mods: ?input.Mods, +) !?struct { + input.Link.Action, + terminal.Selection, +} { + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + if (self.config.links.len == 0) return null; - // Get the line we're hovering over. const line = screen.selectLine(.{ .pin = mouse_pin, .whitespace = null, @@ -4383,11 +4407,13 @@ fn linkAtPos( })); defer strmap.deinit(self.alloc); - // Go through each link and see if we clicked it for (self.config.links) |link| { - switch (link.highlight) { - .always, .hover => {}, - .always_mods, .hover_mods => |v| if (!v.equal(mouse_mods)) continue, + // Skip highlight/mods check when mouse_mods is null (double-click mode) + if (mouse_mods) |mods| { + switch (link.highlight) { + .always, .hover => {}, + .always_mods, .hover_mods => |v| if (!v.equal(mods)) continue, + } } var it = strmap.searchIterator(link.regex); diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index 4ac47eeab..f7d88d1c8 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -147,3 +147,131 @@ test "StringMap searchIterator" { try testing.expect(try it.next() == null); } + +test "StringMap searchIterator URL detection" { + if (comptime !build_options.oniguruma) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const url = @import("../config/url.zig"); + + // Initialize URL regex + try oni.testing.ensureInit(); + var re = try oni.Regex.init( + url.regex, + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + // Initialize our screen with text containing a URL + var s = try Screen.init(alloc, .{ .cols = 40, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello https://example.com/path world"); + + // Get the line + const line = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 10, + .y = 0, + } }).?, + }).?; + var map: StringMap = undefined; + const sel_str = try s.selectionString(alloc, .{ + .sel = line, + .trim = false, + .map = &map, + }); + alloc.free(sel_str); + defer map.deinit(alloc); + + // Search for URL match + var it = map.searchIterator(re); + { + var match = (try it.next()).?; + defer match.deinit(); + + const sel = match.selection(); + // URL should start at x=6 ("https://example.com/path" starts after "hello ") + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + // URL should end at x=29 (end of "/path") + try testing.expectEqual(point.Point{ .screen = .{ + .x = 29, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + try testing.expect(try it.next() == null); +} + +test "StringMap searchIterator URL with click position" { + if (comptime !build_options.oniguruma) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + const url = @import("../config/url.zig"); + + // Initialize URL regex + try oni.testing.ensureInit(); + var re = try oni.Regex.init( + url.regex, + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + // Initialize our screen with text containing a URL + var s = try Screen.init(alloc, .{ .cols = 40, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello https://example.com world"); + + // Simulate clicking on "example" (x=14) + const click_pin = s.pages.pin(.{ .active = .{ + .x = 14, + .y = 0, + } }).?; + + // Get the line + const line = s.selectLine(.{ + .pin = click_pin, + }).?; + var map: StringMap = undefined; + const sel_str = try s.selectionString(alloc, .{ + .sel = line, + .trim = false, + .map = &map, + }); + alloc.free(sel_str); + defer map.deinit(alloc); + + // Search for URL match and verify click position is within URL + var it = map.searchIterator(re); + var found_url = false; + while (true) { + var match = (try it.next()) orelse break; + defer match.deinit(); + + const sel = match.selection(); + if (sel.contains(&s, click_pin)) { + found_url = true; + // Verify URL bounds + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 24, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + break; + } + } + try testing.expect(found_url); +} From 66593157602ee440e4b19734ccabeb8349b3cdd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 7 Jan 2026 10:01:45 -0800 Subject: [PATCH 398/605] tweaks to link detection --- src/Surface.zig | 83 +++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 11fa42e35..7e9a307e5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -327,7 +327,7 @@ const DerivedConfig = struct { window_width: u32, title: ?[:0]const u8, title_report: bool, - links: []Link, + links: []DerivedConfig.Link, link_previews: configpkg.LinkPreviews, scroll_to_bottom: configpkg.Config.ScrollToBottom, notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, @@ -347,7 +347,7 @@ const DerivedConfig = struct { // Build all of our links const links = links: { - var links: std.ArrayList(Link) = .empty; + var links: std.ArrayList(DerivedConfig.Link) = .empty; defer links.deinit(alloc); for (config.link.links.items) |link| { var regex = try link.oniRegex(); @@ -1599,10 +1599,10 @@ fn mouseRefreshLinks( } const link = (try self.linkAtPos(pos)) orelse break :link .{ null, false }; - switch (link[0]) { + switch (link.action) { .open => { const str = try self.io.terminal.screens.active.selectionString(alloc, .{ - .sel = link[1], + .sel = link.selection, .trim = false, }); break :link .{ @@ -1613,7 +1613,7 @@ fn mouseRefreshLinks( ._open_osc8 => { // Show the URL in the status bar - const pin = link[1].start(); + const pin = link.selection.start(); const uri = self.osc8URI(pin) orelse { log.warn("failed to get URI for OSC8 hyperlink", .{}); break :link .{ null, false }; @@ -4146,11 +4146,17 @@ pub fn mouseButtonCallback( 2 => { const sel_ = sel: { // Try link detection without requiring modifier keys - const link_result = self.linkAtPin(pin.*, null) catch null; - if (link_result) |result| { - // Only select URLs (links with .open action) - if (result[0] == .open) break :sel result[1]; + if (self.linkAtPin( + pin.*, + null, + )) |result_| { + if (result_) |result| { + break :sel result.selection; + } + } else |_| { + // Ignore any errors, likely regex errors. } + break :sel self.io.terminal.screens.active.selectWord(pin.*); }; if (sel_) |sel| { @@ -4340,16 +4346,18 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { } } +const Link = struct { + action: input.Link.Action, + selection: terminal.Selection, +}; + /// Returns the link at the given cursor position, if any. /// /// Requires the renderer mutex is held. fn linkAtPos( self: *Surface, pos: apprt.CursorPos, -) !?struct { - input.Link.Action, - terminal.Selection, -} { +) !?Link { // Convert our cursor position to a screen point. const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const mouse_pin: terminal.Pin = mouse_pin: { @@ -4370,29 +4378,27 @@ fn linkAtPos( const cell = rac.cell; if (!cell.hyperlink) break :hyperlink; const sel = terminal.Selection.init(mouse_pin, mouse_pin, false); - return .{ ._open_osc8, sel }; + return .{ .action = ._open_osc8, .selection = sel }; } - // Fall back to regex-based link detection - return self.linkAtPin(mouse_pin, mouse_mods); + // Fall back to configured links + return try self.linkAtPin(mouse_pin, mouse_mods); } -/// Core link detection at a pin position using regex patterns. -/// When mouse_mods is null, skips highlight/modifier checks (for double-click). +/// Detects if a link is present at the given pin. +/// +/// If mouse mods is null then mouse mod requirements are ignored (all +/// configured links are checked). /// /// Requires the renderer state mutex is held. fn linkAtPin( self: *Surface, mouse_pin: terminal.Pin, mouse_mods: ?input.Mods, -) !?struct { - input.Link.Action, - terminal.Selection, -} { - const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; - +) !?Link { if (self.config.links.len == 0) return null; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const line = screen.selectLine(.{ .pin = mouse_pin, .whitespace = null, @@ -4409,12 +4415,10 @@ fn linkAtPin( for (self.config.links) |link| { // Skip highlight/mods check when mouse_mods is null (double-click mode) - if (mouse_mods) |mods| { - switch (link.highlight) { - .always, .hover => {}, - .always_mods, .hover_mods => |v| if (!v.equal(mods)) continue, - } - } + if (mouse_mods) |mods| switch (link.highlight) { + .always, .hover => {}, + .always_mods, .hover_mods => |v| if (!v.equal(mods)) continue, + }; var it = strmap.searchIterator(link.regex); while (true) { @@ -4422,7 +4426,10 @@ fn linkAtPin( defer match.deinit(); const sel = match.selection(); if (!sel.contains(screen, mouse_pin)) continue; - return .{ link.action, sel }; + return .{ + .action = link.action, + .selection = sel, + }; } } @@ -4453,11 +4460,11 @@ fn mouseModsWithCapture(self: *Surface, mods: input.Mods) input.Mods { /// /// Requires the renderer state mutex is held. fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { - const action, const sel = try self.linkAtPos(pos) orelse return false; - switch (action) { + const link = try self.linkAtPos(pos) orelse return false; + switch (link.action) { .open => { const str = try self.io.terminal.screens.active.selectionString(self.alloc, .{ - .sel = sel, + .sel = link.selection, .trim = false, }); defer self.alloc.free(str); @@ -4470,7 +4477,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { }, ._open_osc8 => { - const uri = self.osc8URI(sel.start()) orelse { + const uri = self.osc8URI(link.selection.start()) orelse { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; @@ -5313,11 +5320,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); if (try self.linkAtPos(pos)) |link_info| { - const url_text = switch (link_info[0]) { + const url_text = switch (link_info.action) { .open => url_text: { // For regex links, get the text from selection break :url_text (self.io.terminal.screens.active.selectionString(self.alloc, .{ - .sel = link_info[1], + .sel = link_info.selection, .trim = self.config.clipboard_trim_trailing_spaces, })) catch |err| { log.err("error reading url string err={}", .{err}); @@ -5327,7 +5334,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ._open_osc8 => url_text: { // For OSC8 links, get the URI directly from hyperlink data - const uri = self.osc8URI(link_info[1].start()) orelse { + const uri = self.osc8URI(link_info.selection.start()) orelse { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; From 795de7938d1087eaa324a4e4829a7039714e494d Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 24 Dec 2025 12:34:39 -0500 Subject: [PATCH 399/605] shell-integration: better shell detection and setup Command-based shell detection has been extracted to its own function (detectShell), which is nicer for testing. It now uses argIterator to determine the command's executable, rather than the previous string operations, which allows us to handle command strings containing quotes and spaces. Also, our shell-specific setup functions now use a consistent signature, which simplifies the calling code quite a bit. --- src/termio/shell_integration.zig | 197 ++++++++++++++++--------------- 1 file changed, 102 insertions(+), 95 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index b803d1897..b9477e090 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -45,95 +45,37 @@ pub fn setup( env: *EnvMap, force_shell: ?Shell, ) !?ShellIntegration { - const exe = if (force_shell) |shell| switch (shell) { - .bash => "bash", - .elvish => "elvish", - .fish => "fish", - .zsh => "zsh", - } else switch (command) { - .direct => |v| std.fs.path.basename(v[0]), - .shell => |v| exe: { - // Shell strings can include spaces so we want to only - // look up to the space if it exists. No shell that we integrate - // has spaces. - const idx = std.mem.indexOfScalar(u8, v, ' ') orelse v.len; - break :exe std.fs.path.basename(v[0..idx]); - }, - }; + const shell: Shell = force_shell orelse + try detectShell(alloc_arena, command) orelse + return null; - const result = try setupShell( - alloc_arena, - resource_dir, - command, - env, - exe, - ); - - return result; -} - -fn setupShell( - alloc_arena: Allocator, - resource_dir: []const u8, - command: config.Command, - env: *EnvMap, - exe: []const u8, -) !?ShellIntegration { - if (std.mem.eql(u8, "bash", exe)) { - // Apple distributes their own patched version of Bash 3.2 - // on macOS that disables the ENV-based POSIX startup path. - // This means we're unable to perform our automatic shell - // integration sequence in this specific environment. - // - // If we're running "/bin/bash" on Darwin, we can assume - // we're using Apple's Bash because /bin is non-writable - // on modern macOS due to System Integrity Protection. - if (comptime builtin.target.os.tag.isDarwin()) { - if (std.mem.eql(u8, "/bin/bash", switch (command) { - .direct => |v| v[0], - .shell => |v| v, - })) { - return null; - } - } - - const new_command = try setupBash( + const new_command: config.Command = switch (shell) { + .bash => try setupBash( alloc_arena, command, resource_dir, env, - ) orelse return null; - return .{ - .shell = .bash, - .command = new_command, - }; - } + ), - if (std.mem.eql(u8, "elvish", exe)) { - if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; - return .{ - .shell = .elvish, - .command = try command.clone(alloc_arena), - }; - } + .elvish, .fish => try setupXdgDataDirs( + alloc_arena, + command, + resource_dir, + env, + ), - if (std.mem.eql(u8, "fish", exe)) { - if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; - return .{ - .shell = .fish, - .command = try command.clone(alloc_arena), - }; - } + .zsh => try setupZsh( + alloc_arena, + command, + resource_dir, + env, + ), + } orelse return null; - if (std.mem.eql(u8, "zsh", exe)) { - if (!try setupZsh(resource_dir, env)) return null; - return .{ - .shell = .zsh, - .command = try command.clone(alloc_arena), - }; - } - - return null; + return .{ + .shell = shell, + .command = new_command, + }; } test "force shell" { @@ -185,6 +127,55 @@ test "shell integration failure" { try testing.expectEqual(0, env.count()); } +fn detectShell(alloc: Allocator, command: config.Command) !?Shell { + var arg_iter = try command.argIterator(alloc); + defer arg_iter.deinit(); + + const arg0 = arg_iter.next() orelse return null; + const exe = std.fs.path.basename(arg0); + + if (std.mem.eql(u8, "bash", exe)) { + // Apple distributes their own patched version of Bash 3.2 + // on macOS that disables the ENV-based POSIX startup path. + // This means we're unable to perform our automatic shell + // integration sequence in this specific environment. + // + // If we're running "/bin/bash" on Darwin, we can assume + // we're using Apple's Bash because /bin is non-writable + // on modern macOS due to System Integrity Protection. + if (comptime builtin.target.os.tag.isDarwin()) { + if (std.mem.eql(u8, "/bin/bash", arg0)) { + return null; + } + } + return .bash; + } + + if (std.mem.eql(u8, "elvish", exe)) return .elvish; + if (std.mem.eql(u8, "fish", exe)) return .fish; + if (std.mem.eql(u8, "zsh", exe)) return .zsh; + + return null; +} + +test detectShell { + const testing = std.testing; + const alloc = testing.allocator; + + try testing.expect(try detectShell(alloc, .{ .shell = "sh" }) == null); + try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash" })); + try testing.expectEqual(.elvish, try detectShell(alloc, .{ .shell = "elvish" })); + try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" })); + try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" })); + + if (comptime builtin.target.os.tag.isDarwin()) { + try testing.expect(try detectShell(alloc, .{ .shell = "/bin/bash" }) == null); + } + + try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash -c 'command'" })); + try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "\"/a b/bash\"" })); +} + /// Set up the shell integration features environment variable. pub fn setupFeatures( env: *EnvMap, @@ -603,10 +594,11 @@ test "bash: missing resources" { /// so that the shell can refer to it and safely remove this directory /// from `XDG_DATA_DIRS` when integration is complete. fn setupXdgDataDirs( - alloc_arena: Allocator, + alloc: Allocator, + command: config.Command, resource_dir: []const u8, env: *EnvMap, -) !bool { +) !?config.Command { var path_buf: [std.fs.max_path_bytes]u8 = undefined; // Get our path to the shell integration directory. @@ -617,7 +609,7 @@ fn setupXdgDataDirs( ); var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { log.warn("unable to open {s}: {}", .{ integ_path, err }); - return false; + return null; }; integ_dir.close(); @@ -631,7 +623,7 @@ fn setupXdgDataDirs( // 4K is a reasonable size for this for most cases. However, env // vars can be significantly larger so if we have to we fall // back to a heap allocated value. - var stack_alloc_state = std.heap.stackFallback(4096, alloc_arena); + var stack_alloc_state = std.heap.stackFallback(4096, alloc); const stack_alloc = stack_alloc_state.get(); // If no XDG_DATA_DIRS set use the default value as specified. @@ -648,7 +640,7 @@ fn setupXdgDataDirs( ), ); - return true; + return try command.clone(alloc); } test "xdg: empty XDG_DATA_DIRS" { @@ -664,7 +656,8 @@ test "xdg: empty XDG_DATA_DIRS" { var env = EnvMap.init(alloc); defer env.deinit(); - try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); + const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env); + try testing.expectEqualStrings("xdg", command.?.shell); var path_buf: [std.fs.max_path_bytes]u8 = undefined; try testing.expectEqualStrings( @@ -691,7 +684,9 @@ test "xdg: existing XDG_DATA_DIRS" { defer env.deinit(); try env.put("XDG_DATA_DIRS", "/opt/share"); - try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); + + const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env); + try testing.expectEqualStrings("xdg", command.?.shell); var path_buf: [std.fs.max_path_bytes]u8 = undefined; try testing.expectEqualStrings( @@ -719,7 +714,7 @@ test "xdg: missing resources" { var env = EnvMap.init(alloc); defer env.deinit(); - try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env)); + try testing.expect(try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, resources_dir, &env) == null); try testing.expectEqual(0, env.count()); } @@ -727,9 +722,11 @@ test "xdg: missing resources" { /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config. fn setupZsh( + alloc: Allocator, + command: config.Command, resource_dir: []const u8, env: *EnvMap, -) !bool { +) !?config.Command { // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); @@ -744,24 +741,29 @@ fn setupZsh( ); var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { log.warn("unable to open {s}: {}", .{ integ_path, err }); - return false; + return null; }; integ_dir.close(); try env.put("ZDOTDIR", integ_path); - return true; + return try command.clone(alloc); } test "zsh" { const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); defer res.deinit(); var env = EnvMap.init(testing.allocator); defer env.deinit(); - try testing.expect(try setupZsh(res.path, &env)); + const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env); + try testing.expectEqualStrings("zsh", command.?.shell); try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null); } @@ -769,6 +771,10 @@ test "zsh" { test "zsh: ZDOTDIR" { const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); defer res.deinit(); @@ -777,7 +783,8 @@ test "zsh: ZDOTDIR" { try env.put("ZDOTDIR", "$HOME/.config/zsh"); - try testing.expect(try setupZsh(res.path, &env)); + const command = try setupZsh(alloc, .{ .shell = "zsh" }, res.path, &env); + try testing.expectEqualStrings("zsh", command.?.shell); try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?); } @@ -797,7 +804,7 @@ test "zsh: missing resources" { var env = EnvMap.init(alloc); defer env.deinit(); - try testing.expect(!try setupZsh(resources_dir, &env)); + try testing.expect(try setupZsh(alloc, .{ .shell = "zsh" }, resources_dir, &env) == null); try testing.expectEqual(0, env.count()); } From 7bfcaef1e894eacede1208ab1dc99ca727010386 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 7 Jan 2026 12:51:40 -0800 Subject: [PATCH 400/605] terminal: formatting feedback --- src/terminal/formatter.zig | 262 +++++++++++++++++-------------------- 1 file changed, 121 insertions(+), 141 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 2e846f7c1..4249187a7 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1120,18 +1120,27 @@ pub const PageFormatter = struct { // If we have a zero value, then we accumulate a counter. We // only want to turn zero values into spaces if we have a non-zero - // char sometime later. However, for styled formats (VT, HTML), if - // the cell has styling (e.g., background color), we must emit it - // to preserve the visual appearance. - const dominated_by_style = self.opts.emit.styled() and - (!cell.isEmpty() or cell.hasStyling()); - if (!dominated_by_style and !cell.hasText()) { - blank_cells += 1; - continue; - } - if (cell.codepoint() == ' ' and self.opts.trim and !dominated_by_style) { - blank_cells += 1; - continue; + // char sometime later. + blank: { + // If we're emitting styled output (not plaintext) and + // the cell has some kind of styling or is not empty + // then this isn't blank. + if (self.opts.emit.styled() and + (!cell.isEmpty() or cell.hasStyling())) break :blank; + + // Cells with no text are blank + if (!cell.hasText()) { + blank_cells += 1; + continue; + } + + // Trailing spaces are blank. We know it is trailing + // because if we get a non-empty cell later we'll + // fill the blanks. + if (cell.codepoint() == ' ' and self.opts.trim) { + blank_cells += 1; + continue; + } } // This cell is not blank. If we have accumulated blank cells @@ -1169,79 +1178,72 @@ pub const PageFormatter = struct { blank_cells = 0; } + style: { + // If we aren't emitting styled output then we don't + // have to worry about styles. + if (!self.opts.emit.styled()) break :style; + + // Get our cell style. + const cell_style = self.cellStyle(cell); + + // If the style hasn't changed, don't bloat output. + if (cell_style.eql(style)) break :style; + + // If we had a previous style, we need to close it, + // because we've confirmed we have some new style + // (which is maybe default). + if (!style.default()) switch (self.opts.emit) { + .html => try self.formatStyleClose(writer), + + // For VT, we only close if we're switching to a default + // style because any non-default style will emit + // a \x1b[0m as the start of a VT coloring sequence. + .vt => if (cell_style.default()) try self.formatStyleClose(writer), + + // Unreachable because of the styled() check at the + // top of this block. + .plain => unreachable, + }; + + // At this point, we can copy our style over + style = cell_style; + + // If we're just the default style now, we're done. + if (cell_style.default()) break :style; + + // New style, emit it. + try self.formatStyleOpen( + writer, + &style, + ); + + // If we have a point map, we map the style to + // this cell. + if (self.point_map) |*map| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try self.formatStyleOpen( + &discarding.writer, + &style, + ); + for (0..discarding.count) |_| map.map.append(map.alloc, .{ + .x = x, + .y = y, + }) catch return error.WriteFailed; + } + } + switch (cell.content_tag) { // We combine codepoint and graphemes because both have // shared style handling. We use comptime to dup it. inline .codepoint, .codepoint_grapheme => |tag| { - // Handle closing our styling if we go back to unstyled - // content. - if (self.opts.emit.styled() and - !cell.hasStyling() and - !style.default()) - { - try self.formatStyleClose(writer); - style = .{}; - } - - // If we're emitting styling and we have styles, then - // we need to load the style and emit any sequences - // as necessary. - if (self.opts.emit.styled() and cell.hasStyling()) style: { - // Get the style. - const cell_style = self.page.styles.get( - self.page.memory, - cell.style_id, - ); - - // If the style hasn't changed since our last - // emitted style, don't bloat the output. - if (cell_style.eql(style)) break :style; - - // We need to emit a closing tag if the style - // was non-default before, which means we set - // styles once. - const closing = !style.default(); - - // New style, emit it. - style = cell_style.*; - try self.formatStyleOpen( - writer, - &style, - closing, - ); - - // If we have a point map, we map the style to - // this cell. - if (self.point_map) |*map| { - var discarding: std.Io.Writer.Discarding = .init(&.{}); - try self.formatStyleOpen( - &discarding.writer, - &style, - closing, - ); - for (0..discarding.count) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; - } - } - - // For styled cells without text, emit a space to carry the styling - if (cell.hasText()) { - try self.writeCell(tag, writer, cell); - } else { - try writer.writeByte(' '); - } + try self.writeCell(tag, writer, cell); // If we have a point map, all codepoints map to this // cell. if (self.point_map) |*map| { - const byte_count: usize = if (cell.hasText()) count: { - var discarding: std.Io.Writer.Discarding = .init(&.{}); - try self.writeCell(tag, &discarding.writer, cell); - break :count discarding.count; - } else 1; - for (0..byte_count) |_| map.map.append(map.alloc, .{ + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try self.writeCell(tag, &discarding.writer, cell); + for (0..discarding.count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, }) catch return error.WriteFailed; @@ -1250,21 +1252,12 @@ pub const PageFormatter = struct { // Cells with only background color (no text). Emit a space // with the appropriate background color SGR sequence. - .bg_color_palette => { - const index = cell.content.color_palette; - try self.emitBgColorSgr(writer, index, null, &style); + .bg_color_palette, .bg_color_rgb => { try writer.writeByte(' '); - if (self.point_map) |*map| { - map.map.append(map.alloc, .{ .x = x, .y = y }) catch return error.WriteFailed; - } - }, - .bg_color_rgb => { - const rgb = cell.content.color_rgb; - try self.emitBgColorSgr(writer, null, rgb, &style); - try writer.writeByte(' '); - if (self.point_map) |*map| { - map.map.append(map.alloc, .{ .x = x, .y = y }) catch return error.WriteFailed; - } + if (self.point_map) |*map| map.map.append( + map.alloc, + .{ .x = x, .y = y }, + ) catch return error.WriteFailed; }, } } @@ -1298,6 +1291,14 @@ pub const PageFormatter = struct { writer: *std.Io.Writer, cell: *const Cell, ) !void { + // Blank cells get an empty space that isn't replaced by anything + // because it isn't really a space. We do this so that formatting + // is preserved if we're emitting styles. + if (!cell.hasText()) { + try writer.writeByte(' '); + return; + } + try self.writeCodepointWithReplacement(writer, cell.content.codepoint); if (comptime tag == .codepoint_grapheme) { for (self.page.lookupGrapheme(cell).?) |cp| { @@ -1381,67 +1382,47 @@ pub const PageFormatter = struct { } } - /// Emit background color SGR sequence for bg_color_* content tags. - /// Updates the style tracking to reflect the emitted background. - fn emitBgColorSgr( - self: PageFormatter, - writer: *std.Io.Writer, - palette_index: ?u8, - rgb: ?Cell.RGB, - style: *Style, - ) std.Io.Writer.Error!void { - switch (self.opts.emit) { - .plain => {}, - .vt => { - // Close previous style if non-default - if (!style.default()) try writer.writeAll("\x1b[0m"); - // Emit background color - if (palette_index) |idx| { - try writer.print("\x1b[48;5;{d}m", .{idx}); - } else if (rgb) |c| { - try writer.print("\x1b[48;2;{d};{d};{d}m", .{ c.r, c.g, c.b }); - } - // Update style tracking - set bg_color so we know to reset later - style.* = .{}; - style.bg_color = if (palette_index) |idx| - .{ .palette = idx } - else if (rgb) |c| - .{ .rgb = .{ .r = c.r, .g = c.g, .b = c.b } } - else - .none; + /// Returns the style for the given cell. If there is no styling this + /// will return the default style. + fn cellStyle( + self: *const PageFormatter, + cell: *const Cell, + ) Style { + return switch (cell.content_tag) { + inline .codepoint, .codepoint_grapheme => if (!cell.hasStyling()) + .{} + else + self.page.styles.get( + self.page.memory, + cell.style_id, + ).*, + + .bg_color_palette => .{ + .bg_color = .{ + .palette = cell.content.color_palette, + }, }, - .html => { - // Close previous tag if needed - if (!style.default()) try writer.writeAll("

"); - // Emit background color as inline style - if (palette_index) |idx| { - try writer.print("
", .{idx}); - } else if (rgb) |c| { - try writer.print("
2}{x:0>2}{x:0>2};\">", .{ c.r, c.g, c.b }); - } - style.* = .{}; - style.bg_color = if (palette_index) |idx| - .{ .palette = idx } - else if (rgb) |c| - .{ .rgb = .{ .r = c.r, .g = c.g, .b = c.b } } - else - .none; + + .bg_color_rgb => .{ + .bg_color = .{ + .rgb = .{ + .r = cell.content.color_rgb.r, + .g = cell.content.color_rgb.g, + .b = cell.content.color_rgb.b, + }, + }, }, - } + }; } fn formatStyleOpen( self: PageFormatter, writer: *std.Io.Writer, style: *const Style, - closing: bool, ) std.Io.Writer.Error!void { switch (self.opts.emit) { .plain => unreachable, - // Note: we don't use closing on purpose because VT sequences - // always reset the prior style. Our formatter always emits a - // \x1b[0m before emitting a new style if necessary. .vt => { var formatter = style.formatterVt(); formatter.palette = self.opts.palette; @@ -1451,7 +1432,6 @@ pub const PageFormatter = struct { // We use `display: inline` so that the div doesn't impact // layout since we're primarily using it as a CSS wrapper. .html => { - if (closing) try writer.writeAll("
"); var formatter = style.formatterHtml(); formatter.palette = self.opts.palette; try writer.print( From 5a7fdf735ed97b2448d6f7f7f2eceb1684e316d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 7 Jan 2026 13:32:54 -0800 Subject: [PATCH 401/605] macos: custom tab title shows bell if active Fixes #10210 --- .../Features/Terminal/BaseTerminalController.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 88d8e39d8..b739e9ed1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -834,7 +834,15 @@ class BaseTerminalController: NSWindowController, private func applyTitleToWindow() { guard let window else { return } - window.title = titleOverride ?? lastComputedTitle + + if let titleOverride { + window.title = computeTitle( + title: titleOverride, + bell: focusedSurface?.bell ?? false) + return + } + + window.title = lastComputedTitle } func pwdDidChange(to: URL?) { From 111b0996d23e997d4b10411b10be04a6067bd9da Mon Sep 17 00:00:00 2001 From: Jagjeevan Kashid Date: Fri, 26 Dec 2025 19:33:50 +0530 Subject: [PATCH 402/605] feat: key-remap configuration to remap modifiers at the app-level Signed-off-by: Jagjeevan Kashid --- src/Surface.zig | 22 ++- src/config/Config.zig | 138 +++++++++++++++++ src/input.zig | 1 + src/input/KeyRemap.zig | 327 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 src/input/KeyRemap.zig diff --git a/src/Surface.zig b/src/Surface.zig index 7e9a307e5..c0c933d1c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -333,6 +333,7 @@ const DerivedConfig = struct { notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction, notify_on_command_finish_after: Duration, + key_remaps: []const input.KeyRemap, const Link = struct { regex: oni.Regex, @@ -408,6 +409,7 @@ const DerivedConfig = struct { .notify_on_command_finish = config.@"notify-on-command-finish", .notify_on_command_finish_action = config.@"notify-on-command-finish-action", .notify_on_command_finish_after = config.@"notify-on-command-finish-after", + .key_remaps = config.@"key-remap".value.items, // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -2576,8 +2578,14 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { /// then Ghosty will act as though the binding does not exist. pub fn keyEventIsBinding( self: *Surface, - event: input.KeyEvent, + event_orig: input.KeyEvent, ) bool { + // Apply key remappings for consistency with keyCallback + var event = event_orig; + if (self.config.key_remaps.len > 0) { + event.mods = input.KeyRemap.applyRemaps(self.config.key_remaps, event_orig.mods); + } + switch (event.action) { .release => return false, .press, .repeat => {}, @@ -2605,9 +2613,16 @@ pub fn keyEventIsBinding( /// sending to the terminal, etc. pub fn keyCallback( self: *Surface, - event: input.KeyEvent, + event_orig: input.KeyEvent, ) !InputEffect { - // log.warn("text keyCallback event={}", .{event}); + // log.warn("text keyCallback event={}", .{event_orig}); + + // Apply key remappings to transform modifiers before any processing. + // This allows users to remap modifier keys at the app level. + var event = event_orig; + if (self.config.key_remaps.len > 0) { + event.mods = input.KeyRemap.applyRemaps(self.config.key_remaps, event_orig.mods); + } // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); @@ -2645,7 +2660,6 @@ pub fn keyCallback( event, if (insp_ev) |*ev| ev else null, )) |v| return v; - // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); diff --git a/src/config/Config.zig b/src/config/Config.zig index d9066b06d..2f0be6ab3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1757,6 +1757,38 @@ class: ?[:0]const u8 = null, /// Key tables are available since Ghostty 1.3.0. keybind: Keybinds = .{}, +/// Remap modifier keys within Ghostty. This allows you to swap or reassign +/// modifier keys at the application level without affecting system-wide +/// settings. +/// +/// The format is `from=to` where both `from` and `to` are modifier key names. +/// You can use generic names like `ctrl`, `alt`, `shift`, `super` (macOS: +/// `cmd`/`command`) or sided names like `left_ctrl`, `right_alt`, etc. +/// +/// Example: +/// +/// key-remap = ctrl=super +/// key-remap = left_control=right_alt +/// +/// Important notes: +/// +/// * This is a one-way remap. If you remap `ctrl=super`, then the physical +/// Ctrl key acts as Super, but the Super key remains Super. +/// +/// * Remaps are not transitive. If you remap `ctrl=super` and `alt=ctrl`, +/// pressing Alt will produce Ctrl, NOT Super. +/// +/// * This affects both keybind matching and terminal input encoding. +/// +/// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. +/// Use sided names (e.g. `left_ctrl`) to remap only one side. +/// +/// This configuration can be repeated to specify multiple remaps. +/// +/// Currently only supported on macOS. Linux/GTK support is planned for +/// a future release. +@"key-remap": RepeatableKeyRemap = .{}, + /// Horizontal window padding. This applies padding between the terminal cells /// and the left and right window borders. The value is in points, meaning that /// it will be scaled appropriately for screen DPI. @@ -8021,6 +8053,112 @@ pub const RepeatableLink = struct { } }; +/// RepeatableKeyRemap is used for the key-remap configuration which +/// allows remapping modifier keys within Ghostty. +pub const RepeatableKeyRemap = struct { + const Self = @This(); + + value: std.ArrayListUnmanaged(inputpkg.KeyRemap) = .empty, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + // Empty/unset input clears the list + const input = input_ orelse ""; + if (input.len == 0) { + self.value.clearRetainingCapacity(); + return; + } + + // Parse the key remap + const remap = inputpkg.KeyRemap.parse(input) catch |err| switch (err) { + error.InvalidFormat => return error.InvalidValue, + error.InvalidModifier => return error.InvalidValue, + }; + + // Reserve space and append + try self.value.ensureUnusedCapacity(alloc, 1); + self.value.appendAssumeCapacity(remap); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ + .value = try self.value.clone(alloc), + }; + } + + /// Compare if two values are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + return true; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + for (self.value.items) |item| { + // Format as "from=to" + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + writer.print("{s}={s}", .{ @tagName(item.from), @tagName(item.to) }) catch + return error.OutOfMemory; + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + } + + test "RepeatableKeyRemap parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableKeyRemap = .{}; + + try list.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + try testing.expectEqual(inputpkg.KeyRemap.ModKey.ctrl, list.value.items[0].from); + try testing.expectEqual(inputpkg.KeyRemap.ModKey.super, list.value.items[0].to); + + try list.parseCLI(alloc, "alt=shift"); + try testing.expectEqual(@as(usize, 2), list.value.items.len); + } + + test "RepeatableKeyRemap parseCLI clear" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableKeyRemap = .{}; + + try list.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 1), list.value.items.len); + + // Empty clears the list + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "RepeatableKeyRemap parseCLI invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableKeyRemap = .{}; + + try testing.expectError(error.InvalidValue, list.parseCLI(alloc, "foo=bar")); + try testing.expectError(error.InvalidValue, list.parseCLI(alloc, "ctrl")); + } +}; + /// Options for copy on select behavior. pub const CopyOnSelect = enum { /// Disables copy on select entirely. diff --git a/src/input.zig b/src/input.zig index be84a60d6..fe981ccc9 100644 --- a/src/input.zig +++ b/src/input.zig @@ -31,6 +31,7 @@ pub const ScrollMods = mouse.ScrollMods; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; pub const Trigger = Binding.Trigger; +pub const KeyRemap = @import("input/KeyRemap.zig"); // Keymap is only available on macOS right now. We could implement it // in theory for XKB too on Linux but we don't need it right now. diff --git a/src/input/KeyRemap.zig b/src/input/KeyRemap.zig new file mode 100644 index 000000000..de13ab70d --- /dev/null +++ b/src/input/KeyRemap.zig @@ -0,0 +1,327 @@ +//! Key remapping support for modifier keys within Ghostty. +//! +//! This module allows users to remap modifier keys (ctrl, alt, shift, super) +//! at the application level without affecting system-wide settings. +//! +//! Syntax: `key-remap = from=to` +//! +//! Examples: +//! key-remap = ctrl=super -- Ctrl acts as Super +//! key-remap = left_alt=ctrl -- Left Alt acts as Ctrl +//! +//! Remapping is one-way and non-transitive: +//! - `ctrl=super` means Ctrl→Super, but Super stays Super +//! - `ctrl=super` + `alt=ctrl` means Alt→Ctrl (NOT Super) + +const KeyRemap = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const key = @import("key.zig"); +const Mods = key.Mods; + +from: ModKey, +to: ModKey, + +pub const ModKey = enum { + ctrl, + alt, + shift, + super, + left_ctrl, + left_alt, + left_shift, + left_super, + right_ctrl, + right_alt, + right_shift, + right_super, + + pub fn isGeneric(self: ModKey) bool { + return switch (self) { + .ctrl, .alt, .shift, .super => true, + else => false, + }; + } + + pub fn parse(input: []const u8) ?ModKey { + const map = std.StaticStringMap(ModKey).initComptime(.{ + .{ "ctrl", .ctrl }, + .{ "control", .ctrl }, + .{ "alt", .alt }, + .{ "opt", .alt }, + .{ "option", .alt }, + .{ "shift", .shift }, + .{ "super", .super }, + .{ "cmd", .super }, + .{ "command", .super }, + .{ "left_ctrl", .left_ctrl }, + .{ "left_control", .left_ctrl }, + .{ "leftctrl", .left_ctrl }, + .{ "leftcontrol", .left_ctrl }, + .{ "left_alt", .left_alt }, + .{ "left_opt", .left_alt }, + .{ "left_option", .left_alt }, + .{ "leftalt", .left_alt }, + .{ "leftopt", .left_alt }, + .{ "leftoption", .left_alt }, + .{ "left_shift", .left_shift }, + .{ "leftshift", .left_shift }, + .{ "left_super", .left_super }, + .{ "left_cmd", .left_super }, + .{ "left_command", .left_super }, + .{ "leftsuper", .left_super }, + .{ "leftcmd", .left_super }, + .{ "leftcommand", .left_super }, + .{ "right_ctrl", .right_ctrl }, + .{ "right_control", .right_ctrl }, + .{ "rightctrl", .right_ctrl }, + .{ "rightcontrol", .right_ctrl }, + .{ "right_alt", .right_alt }, + .{ "right_opt", .right_alt }, + .{ "right_option", .right_alt }, + .{ "rightalt", .right_alt }, + .{ "rightopt", .right_alt }, + .{ "rightoption", .right_alt }, + .{ "right_shift", .right_shift }, + .{ "rightshift", .right_shift }, + .{ "right_super", .right_super }, + .{ "right_cmd", .right_super }, + .{ "right_command", .right_super }, + .{ "rightsuper", .right_super }, + .{ "rightcmd", .right_super }, + .{ "rightcommand", .right_super }, + }); + + var buf: [32]u8 = undefined; + if (input.len > buf.len) return null; + const lower = std.ascii.lowerString(&buf, input); + return map.get(lower); + } +}; + +pub fn parse(input: []const u8) !KeyRemap { + const eql_idx = std.mem.indexOf(u8, input, "=") orelse + return error.InvalidFormat; + + const from_str = std.mem.trim(u8, input[0..eql_idx], " \t"); + const to_str = std.mem.trim(u8, input[eql_idx + 1 ..], " \t"); + + if (from_str.len == 0 or to_str.len == 0) { + return error.InvalidFormat; + } + + const from = ModKey.parse(from_str) orelse return error.InvalidModifier; + const to = ModKey.parse(to_str) orelse return error.InvalidModifier; + + return .{ .from = from, .to = to }; +} + +pub fn apply(self: KeyRemap, mods: Mods) ?Mods { + var result = mods; + var matched = false; + + switch (self.from) { + .ctrl => if (mods.ctrl) { + result.ctrl = false; + matched = true; + }, + .left_ctrl => if (mods.ctrl and mods.sides.ctrl == .left) { + result.ctrl = false; + matched = true; + }, + .right_ctrl => if (mods.ctrl and mods.sides.ctrl == .right) { + result.ctrl = false; + matched = true; + }, + .alt => if (mods.alt) { + result.alt = false; + matched = true; + }, + .left_alt => if (mods.alt and mods.sides.alt == .left) { + result.alt = false; + matched = true; + }, + .right_alt => if (mods.alt and mods.sides.alt == .right) { + result.alt = false; + matched = true; + }, + .shift => if (mods.shift) { + result.shift = false; + matched = true; + }, + .left_shift => if (mods.shift and mods.sides.shift == .left) { + result.shift = false; + matched = true; + }, + .right_shift => if (mods.shift and mods.sides.shift == .right) { + result.shift = false; + matched = true; + }, + .super => if (mods.super) { + result.super = false; + matched = true; + }, + .left_super => if (mods.super and mods.sides.super == .left) { + result.super = false; + matched = true; + }, + .right_super => if (mods.super and mods.sides.super == .right) { + result.super = false; + matched = true; + }, + } + + if (!matched) return null; + + switch (self.to) { + .ctrl, .left_ctrl => { + result.ctrl = true; + result.sides.ctrl = .left; + }, + .right_ctrl => { + result.ctrl = true; + result.sides.ctrl = .right; + }, + .alt, .left_alt => { + result.alt = true; + result.sides.alt = .left; + }, + .right_alt => { + result.alt = true; + result.sides.alt = .right; + }, + .shift, .left_shift => { + result.shift = true; + result.sides.shift = .left; + }, + .right_shift => { + result.shift = true; + result.sides.shift = .right; + }, + .super, .left_super => { + result.super = true; + result.sides.super = .left; + }, + .right_super => { + result.super = true; + result.sides.super = .right; + }, + } + + return result; +} + +/// Apply remaps non-transitively: each remap checks the original mods. +pub fn applyRemaps(remaps: []const KeyRemap, mods: Mods) Mods { + var result = mods; + for (remaps) |remap| { + if (remap.apply(mods)) |_| { + switch (remap.from) { + .ctrl, .left_ctrl, .right_ctrl => result.ctrl = false, + .alt, .left_alt, .right_alt => result.alt = false, + .shift, .left_shift, .right_shift => result.shift = false, + .super, .left_super, .right_super => result.super = false, + } + switch (remap.to) { + .ctrl, .left_ctrl => { + result.ctrl = true; + result.sides.ctrl = .left; + }, + .right_ctrl => { + result.ctrl = true; + result.sides.ctrl = .right; + }, + .alt, .left_alt => { + result.alt = true; + result.sides.alt = .left; + }, + .right_alt => { + result.alt = true; + result.sides.alt = .right; + }, + .shift, .left_shift => { + result.shift = true; + result.sides.shift = .left; + }, + .right_shift => { + result.shift = true; + result.sides.shift = .right; + }, + .super, .left_super => { + result.super = true; + result.sides.super = .left; + }, + .right_super => { + result.super = true; + result.sides.super = .right; + }, + } + } + } + return result; +} + +pub fn clone(self: KeyRemap, alloc: Allocator) Allocator.Error!KeyRemap { + _ = alloc; + return self; +} + +pub fn equal(self: KeyRemap, other: KeyRemap) bool { + return self.from == other.from and self.to == other.to; +} + +test "ModKey.parse" { + const testing = std.testing; + + try testing.expectEqual(ModKey.ctrl, ModKey.parse("ctrl").?); + try testing.expectEqual(ModKey.ctrl, ModKey.parse("control").?); + try testing.expectEqual(ModKey.ctrl, ModKey.parse("CTRL").?); + try testing.expectEqual(ModKey.alt, ModKey.parse("alt").?); + try testing.expectEqual(ModKey.super, ModKey.parse("cmd").?); + try testing.expectEqual(ModKey.left_ctrl, ModKey.parse("left_ctrl").?); + try testing.expectEqual(ModKey.right_alt, ModKey.parse("right_alt").?); + try testing.expect(ModKey.parse("foo") == null); +} + +test "parse" { + const testing = std.testing; + + const remap = try parse("ctrl=super"); + try testing.expectEqual(ModKey.ctrl, remap.from); + try testing.expectEqual(ModKey.super, remap.to); + + const spaced = try parse(" ctrl = super "); + try testing.expectEqual(ModKey.ctrl, spaced.from); + + try testing.expectError(error.InvalidFormat, parse("ctrl")); + try testing.expectError(error.InvalidModifier, parse("foo=bar")); +} + +test "apply" { + const testing = std.testing; + + const remap = try parse("ctrl=super"); + const mods = Mods{ .ctrl = true }; + const result = remap.apply(mods).?; + + try testing.expect(!result.ctrl); + try testing.expect(result.super); + try testing.expect(remap.apply(Mods{ .alt = true }) == null); +} + +test "applyRemaps non-transitive" { + const testing = std.testing; + + const remaps = [_]KeyRemap{ + try parse("ctrl=super"), + try parse("alt=ctrl"), + }; + + const mods = Mods{ .alt = true }; + const result = applyRemaps(&remaps, mods); + + try testing.expect(!result.alt); + try testing.expect(result.ctrl); + try testing.expect(!result.super); +} From 8415d8215b5eafe5cdc6905daf13e365476448c1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 06:52:02 -0800 Subject: [PATCH 403/605] comments --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2f0be6ab3..8a314346d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1765,6 +1765,11 @@ keybind: Keybinds = .{}, /// You can use generic names like `ctrl`, `alt`, `shift`, `super` (macOS: /// `cmd`/`command`) or sided names like `left_ctrl`, `right_alt`, etc. /// +/// This will NOT change keyboard layout or key encodings outside of Ghostty. +/// For example, on macOS, `option+a` may still produce `å` even if `option` is +/// remapped to `ctrl`. Desktop environments usually handle key layout long +/// before Ghostty receives the key events. +/// /// Example: /// /// key-remap = ctrl=super @@ -1779,6 +1784,9 @@ keybind: Keybinds = .{}, /// pressing Alt will produce Ctrl, NOT Super. /// /// * This affects both keybind matching and terminal input encoding. +/// This does NOT impact keyboard layout or how keys are interpreted +/// prior to Ghostty receiving them. For example, `option+a` on macOS +/// may still produce `å` even if `option` is remapped to `ctrl`. /// /// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. /// Use sided names (e.g. `left_ctrl`) to remap only one side. From 619427c84c477ea3de9794b219156a0f410fe568 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 07:02:46 -0800 Subject: [PATCH 404/605] input: move mods out to key_mods.zig --- src/input.zig | 3 +- src/input/Binding.zig | 15 ++-- src/input/key.zig | 157 +--------------------------------- src/input/key_mods.zig | 185 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 166 deletions(-) create mode 100644 src/input/key_mods.zig diff --git a/src/input.zig b/src/input.zig index fe981ccc9..df636c122 100644 --- a/src/input.zig +++ b/src/input.zig @@ -4,6 +4,7 @@ const builtin = @import("builtin"); const config = @import("input/config.zig"); const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); +const key_mods = @import("input/key_mods.zig"); const keyboard = @import("input/keyboard.zig"); pub const command = @import("input/command.zig"); @@ -22,7 +23,7 @@ pub const Key = key.Key; pub const KeyboardLayout = keyboard.Layout; pub const KeyEvent = key.KeyEvent; pub const InspectorMode = Binding.Action.InspectorMode; -pub const Mods = key.Mods; +pub const Mods = key_mods.Mods; pub const MouseButton = mouse.Button; pub const MouseButtonState = mouse.ButtonState; pub const MousePressureStage = mouse.PressureStage; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 08f5fdf7c..3197bb7d1 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -9,6 +9,7 @@ const build_config = @import("../build_config.zig"); const uucode = @import("uucode"); const EntryFormatter = @import("../config/formatter.zig").EntryFormatter; const key = @import("key.zig"); +const key_mods = @import("key_mods.zig"); const KeyEvent = key.KeyEvent; /// The trigger that needs to be performed to execute the action. @@ -1640,18 +1641,12 @@ pub const Trigger = struct { } // Alias modifiers - const alias_mods = .{ - .{ "cmd", "super" }, - .{ "command", "super" }, - .{ "opt", "alt" }, - .{ "option", "alt" }, - .{ "control", "ctrl" }, - }; - inline for (alias_mods) |pair| { + inline for (key_mods.alias) |pair| { if (std.mem.eql(u8, part, pair[0])) { // Repeat not allowed - if (@field(result.mods, pair[1])) return Error.InvalidFormat; - @field(result.mods, pair[1]) = true; + const field = @tagName(pair[1]); + if (@field(result.mods, field)) return Error.InvalidFormat; + @field(result.mods, field) = true; continue :loop; } } diff --git a/src/input/key.zig b/src/input/key.zig index 6445871eb..a929a0323 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -4,6 +4,8 @@ const Allocator = std.mem.Allocator; const cimgui = @import("dcimgui"); const OptionAsAlt = @import("config.zig").OptionAsAlt; +pub const Mods = @import("key_mods.zig").Mods; + /// A generic key input event. This is the information that is necessary /// regardless of apprt in order to generate the proper terminal /// control sequences for a given key press. @@ -76,161 +78,6 @@ pub const KeyEvent = struct { } }; -/// A bitmask for all key modifiers. -/// -/// IMPORTANT: Any changes here update include/ghostty.h -pub const Mods = packed struct(Mods.Backing) { - pub const Backing = u16; - - shift: bool = false, - ctrl: bool = false, - alt: bool = false, - super: bool = false, - caps_lock: bool = false, - num_lock: bool = false, - sides: side = .{}, - _padding: u6 = 0, - - /// Tracks the side that is active for any given modifier. Note - /// that this doesn't confirm a modifier is pressed; you must check - /// the bool for that in addition to this. - /// - /// Not all platforms support this, check apprt for more info. - pub const side = packed struct(u4) { - shift: Side = .left, - ctrl: Side = .left, - alt: Side = .left, - super: Side = .left, - }; - - pub const Side = enum(u1) { left, right }; - - /// Integer value of this struct. - pub fn int(self: Mods) Backing { - return @bitCast(self); - } - - /// Returns true if no modifiers are set. - pub fn empty(self: Mods) bool { - return self.int() == 0; - } - - /// Returns true if two mods are equal. - pub fn equal(self: Mods, other: Mods) bool { - return self.int() == other.int(); - } - - /// Return mods that are only relevant for bindings. - pub fn binding(self: Mods) Mods { - return .{ - .shift = self.shift, - .ctrl = self.ctrl, - .alt = self.alt, - .super = self.super, - }; - } - - /// Perform `self &~ other` to remove the other mods from self. - pub fn unset(self: Mods, other: Mods) Mods { - return @bitCast(self.int() & ~other.int()); - } - - /// Returns the mods without locks set. - pub fn withoutLocks(self: Mods) Mods { - var copy = self; - copy.caps_lock = false; - copy.num_lock = false; - return copy; - } - - /// Return the mods to use for key translation. This handles settings - /// like macos-option-as-alt. The translation mods should be used for - /// translation but never sent back in for the key callback. - pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { - var result = self; - - // macos-option-as-alt for darwin - if (comptime builtin.target.os.tag.isDarwin()) alt: { - // Alt has to be set only on the correct side - switch (option_as_alt) { - .false => break :alt, - .true => {}, - .left => if (self.sides.alt == .right) break :alt, - .right => if (self.sides.alt == .left) break :alt, - } - - // Unset alt - result.alt = false; - } - - return result; - } - - /// Checks to see if super is on (MacOS) or ctrl. - pub fn ctrlOrSuper(self: Mods) bool { - if (comptime builtin.target.os.tag.isDarwin()) { - return self.super; - } - return self.ctrl; - } - - // For our own understanding - test { - const testing = std.testing; - try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); - try testing.expectEqual( - @as(Backing, @bitCast(Mods{ .shift = true })), - @as(Backing, 0b0000_0001), - ); - } - - test "translation macos-option-as-alt" { - if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; - - const testing = std.testing; - - // Unset - { - const mods: Mods = .{}; - const result = mods.translation(.true); - try testing.expectEqual(result, mods); - } - - // Set - { - const mods: Mods = .{ .alt = true }; - const result = mods.translation(.true); - try testing.expectEqual(Mods{}, result); - } - - // Set but disabled - { - const mods: Mods = .{ .alt = true }; - const result = mods.translation(.false); - try testing.expectEqual(result, mods); - } - - // Set wrong side - { - const mods: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; - const result = mods.translation(.left); - try testing.expectEqual(result, mods); - } - { - const mods: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; - const result = mods.translation(.right); - try testing.expectEqual(result, mods); - } - - // Set with other mods - { - const mods: Mods = .{ .alt = true, .shift = true }; - const result = mods.translation(.true); - try testing.expectEqual(Mods{ .shift = true }, result); - } - } -}; - /// The action associated with an input event. This is backed by a c_int /// so that we can use the enum as-is for our embedding API. /// diff --git a/src/input/key_mods.zig b/src/input/key_mods.zig new file mode 100644 index 000000000..885bceda1 --- /dev/null +++ b/src/input/key_mods.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const OptionAsAlt = @import("config.zig").OptionAsAlt; + +/// Aliases for modifier names. +pub const alias: []const struct { []const u8, Mod } = &.{ + .{ "cmd", .super }, + .{ "command", .super }, + .{ "opt", .alt }, + .{ "option", .alt }, + .{ "control", .ctrl }, +}; + +/// Single modifier +pub const Mod = enum { + shift, + ctrl, + alt, + super, + + pub const Side = enum(u1) { left, right }; +}; + +/// A bitmask for all key modifiers. +/// +/// IMPORTANT: Any changes here update include/ghostty.h +pub const Mods = packed struct(Mods.Backing) { + pub const Backing = u16; + + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + caps_lock: bool = false, + num_lock: bool = false, + sides: side = .{}, + _padding: u6 = 0, + + /// Tracks the side that is active for any given modifier. Note + /// that this doesn't confirm a modifier is pressed; you must check + /// the bool for that in addition to this. + /// + /// Not all platforms support this, check apprt for more info. + pub const side = packed struct(u4) { + shift: Mod.Side = .left, + ctrl: Mod.Side = .left, + alt: Mod.Side = .left, + super: Mod.Side = .left, + }; + + /// Integer value of this struct. + pub fn int(self: Mods) Backing { + return @bitCast(self); + } + + /// Returns true if no modifiers are set. + pub fn empty(self: Mods) bool { + return self.int() == 0; + } + + /// Returns true if two mods are equal. + pub fn equal(self: Mods, other: Mods) bool { + return self.int() == other.int(); + } + + /// Return mods that are only relevant for bindings. + pub fn binding(self: Mods) Mods { + return .{ + .shift = self.shift, + .ctrl = self.ctrl, + .alt = self.alt, + .super = self.super, + }; + } + + /// Perform `self &~ other` to remove the other mods from self. + pub fn unset(self: Mods, other: Mods) Mods { + return @bitCast(self.int() & ~other.int()); + } + + /// Returns the mods without locks set. + pub fn withoutLocks(self: Mods) Mods { + var copy = self; + copy.caps_lock = false; + copy.num_lock = false; + return copy; + } + + /// Return the mods to use for key translation. This handles settings + /// like macos-option-as-alt. The translation mods should be used for + /// translation but never sent back in for the key callback. + pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { + var result = self; + + // macos-option-as-alt for darwin + if (comptime builtin.target.os.tag.isDarwin()) alt: { + // Alt has to be set only on the correct side + switch (option_as_alt) { + .false => break :alt, + .true => {}, + .left => if (self.sides.alt == .right) break :alt, + .right => if (self.sides.alt == .left) break :alt, + } + + // Unset alt + result.alt = false; + } + + return result; + } + + /// Checks to see if super is on (MacOS) or ctrl. + pub fn ctrlOrSuper(self: Mods) bool { + if (comptime builtin.target.os.tag.isDarwin()) { + return self.super; + } + return self.ctrl; + } + + // For our own understanding + test { + const testing = std.testing; + try testing.expectEqual(@as(Backing, @bitCast(Mods{})), @as(Backing, 0b0)); + try testing.expectEqual( + @as(Backing, @bitCast(Mods{ .shift = true })), + @as(Backing, 0b0000_0001), + ); + } + + test "translation macos-option-as-alt" { + if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; + + const testing = std.testing; + + // Unset + { + const mods: Mods = .{}; + const result = mods.translation(.true); + try testing.expectEqual(result, mods); + } + + // Set + { + const mods: Mods = .{ .alt = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{}, result); + } + + // Set but disabled + { + const mods: Mods = .{ .alt = true }; + const result = mods.translation(.false); + try testing.expectEqual(result, mods); + } + + // Set wrong side + { + const mods: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; + const result = mods.translation(.left); + try testing.expectEqual(result, mods); + } + { + const mods: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const result = mods.translation(.right); + try testing.expectEqual(result, mods); + } + + // Set with other mods + { + const mods: Mods = .{ .alt = true, .shift = true }; + const result = mods.translation(.true); + try testing.expectEqual(Mods{ .shift = true }, result); + } + } +}; + +/// Modifier remapping. See `key-remap` in Config.zig for detailed docs. +pub const RemapSet = struct { + /// Available mappings. + map: std.ArrayHashMapUnmanaged(Mods, Mods), + + /// The mask of remapped modifiers that can be used to quickly + /// check if some input mods need remapping. + mask: Mods.Backing, +}; From f804a4344e9748bafaf5ad4d4eea4800f941d77d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 08:15:06 -0800 Subject: [PATCH 405/605] input: RemapSet --- src/config/Config.zig | 3 +- src/input.zig | 1 + src/input/key_mods.zig | 424 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 423 insertions(+), 5 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 8a314346d..0d883fddc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -37,6 +37,7 @@ const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); +const KeyRemapSet = @import("key_mods.zig").RemapSet; // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -1789,7 +1790,7 @@ keybind: Keybinds = .{}, /// may still produce `å` even if `option` is remapped to `ctrl`. /// /// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. -/// Use sided names (e.g. `left_ctrl`) to remap only one side. +/// Use sided names (e.g. `left_ctrl`) to remap only one side. /// /// /// This configuration can be repeated to specify multiple remaps. /// diff --git a/src/input.zig b/src/input.zig index df636c122..232e03580 100644 --- a/src/input.zig +++ b/src/input.zig @@ -22,6 +22,7 @@ pub const Link = @import("input/Link.zig"); pub const Key = key.Key; pub const KeyboardLayout = keyboard.Layout; pub const KeyEvent = key.KeyEvent; +pub const KeyRemapSet = key_mods.RemapSet; pub const InspectorMode = Binding.Action.InspectorMode; pub const Mods = key_mods.Mods; pub const MouseButton = mouse.Button; diff --git a/src/input/key_mods.zig b/src/input/key_mods.zig index 885bceda1..885fe357b 100644 --- a/src/input/key_mods.zig +++ b/src/input/key_mods.zig @@ -1,4 +1,6 @@ const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const OptionAsAlt = @import("config.zig").OptionAsAlt; @@ -33,19 +35,46 @@ pub const Mods = packed struct(Mods.Backing) { super: bool = false, caps_lock: bool = false, num_lock: bool = false, - sides: side = .{}, + sides: Side = .{}, _padding: u6 = 0, + /// The standard modifier keys only. Does not include the lock keys, + /// only standard bindable keys. + pub const Keys = packed struct(u4) { + shift: bool = false, + ctrl: bool = false, + alt: bool = false, + super: bool = false, + + pub const Backing = @typeInfo(Keys).@"struct".backing_integer.?; + + pub inline fn int(self: Keys) Keys.Backing { + return @bitCast(self); + } + }; + /// Tracks the side that is active for any given modifier. Note /// that this doesn't confirm a modifier is pressed; you must check /// the bool for that in addition to this. /// /// Not all platforms support this, check apprt for more info. - pub const side = packed struct(u4) { + pub const Side = packed struct(u4) { shift: Mod.Side = .left, ctrl: Mod.Side = .left, alt: Mod.Side = .left, super: Mod.Side = .left, + + pub const Backing = @typeInfo(Side).@"struct".backing_integer.?; + }; + + /// The mask that has all the side bits set. + pub const side_mask: Mods = .{ + .sides = .{ + .shift = .right, + .ctrl = .right, + .alt = .right, + .super = .right, + }, }; /// Integer value of this struct. @@ -63,6 +92,16 @@ pub const Mods = packed struct(Mods.Backing) { return self.int() == other.int(); } + /// Returns only the keys. + /// + /// In the future I want to remove `binding` for this. I didn't want + /// to do that all in one PR where I added this because its a bigger + /// change. + pub fn keys(self: Mods) Keys { + const backing: Keys.Backing = @truncate(self.int()); + return @bitCast(backing); + } + /// Return mods that are only relevant for bindings. pub fn binding(self: Mods) Mods { return .{ @@ -177,9 +216,386 @@ pub const Mods = packed struct(Mods.Backing) { /// Modifier remapping. See `key-remap` in Config.zig for detailed docs. pub const RemapSet = struct { /// Available mappings. - map: std.ArrayHashMapUnmanaged(Mods, Mods), + map: std.AutoArrayHashMapUnmanaged(Mods, Mods), /// The mask of remapped modifiers that can be used to quickly /// check if some input mods need remapping. - mask: Mods.Backing, + mask: Mask, + + pub const empty: RemapSet = .{ + .map = .{}, + .mask = .{}, + }; + + pub const ParseError = Allocator.Error || error{ + MissingAssignment, + InvalidMod, + }; + + pub fn deinit(self: *RemapSet, alloc: Allocator) void { + self.map.deinit(alloc); + } + + /// Parse a modifier remap and add it to the set. + pub fn parse( + self: *RemapSet, + alloc: Allocator, + input: []const u8, + ) ParseError!void { + // Find the assignment point ('=') + const eql_idx = std.mem.indexOfScalar( + u8, + input, + '=', + ) orelse return error.MissingAssignment; + + // The to side defaults to "left" if no explicit side is given. + // This is because this is the default unsided value provided by + // the apprts in the current Mods layout. + const to: Mods = to: { + const raw = try parseMod(input[eql_idx + 1 ..]); + break :to initMods(raw[0], raw[1] orelse .left); + }; + + // The from side, if sided, is easy and we put it directly into + // the map. + const from_raw = try parseMod(input[0..eql_idx]); + if (from_raw[1]) |from_side| { + const from: Mods = initMods(from_raw[0], from_side); + try self.map.put( + alloc, + from, + to, + ); + errdefer comptime unreachable; + self.mask.update(from); + return; + } + + // We need to do some combinatorial explosion here for unsided + // from in order to assign all possible sides. + const from_left = initMods(from_raw[0], .left); + const from_right = initMods(from_raw[0], .right); + try self.map.put( + alloc, + from_left, + to, + ); + errdefer _ = self.map.swapRemove(from_left); + try self.map.put( + alloc, + from_right, + to, + ); + errdefer _ = self.map.swapRemove(from_right); + + errdefer comptime unreachable; + self.mask.update(from_left); + self.mask.update(from_right); + } + + /// Must be called prior to any remappings so that the mapping + /// is sorted properly. Otherwise, you will get invalid results. + pub fn finalize(self: *RemapSet) void { + const Context = struct { + keys: []const Mods, + + pub fn lessThan( + ctx: @This(), + a_index: usize, + b_index: usize, + ) bool { + _ = b_index; + + // Mods with any right sides prioritize + const side_mask = comptime Mods.side_mask.int(); + const a = ctx.keys[a_index]; + return a.int() & side_mask != 0; + } + }; + + self.map.sort(Context{ .keys = self.map.keys() }); + } + + /// Parses a single mode in a single remapping string. E.g. + /// `ctrl` or `left_shift`. + fn parseMod(input: []const u8) error{InvalidMod}!struct { Mod, ?Mod.Side } { + const side_str, const mod_str = if (std.mem.indexOfScalar( + u8, + input, + '_', + )) |idx| .{ + input[0..idx], + input[idx + 1 ..], + } else .{ + "", + input, + }; + + return .{ + std.meta.stringToEnum( + Mod, + mod_str, + ) orelse return error.InvalidMod, + if (side_str.len > 0) std.meta.stringToEnum( + Mod.Side, + side_str, + ) orelse return error.InvalidMod else null, + }; + } + + fn initMods(mod: Mod, side: Mod.Side) Mods { + switch (mod) { + inline else => |tag| { + var mods: Mods = .{}; + @field(mods, @tagName(tag)) = true; + @field(mods.sides, @tagName(tag)) = side; + return mods; + }, + } + } + + /// Returns true if the given mods need remapping. + pub fn isRemapped(self: *const RemapSet, mods: Mods) bool { + return self.mask.match(mods); + } + + /// Apply a remap to the given mods. + pub fn apply(self: *const RemapSet, mods: Mods) Mods { + if (!self.isRemapped(mods)) return mods; + + const mods_binding: Mods.Keys.Backing = @truncate(mods.int()); + const mods_sides: Mods.Side.Backing = @bitCast(mods.sides); + + var it = self.map.iterator(); + while (it.next()) |entry| { + const from = entry.key_ptr.*; + const from_binding: Mods.Keys.Backing = @truncate(from.int()); + if (mods_binding & from_binding != from_binding) continue; + const from_sides: Mods.Side.Backing = @bitCast(from.sides); + if ((mods_sides ^ from_sides) & from_binding != 0) continue; + + var mods_int = mods.int(); + mods_int &= ~from.int(); + mods_int |= entry.value_ptr.int(); + return @bitCast(mods_int); + } + + unreachable; + } + + /// Tracks which modifier keys and sides have remappings registered. + /// Used as a fast pre-check before doing expensive map lookups. + /// + /// The mask uses separate tracking for left and right sides because + /// remappings can be side-specific (e.g., only remap left_ctrl). + /// + /// Note: `left_sides` uses inverted logic where 1 means "left is remapped" + /// even though `Mod.Side.left = 0`. This allows efficient bitwise matching + /// since we can AND directly with the side bits. + pub const Mask = packed struct(u12) { + /// Which modifier keys (shift/ctrl/alt/super) have any remapping. + keys: Mods.Keys = .{}, + /// Which modifiers have left-side remappings (inverted: 1 = left remapped). + left_sides: Mods.Side = .{}, + /// Which modifiers have right-side remappings (1 = right remapped). + right_sides: Mods.Side = .{}, + + /// Adds a modifier to the mask, marking it as having a remapping. + pub fn update(self: *Mask, mods: Mods) void { + const keys_int: Mods.Keys.Backing = mods.keys().int(); + + // OR the new keys into our existing keys mask. + // Example: keys=0b0000, new ctrl → keys=0b0010 + self.keys = @bitCast(self.keys.int() | keys_int); + + // Both Keys and Side are u4 with matching bit positions. + // This lets us use keys_int to select which side bits to update. + const sides: Mods.Side.Backing = @bitCast(mods.sides); + const left_int: Mods.Side.Backing = @bitCast(self.left_sides); + const right_int: Mods.Side.Backing = @bitCast(self.right_sides); + + // Update left_sides: set bit if this key is active AND side is left. + // Since Side.left=0, we invert sides (~sides) so left becomes 1. + // keys_int masks to only affect the modifier being added. + // Example: left_ctrl → keys_int=0b0010, ~sides=0b1111 (left=0 inverted) + // result: left_int | (0b0010 & 0b1111) = left_int | 0b0010 + self.left_sides = @bitCast(left_int | (keys_int & ~sides)); + + // Update right_sides: set bit if this key is active AND side is right. + // Since Side.right=1, we use sides directly. + // Example: right_ctrl → keys_int=0b0010, sides=0b0010 (right=1) + // result: right_int | (0b0010 & 0b0010) = right_int | 0b0010 + self.right_sides = @bitCast(right_int | (keys_int & sides)); + } + + /// Returns true if the given mods match any remapping in this mask. + /// This is a fast check to avoid expensive map lookups when no + /// remapping could possibly apply. + /// + /// Checks both that the modifier key is remapped AND that the + /// specific side (left/right) being pressed has a remapping. + pub fn match(self: *const Mask, mods: Mods) bool { + // Find which pressed keys have remappings registered. + // Example: pressed={ctrl,alt}, mask={ctrl} → active=0b0010 (just ctrl) + const active = mods.keys().int() & self.keys.int(); + if (active == 0) return false; + + // Check if the pressed side matches a remapped side. + // For left (sides bit = 0): check against left_int (where 1 = left remapped) + // ~sides inverts so left becomes 1, then AND with left_int + // For right (sides bit = 1): check against right_int directly + // + // Example: pressing left_ctrl (sides.ctrl=0, left_int.ctrl=1) + // ~sides = 0b1111, left_int = 0b0010 + // (~sides & left_int) = 0b0010 ✓ matches + // + // Example: pressing right_ctrl but only left_ctrl is remapped + // sides = 0b0010, left_int = 0b0010, right_int = 0b0000 + // (~0b0010 & 0b0010) | (0b0010 & 0b0000) = 0b0000 ✗ no match + const sides: Mods.Side.Backing = @bitCast(mods.sides); + const left_int: Mods.Side.Backing = @bitCast(self.left_sides); + const right_int: Mods.Side.Backing = @bitCast(self.right_sides); + const side_match = (~sides & left_int) | (sides & right_int); + + // Final check: is any active (pressed + remapped) key also side-matched? + return (active & side_match) != 0; + } + }; }; + +test "RemapSet: unsided remap creates both left and right mappings" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + try set.parse(alloc, "ctrl=super"); + set.finalize(); + try testing.expectEqual( + Mods{ + .super = true, + .sides = .{ .super = .left }, + }, + set.apply(.{ + .ctrl = true, + .sides = .{ .ctrl = .left }, + }), + ); + try testing.expectEqual( + Mods{ + .super = true, + .sides = .{ .super = .left }, + }, + set.apply(.{ + .ctrl = true, + .sides = .{ .ctrl = .right }, + }), + ); +} + +test "RemapSet: sided from only maps that side" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_alt=ctrl"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl, set.apply(left_alt)); + + const right_alt: Mods = .{ .alt = true, .sides = .{ .alt = .right } }; + try testing.expectEqual(right_alt, set.apply(right_alt)); +} + +test "RemapSet: sided to" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=right_super"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const right_super: Mods = .{ .super = true, .sides = .{ .super = .right } }; + try testing.expectEqual(right_super, set.apply(left_ctrl)); +} + +test "RemapSet: both sides specified" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_shift=right_ctrl"); + set.finalize(); + + const left_shift: Mods = .{ .shift = true, .sides = .{ .shift = .left } }; + const right_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .right } }; + try testing.expectEqual(right_ctrl, set.apply(left_shift)); +} + +test "RemapSet: multiple parses accumulate" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "left_ctrl=super"); + try set.parse(alloc, "left_alt=ctrl"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_ctrl)); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_ctrl_result: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl_result, set.apply(left_alt)); +} + +test "RemapSet: error on missing assignment" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.MissingAssignment, set.parse(alloc, "ctrl")); + try testing.expectError(error.MissingAssignment, set.parse(alloc, "")); +} + +test "RemapSet: error on invalid modifier" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.InvalidMod, set.parse(alloc, "invalid=ctrl")); + try testing.expectError(error.InvalidMod, set.parse(alloc, "ctrl=invalid")); + try testing.expectError(error.InvalidMod, set.parse(alloc, "middle_ctrl=super")); +} + +test "RemapSet: isRemapped checks mask" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=super"); + set.finalize(); + + try testing.expect(set.isRemapped(.{ .ctrl = true })); + try testing.expect(!set.isRemapped(.{ .alt = true })); + try testing.expect(!set.isRemapped(.{ .shift = true })); +} From 5b24aebcab75e52b7c04c507d9a7c0e8785b8855 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 10:18:56 -0800 Subject: [PATCH 406/605] update to use new RemapSet --- src/Surface.zig | 12 +- src/config/Config.zig | 113 +------------- src/input.zig | 1 - src/input/KeyRemap.zig | 327 ----------------------------------------- src/input/key_mods.zig | 223 +++++++++++++++++++++++++++- 5 files changed, 232 insertions(+), 444 deletions(-) delete mode 100644 src/input/KeyRemap.zig diff --git a/src/Surface.zig b/src/Surface.zig index c0c933d1c..cc727826f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -333,7 +333,7 @@ const DerivedConfig = struct { notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction, notify_on_command_finish_after: Duration, - key_remaps: []const input.KeyRemap, + key_remaps: input.KeyRemapSet, const Link = struct { regex: oni.Regex, @@ -409,7 +409,7 @@ const DerivedConfig = struct { .notify_on_command_finish = config.@"notify-on-command-finish", .notify_on_command_finish_action = config.@"notify-on-command-finish-action", .notify_on_command_finish_after = config.@"notify-on-command-finish-after", - .key_remaps = config.@"key-remap".value.items, + .key_remaps = try config.@"key-remap".clone(alloc), // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -2582,8 +2582,8 @@ pub fn keyEventIsBinding( ) bool { // Apply key remappings for consistency with keyCallback var event = event_orig; - if (self.config.key_remaps.len > 0) { - event.mods = input.KeyRemap.applyRemaps(self.config.key_remaps, event_orig.mods); + if (self.config.key_remaps.isRemapped(event_orig.mods)) { + event.mods = self.config.key_remaps.apply(event_orig.mods); } switch (event.action) { @@ -2620,8 +2620,8 @@ pub fn keyCallback( // Apply key remappings to transform modifiers before any processing. // This allows users to remap modifier keys at the app level. var event = event_orig; - if (self.config.key_remaps.len > 0) { - event.mods = input.KeyRemap.applyRemaps(self.config.key_remaps, event_orig.mods); + if (self.config.key_remaps.isRemapped(event_orig.mods)) { + event.mods = self.config.key_remaps.apply(event_orig.mods); } // Crash metadata in case we crash in here diff --git a/src/config/Config.zig b/src/config/Config.zig index 0d883fddc..3ff31ae0e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -37,7 +37,7 @@ const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); -const KeyRemapSet = @import("key_mods.zig").RemapSet; +const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -1796,7 +1796,7 @@ keybind: Keybinds = .{}, /// /// Currently only supported on macOS. Linux/GTK support is planned for /// a future release. -@"key-remap": RepeatableKeyRemap = .{}, +@"key-remap": KeyRemapSet = .empty, /// Horizontal window padding. This applies padding between the terminal cells /// and the left and right window borders. The value is in points, meaning that @@ -4477,6 +4477,9 @@ pub fn finalize(self: *Config) !void { } self.@"faint-opacity" = std.math.clamp(self.@"faint-opacity", 0.0, 1.0); + + // Finalize key remapping set for efficient lookups + self.@"key-remap".finalize(); } /// Callback for src/cli/args.zig to allow us to handle special cases @@ -8062,112 +8065,6 @@ pub const RepeatableLink = struct { } }; -/// RepeatableKeyRemap is used for the key-remap configuration which -/// allows remapping modifier keys within Ghostty. -pub const RepeatableKeyRemap = struct { - const Self = @This(); - - value: std.ArrayListUnmanaged(inputpkg.KeyRemap) = .empty, - - pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { - // Empty/unset input clears the list - const input = input_ orelse ""; - if (input.len == 0) { - self.value.clearRetainingCapacity(); - return; - } - - // Parse the key remap - const remap = inputpkg.KeyRemap.parse(input) catch |err| switch (err) { - error.InvalidFormat => return error.InvalidValue, - error.InvalidModifier => return error.InvalidValue, - }; - - // Reserve space and append - try self.value.ensureUnusedCapacity(alloc, 1); - self.value.appendAssumeCapacity(remap); - } - - /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { - return .{ - .value = try self.value.clone(alloc), - }; - } - - /// Compare if two values are equal. Required by Config. - pub fn equal(self: Self, other: Self) bool { - if (self.value.items.len != other.value.items.len) return false; - for (self.value.items, other.value.items) |a, b| { - if (!a.equal(b)) return false; - } - return true; - } - - /// Used by Formatter - pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { - if (self.value.items.len == 0) { - try formatter.formatEntry(void, {}); - return; - } - - for (self.value.items) |item| { - // Format as "from=to" - var buf: [64]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const writer = fbs.writer(); - writer.print("{s}={s}", .{ @tagName(item.from), @tagName(item.to) }) catch - return error.OutOfMemory; - try formatter.formatEntry([]const u8, fbs.getWritten()); - } - } - - test "RepeatableKeyRemap parseCLI" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: RepeatableKeyRemap = .{}; - - try list.parseCLI(alloc, "ctrl=super"); - try testing.expectEqual(@as(usize, 1), list.value.items.len); - try testing.expectEqual(inputpkg.KeyRemap.ModKey.ctrl, list.value.items[0].from); - try testing.expectEqual(inputpkg.KeyRemap.ModKey.super, list.value.items[0].to); - - try list.parseCLI(alloc, "alt=shift"); - try testing.expectEqual(@as(usize, 2), list.value.items.len); - } - - test "RepeatableKeyRemap parseCLI clear" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: RepeatableKeyRemap = .{}; - - try list.parseCLI(alloc, "ctrl=super"); - try testing.expectEqual(@as(usize, 1), list.value.items.len); - - // Empty clears the list - try list.parseCLI(alloc, ""); - try testing.expectEqual(@as(usize, 0), list.value.items.len); - } - - test "RepeatableKeyRemap parseCLI invalid" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var list: RepeatableKeyRemap = .{}; - - try testing.expectError(error.InvalidValue, list.parseCLI(alloc, "foo=bar")); - try testing.expectError(error.InvalidValue, list.parseCLI(alloc, "ctrl")); - } -}; - /// Options for copy on select behavior. pub const CopyOnSelect = enum { /// Disables copy on select entirely. diff --git a/src/input.zig b/src/input.zig index 232e03580..bad3ac1f3 100644 --- a/src/input.zig +++ b/src/input.zig @@ -33,7 +33,6 @@ pub const ScrollMods = mouse.ScrollMods; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; pub const Trigger = Binding.Trigger; -pub const KeyRemap = @import("input/KeyRemap.zig"); // Keymap is only available on macOS right now. We could implement it // in theory for XKB too on Linux but we don't need it right now. diff --git a/src/input/KeyRemap.zig b/src/input/KeyRemap.zig deleted file mode 100644 index de13ab70d..000000000 --- a/src/input/KeyRemap.zig +++ /dev/null @@ -1,327 +0,0 @@ -//! Key remapping support for modifier keys within Ghostty. -//! -//! This module allows users to remap modifier keys (ctrl, alt, shift, super) -//! at the application level without affecting system-wide settings. -//! -//! Syntax: `key-remap = from=to` -//! -//! Examples: -//! key-remap = ctrl=super -- Ctrl acts as Super -//! key-remap = left_alt=ctrl -- Left Alt acts as Ctrl -//! -//! Remapping is one-way and non-transitive: -//! - `ctrl=super` means Ctrl→Super, but Super stays Super -//! - `ctrl=super` + `alt=ctrl` means Alt→Ctrl (NOT Super) - -const KeyRemap = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const key = @import("key.zig"); -const Mods = key.Mods; - -from: ModKey, -to: ModKey, - -pub const ModKey = enum { - ctrl, - alt, - shift, - super, - left_ctrl, - left_alt, - left_shift, - left_super, - right_ctrl, - right_alt, - right_shift, - right_super, - - pub fn isGeneric(self: ModKey) bool { - return switch (self) { - .ctrl, .alt, .shift, .super => true, - else => false, - }; - } - - pub fn parse(input: []const u8) ?ModKey { - const map = std.StaticStringMap(ModKey).initComptime(.{ - .{ "ctrl", .ctrl }, - .{ "control", .ctrl }, - .{ "alt", .alt }, - .{ "opt", .alt }, - .{ "option", .alt }, - .{ "shift", .shift }, - .{ "super", .super }, - .{ "cmd", .super }, - .{ "command", .super }, - .{ "left_ctrl", .left_ctrl }, - .{ "left_control", .left_ctrl }, - .{ "leftctrl", .left_ctrl }, - .{ "leftcontrol", .left_ctrl }, - .{ "left_alt", .left_alt }, - .{ "left_opt", .left_alt }, - .{ "left_option", .left_alt }, - .{ "leftalt", .left_alt }, - .{ "leftopt", .left_alt }, - .{ "leftoption", .left_alt }, - .{ "left_shift", .left_shift }, - .{ "leftshift", .left_shift }, - .{ "left_super", .left_super }, - .{ "left_cmd", .left_super }, - .{ "left_command", .left_super }, - .{ "leftsuper", .left_super }, - .{ "leftcmd", .left_super }, - .{ "leftcommand", .left_super }, - .{ "right_ctrl", .right_ctrl }, - .{ "right_control", .right_ctrl }, - .{ "rightctrl", .right_ctrl }, - .{ "rightcontrol", .right_ctrl }, - .{ "right_alt", .right_alt }, - .{ "right_opt", .right_alt }, - .{ "right_option", .right_alt }, - .{ "rightalt", .right_alt }, - .{ "rightopt", .right_alt }, - .{ "rightoption", .right_alt }, - .{ "right_shift", .right_shift }, - .{ "rightshift", .right_shift }, - .{ "right_super", .right_super }, - .{ "right_cmd", .right_super }, - .{ "right_command", .right_super }, - .{ "rightsuper", .right_super }, - .{ "rightcmd", .right_super }, - .{ "rightcommand", .right_super }, - }); - - var buf: [32]u8 = undefined; - if (input.len > buf.len) return null; - const lower = std.ascii.lowerString(&buf, input); - return map.get(lower); - } -}; - -pub fn parse(input: []const u8) !KeyRemap { - const eql_idx = std.mem.indexOf(u8, input, "=") orelse - return error.InvalidFormat; - - const from_str = std.mem.trim(u8, input[0..eql_idx], " \t"); - const to_str = std.mem.trim(u8, input[eql_idx + 1 ..], " \t"); - - if (from_str.len == 0 or to_str.len == 0) { - return error.InvalidFormat; - } - - const from = ModKey.parse(from_str) orelse return error.InvalidModifier; - const to = ModKey.parse(to_str) orelse return error.InvalidModifier; - - return .{ .from = from, .to = to }; -} - -pub fn apply(self: KeyRemap, mods: Mods) ?Mods { - var result = mods; - var matched = false; - - switch (self.from) { - .ctrl => if (mods.ctrl) { - result.ctrl = false; - matched = true; - }, - .left_ctrl => if (mods.ctrl and mods.sides.ctrl == .left) { - result.ctrl = false; - matched = true; - }, - .right_ctrl => if (mods.ctrl and mods.sides.ctrl == .right) { - result.ctrl = false; - matched = true; - }, - .alt => if (mods.alt) { - result.alt = false; - matched = true; - }, - .left_alt => if (mods.alt and mods.sides.alt == .left) { - result.alt = false; - matched = true; - }, - .right_alt => if (mods.alt and mods.sides.alt == .right) { - result.alt = false; - matched = true; - }, - .shift => if (mods.shift) { - result.shift = false; - matched = true; - }, - .left_shift => if (mods.shift and mods.sides.shift == .left) { - result.shift = false; - matched = true; - }, - .right_shift => if (mods.shift and mods.sides.shift == .right) { - result.shift = false; - matched = true; - }, - .super => if (mods.super) { - result.super = false; - matched = true; - }, - .left_super => if (mods.super and mods.sides.super == .left) { - result.super = false; - matched = true; - }, - .right_super => if (mods.super and mods.sides.super == .right) { - result.super = false; - matched = true; - }, - } - - if (!matched) return null; - - switch (self.to) { - .ctrl, .left_ctrl => { - result.ctrl = true; - result.sides.ctrl = .left; - }, - .right_ctrl => { - result.ctrl = true; - result.sides.ctrl = .right; - }, - .alt, .left_alt => { - result.alt = true; - result.sides.alt = .left; - }, - .right_alt => { - result.alt = true; - result.sides.alt = .right; - }, - .shift, .left_shift => { - result.shift = true; - result.sides.shift = .left; - }, - .right_shift => { - result.shift = true; - result.sides.shift = .right; - }, - .super, .left_super => { - result.super = true; - result.sides.super = .left; - }, - .right_super => { - result.super = true; - result.sides.super = .right; - }, - } - - return result; -} - -/// Apply remaps non-transitively: each remap checks the original mods. -pub fn applyRemaps(remaps: []const KeyRemap, mods: Mods) Mods { - var result = mods; - for (remaps) |remap| { - if (remap.apply(mods)) |_| { - switch (remap.from) { - .ctrl, .left_ctrl, .right_ctrl => result.ctrl = false, - .alt, .left_alt, .right_alt => result.alt = false, - .shift, .left_shift, .right_shift => result.shift = false, - .super, .left_super, .right_super => result.super = false, - } - switch (remap.to) { - .ctrl, .left_ctrl => { - result.ctrl = true; - result.sides.ctrl = .left; - }, - .right_ctrl => { - result.ctrl = true; - result.sides.ctrl = .right; - }, - .alt, .left_alt => { - result.alt = true; - result.sides.alt = .left; - }, - .right_alt => { - result.alt = true; - result.sides.alt = .right; - }, - .shift, .left_shift => { - result.shift = true; - result.sides.shift = .left; - }, - .right_shift => { - result.shift = true; - result.sides.shift = .right; - }, - .super, .left_super => { - result.super = true; - result.sides.super = .left; - }, - .right_super => { - result.super = true; - result.sides.super = .right; - }, - } - } - } - return result; -} - -pub fn clone(self: KeyRemap, alloc: Allocator) Allocator.Error!KeyRemap { - _ = alloc; - return self; -} - -pub fn equal(self: KeyRemap, other: KeyRemap) bool { - return self.from == other.from and self.to == other.to; -} - -test "ModKey.parse" { - const testing = std.testing; - - try testing.expectEqual(ModKey.ctrl, ModKey.parse("ctrl").?); - try testing.expectEqual(ModKey.ctrl, ModKey.parse("control").?); - try testing.expectEqual(ModKey.ctrl, ModKey.parse("CTRL").?); - try testing.expectEqual(ModKey.alt, ModKey.parse("alt").?); - try testing.expectEqual(ModKey.super, ModKey.parse("cmd").?); - try testing.expectEqual(ModKey.left_ctrl, ModKey.parse("left_ctrl").?); - try testing.expectEqual(ModKey.right_alt, ModKey.parse("right_alt").?); - try testing.expect(ModKey.parse("foo") == null); -} - -test "parse" { - const testing = std.testing; - - const remap = try parse("ctrl=super"); - try testing.expectEqual(ModKey.ctrl, remap.from); - try testing.expectEqual(ModKey.super, remap.to); - - const spaced = try parse(" ctrl = super "); - try testing.expectEqual(ModKey.ctrl, spaced.from); - - try testing.expectError(error.InvalidFormat, parse("ctrl")); - try testing.expectError(error.InvalidModifier, parse("foo=bar")); -} - -test "apply" { - const testing = std.testing; - - const remap = try parse("ctrl=super"); - const mods = Mods{ .ctrl = true }; - const result = remap.apply(mods).?; - - try testing.expect(!result.ctrl); - try testing.expect(result.super); - try testing.expect(remap.apply(Mods{ .alt = true }) == null); -} - -test "applyRemaps non-transitive" { - const testing = std.testing; - - const remaps = [_]KeyRemap{ - try parse("ctrl=super"), - try parse("alt=ctrl"), - }; - - const mods = Mods{ .alt = true }; - const result = applyRemaps(&remaps, mods); - - try testing.expect(!result.alt); - try testing.expect(result.ctrl); - try testing.expect(!result.super); -} diff --git a/src/input/key_mods.zig b/src/input/key_mods.zig index 885fe357b..be759ae1b 100644 --- a/src/input/key_mods.zig +++ b/src/input/key_mods.zig @@ -232,8 +232,21 @@ pub const RemapSet = struct { InvalidMod, }; - pub fn deinit(self: *RemapSet, alloc: Allocator) void { - self.map.deinit(alloc); + /// Parse from CLI input. Required by Config. + pub fn parseCLI(self: *RemapSet, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse ""; + + // Empty value resets the set + if (value.len == 0) { + self.map.clearRetainingCapacity(); + self.mask = .{}; + return; + } + + self.parse(alloc, value) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.MissingAssignment, error.InvalidMod => return error.InvalidValue, + }; } /// Parse a modifier remap and add it to the set. @@ -294,6 +307,10 @@ pub const RemapSet = struct { self.mask.update(from_right); } + pub fn deinit(self: *RemapSet, alloc: Allocator) void { + self.map.deinit(alloc); + } + /// Must be called prior to any remappings so that the mapping /// is sorted properly. Otherwise, you will get invalid results. pub fn finalize(self: *RemapSet) void { @@ -317,6 +334,67 @@ pub const RemapSet = struct { self.map.sort(Context{ .keys = self.map.keys() }); } + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RemapSet, alloc: Allocator) Allocator.Error!RemapSet { + return .{ + .map = try self.map.clone(alloc), + .mask = self.mask, + }; + } + + /// Compare if two RemapSets are equal. Required by Config. + pub fn equal(self: RemapSet, other: RemapSet) bool { + if (self.map.count() != other.map.count()) return false; + + var it = self.map.iterator(); + while (it.next()) |entry| { + const other_value = other.map.get(entry.key_ptr.*) orelse return false; + if (!entry.value_ptr.equal(other_value)) return false; + } + + return true; + } + + /// Used by Formatter. Required by Config. + pub fn formatEntry(self: RemapSet, formatter: anytype) !void { + if (self.map.count() == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var it = self.map.iterator(); + while (it.next()) |entry| { + const from = entry.key_ptr.*; + const to = entry.value_ptr.*; + + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + formatMod(writer, from) catch return error.OutOfMemory; + writer.writeByte('=') catch return error.OutOfMemory; + formatMod(writer, to) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, fbs.getWritten()); + } + } + + fn formatMod(writer: anytype, mods: Mods) !void { + // Check which mod is set and format it with optional side prefix + inline for (.{ "shift", "ctrl", "alt", "super" }) |name| { + if (@field(mods, name)) { + const side = @field(mods.sides, name); + if (side == .right) { + try writer.writeAll("right_"); + } else { + // Only write left_ if we need to distinguish + // For now, always write left_ if it's a sided mapping + try writer.writeAll("left_"); + } + try writer.writeAll(name); + return; + } + } + } + /// Parses a single mode in a single remapping string. E.g. /// `ctrl` or `left_shift`. fn parseMod(input: []const u8) error{InvalidMod}!struct { Mod, ?Mod.Side } { @@ -599,3 +677,144 @@ test "RemapSet: isRemapped checks mask" { try testing.expect(!set.isRemapped(.{ .alt = true })); try testing.expect(!set.isRemapped(.{ .shift = true })); } + +test "RemapSet: clone creates independent copy" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "ctrl=super"); + set.finalize(); + + var cloned = try set.clone(alloc); + defer cloned.deinit(alloc); + + try testing.expect(set.equal(cloned)); + try testing.expect(cloned.isRemapped(.{ .ctrl = true })); +} + +test "RemapSet: equal compares correctly" { + const testing = std.testing; + const alloc = testing.allocator; + + var set1: RemapSet = .empty; + defer set1.deinit(alloc); + + var set2: RemapSet = .empty; + defer set2.deinit(alloc); + + try testing.expect(set1.equal(set2)); + + try set1.parse(alloc, "ctrl=super"); + try testing.expect(!set1.equal(set2)); + + try set2.parse(alloc, "ctrl=super"); + try testing.expect(set1.equal(set2)); + + try set1.parse(alloc, "alt=shift"); + try testing.expect(!set1.equal(set2)); +} + +test "RemapSet: parseCLI basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 2), set.map.count()); +} + +test "RemapSet: parseCLI empty clears" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parseCLI(alloc, "ctrl=super"); + try testing.expectEqual(@as(usize, 2), set.map.count()); + + try set.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), set.map.count()); +} + +test "RemapSet: parseCLI invalid" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "foo=bar")); + try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "ctrl")); +} + +test "RemapSet: formatEntry empty" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + const set: RemapSet = .empty; + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = \n", buf.written()); +} + +test "RemapSet: formatEntry single sided" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "left_ctrl=super"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = left_ctrl=left_super\n", buf.written()); +} + +test "RemapSet: formatEntry unsided creates two entries" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "ctrl=super"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + // Unsided creates both left and right mappings + const written = buf.written(); + try testing.expect(std.mem.indexOf(u8, written, "left_ctrl=left_super") != null); + try testing.expect(std.mem.indexOf(u8, written, "right_ctrl=left_super") != null); +} + +test "RemapSet: formatEntry right sided" { + const testing = std.testing; + const formatterpkg = @import("../config/formatter.zig"); + + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var set: RemapSet = .empty; + defer set.deinit(testing.allocator); + + try set.parse(testing.allocator, "left_alt=right_ctrl"); + set.finalize(); + + try set.formatEntry(formatterpkg.entryFormatter("key-remap", &buf.writer)); + try testing.expectEqualSlices(u8, "key-remap = left_alt=right_ctrl\n", buf.written()); +} From 21d9d89d32b2da0426fcecd6eb3ab3723e648367 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 10:26:45 -0800 Subject: [PATCH 407/605] input: RemapSet should support aliased mods --- src/input/key_mods.zig | 102 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/src/input/key_mods.zig b/src/input/key_mods.zig index be759ae1b..35e1c1038 100644 --- a/src/input/key_mods.zig +++ b/src/input/key_mods.zig @@ -410,11 +410,21 @@ pub const RemapSet = struct { input, }; + const mod: Mod = if (std.meta.stringToEnum( + Mod, + mod_str, + )) |mod| mod else mod: { + inline for (alias) |pair| { + if (std.mem.eql(u8, mod_str, pair[0])) { + break :mod pair[1]; + } + } + + return error.InvalidMod; + }; + return .{ - std.meta.stringToEnum( - Mod, - mod_str, - ) orelse return error.InvalidMod, + mod, if (side_str.len > 0) std.meta.stringToEnum( Mod.Side, side_str, @@ -753,6 +763,90 @@ test "RemapSet: parseCLI invalid" { try testing.expectError(error.InvalidValue, set.parseCLI(alloc, "ctrl")); } +test "RemapSet: parse aliased modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "cmd=ctrl"); + set.finalize(); + + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + try testing.expectEqual(left_ctrl, set.apply(left_super)); +} + +test "RemapSet: parse aliased modifiers command" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "command=alt"); + set.finalize(); + + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + try testing.expectEqual(left_alt, set.apply(left_super)); +} + +test "RemapSet: parse aliased modifiers opt and option" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "opt=super"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_alt)); + + set.deinit(alloc); + set = .empty; + + try set.parse(alloc, "option=shift"); + set.finalize(); + + const left_shift: Mods = .{ .shift = true, .sides = .{ .shift = .left } }; + try testing.expectEqual(left_shift, set.apply(left_alt)); +} + +test "RemapSet: parse aliased modifiers control" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "control=super"); + set.finalize(); + + const left_ctrl: Mods = .{ .ctrl = true, .sides = .{ .ctrl = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_ctrl)); +} + +test "RemapSet: parse aliased modifiers on target side" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: RemapSet = .empty; + defer set.deinit(alloc); + + try set.parse(alloc, "alt=cmd"); + set.finalize(); + + const left_alt: Mods = .{ .alt = true, .sides = .{ .alt = .left } }; + const left_super: Mods = .{ .super = true, .sides = .{ .super = .left } }; + try testing.expectEqual(left_super, set.apply(left_alt)); +} + test "RemapSet: formatEntry empty" { const testing = std.testing; const formatterpkg = @import("../config/formatter.zig"); From a6d36b5e6d8c3dbf8bdb8d57bab8ed893eed11cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 10:45:45 -0800 Subject: [PATCH 408/605] config: add more details to the key-remap feature --- src/config/Config.zig | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 3ff31ae0e..2f0bef6ff 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1790,12 +1790,18 @@ keybind: Keybinds = .{}, /// may still produce `å` even if `option` is remapped to `ctrl`. /// /// * Generic modifiers (e.g. `ctrl`) match both left and right physical keys. -/// Use sided names (e.g. `left_ctrl`) to remap only one side. /// +/// Use sided names (e.g. `left_ctrl`) to remap only one side. +/// +/// There are other edge case scenarios that may not behave as expected +/// but are working as intended the way this feature is designed: +/// +/// * On macOS, bindings in the main menu will trigger before any remapping +/// is done. This is because macOS itself handles menu activation and +/// this happens before Ghostty receives the key event. To workaround +/// this, you should unbind the menu items and rebind them using your +/// desired modifier. /// /// This configuration can be repeated to specify multiple remaps. -/// -/// Currently only supported on macOS. Linux/GTK support is planned for -/// a future release. @"key-remap": KeyRemapSet = .empty, /// Horizontal window padding. This applies padding between the terminal cells From fb1268a9089c3be639241402016842fb9e4c9f0d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 17 Dec 2025 12:57:27 -0600 Subject: [PATCH 409/605] benchmark: add doNotOptimizeAway to OSC benchmark --- src/benchmark/OscParser.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig index 6243aba7d..bd82a3534 100644 --- a/src/benchmark/OscParser.zig +++ b/src/benchmark/OscParser.zig @@ -101,7 +101,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { }; for (osc_buf[0..len]) |c| self.parser.next(c); - _ = self.parser.end(std.ascii.control_code.bel); + std.mem.doNotOptimizeAway(self.parser.end(std.ascii.control_code.bel)); self.parser.reset(); } } From d32a94a06ac3172fc86d0b366a48dbcee853daa6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 17 Dec 2025 17:36:18 -0600 Subject: [PATCH 410/605] core: add new OSC parser This replaces the OSC parser with one that only uses a state machine to determine which OSC is being handled, rather than parsing the whole OSC. Once the OSC command is determined the remainder of the data is stored in a buffer until the terminator is found. The data is then parsed to determine the final OSC command. --- src/terminal/kitty/color.zig | 4 + src/terminal/osc.zig | 2226 +++++++++++++++------------------- 2 files changed, 986 insertions(+), 1244 deletions(-) diff --git a/src/terminal/kitty/color.zig b/src/terminal/kitty/color.zig index deeabcfb7..c1072c390 100644 --- a/src/terminal/kitty/color.zig +++ b/src/terminal/kitty/color.zig @@ -17,6 +17,10 @@ pub const OSC = struct { /// request. terminator: Terminator = .st, + pub fn deinit(self: *OSC, alloc: std.mem.Allocator) void { + self.list.deinit(alloc); + } + /// We don't currently support encoding this to C in any way. pub const C = void; diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index f62b7a6cd..d81244b9f 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -309,170 +309,90 @@ pub const Terminator = enum { }; pub const Parser = struct { + /// Maximum size of a "normal" OSC. + const MAX_BUF = 2048; + /// Optional allocator used to accept data longer than MAX_BUF. /// This only applies to some commands (e.g. OSC 52) that can /// reasonably exceed MAX_BUF. - alloc: ?Allocator, + alloc: ?Allocator = null, /// Current state of the parser. - state: State, + state: State = .start, - /// Current command of the parser, this accumulates. - command: Command, + /// Buffer for temporary storage of OSC data + buffer: [MAX_BUF]u8 = undefined, + /// Fixed writer for accumulating OSC data + fixed: ?std.Io.Writer = null, + /// Allocating writer for accumulating OSC data + allocating: ?std.Io.Writer.Allocating = null, + /// Pointer to the active writer for accumulating OSC data + writer: ?*std.Io.Writer = null, - /// Buffer that stores the input we see for a single OSC command. - /// Slices in Command are offsets into this buffer. - buf: [MAX_BUF]u8, - buf_start: usize, - buf_idx: usize, - buf_dynamic: ?*std.ArrayListUnmanaged(u8), - - /// True when a command is complete/valid to return. - complete: bool, - - /// Temporary state that is dependent on the current state. - temp_state: union { - /// Current string parameter being populated - str: *[:0]const u8, - - /// Current numeric parameter being populated - num: u16, - - /// Temporary state for key/value pairs - key: []const u8, - }, - - // Maximum length of a single OSC command. This is the full OSC command - // sequence length (excluding ESC ]). This is arbitrary, I couldn't find - // any definitive resource on how long this should be. - // - // NOTE: This does mean certain OSC sequences such as OSC 8 (hyperlinks) - // won't work if their parameters are larger than fit in the buffer. - const MAX_BUF = 2048; + /// The command that is the result of parsing. + command: Command = .invalid, pub const State = enum { - empty, + start, invalid, - swallow, - // Command prefixes. We could just accumulate and compare (mem.eql) - // but the state space is small enough that we just build it up this way. + // OSC command prefixes. Not all of these are valid OSCs, but may be + // needed to "bridge" to a valid OSC (e.g. to support OSC 777 we need to + // have a state "77" even though there is no OSC 77). @"0", @"1", + @"2", + @"4", + @"5", + @"7", + @"8", + @"9", @"10", - @"104", @"11", @"12", @"13", - @"133", @"14", @"15", @"16", @"17", @"18", @"19", - @"2", @"21", @"22", - @"4", - @"5", @"52", - @"7", @"77", + @"104", + @"110", + @"111", + @"112", + @"113", + @"114", + @"115", + @"116", + @"117", + @"118", + @"119", + @"133", @"777", - @"8", - @"9", - - // We're in a semantic prompt OSC command but we aren't sure - // what the command is yet, i.e. `133;` - semantic_prompt, - semantic_option_start, - semantic_option_key, - semantic_option_value, - semantic_exit_code_start, - semantic_exit_code, - - // Get/set clipboard states - clipboard_kind, - clipboard_kind_end, - - // OSC color operation. - osc_color, - - // Hyperlinks - hyperlink_param_key, - hyperlink_param_value, - hyperlink_uri, - - // rxvt extension. Only used for OSC 777 and only the value "notify" is - // supported - rxvt_extension, - - // Title of a desktop notification - notification_title, - - // Expect a string parameter. param_str must be set as well as - // buf_start. - string, - - // A string that can grow beyond MAX_BUF. This uses the allocator. - // If the parser has no allocator then it is treated as if the - // buffer is full. - allocable_string, - - // Kitty color protocol - // https://sw.kovidgoyal.net/kitty/color-stack/#id1 - kitty_color_protocol_key, - kitty_color_protocol_value, - - // OSC 9 is used by ConEmu and iTerm2 for different things. - // iTerm2 uses it to post a notification[1]. - // ConEmu uses it to implement many custom functions[2]. - // - // Some Linux applications (namely systemd and flatpak) have - // adopted the ConEmu implementation but this causes bogus - // notifications on iTerm2 compatible terminal emulators. - // - // Ghostty supports both by disallowing ConEmu-specific commands - // from being shown as desktop notifications. - // - // [1]: https://iterm2.com/documentation-escape-codes.html - // [2]: https://conemu.github.io/en/AnsiEscapeCodes.html#OSC_Operating_system_commands - osc_9, - - // ConEmu specific substates - conemu_sleep, - conemu_sleep_value, - conemu_message_box, - conemu_tab, - conemu_tab_txt, - conemu_progress_prestate, - conemu_progress_state, - conemu_progress_prevalue, - conemu_progress_value, - conemu_guimacro, }; pub fn init(alloc: ?Allocator) Parser { var result: Parser = .{ .alloc = alloc, - .state = .empty, + .state = .start, + .fixed = null, + .allocating = null, + .writer = null, .command = .invalid, - .buf_start = 0, - .buf_idx = 0, - .buf_dynamic = null, - .complete = false, // Keeping all our undefined values together so we can // visually easily duplicate them in the Valgrind check below. - .buf = undefined, - .temp_state = undefined, + .buffer = undefined, }; if (std.valgrind.runningOnValgrind() > 0) { // Initialize our undefined fields so Valgrind can catch it. // https://github.com/ziglang/zig/issues/19148 - result.buf = undefined; - result.temp_state = undefined; + result.buffer = undefined; } return result; @@ -485,75 +405,108 @@ pub const Parser = struct { /// Reset the parser state. pub fn reset(self: *Parser) void { - // If the state is already empty then we do nothing because - // we may touch uninitialized memory. - if (self.state == .empty) { - assert(self.buf_start == 0); - assert(self.buf_idx == 0); - assert(!self.complete); - assert(self.buf_dynamic == null); - return; - } + // If we set up an allocating writer, free up that memory. + if (self.allocating) |*allocating| allocating.deinit(); - // Some commands have their own memory management we need to clear. + // Handle any cleanup that individual OSCs require. switch (self.command) { - .kitty_color_protocol => |*v| v.list.deinit(self.alloc.?), - .color_operation => |*v| v.requests.deinit(self.alloc.?), - else => {}, + .kitty_color_protocol => |*v| kitty_color_protocol: { + v.deinit(self.alloc orelse break :kitty_color_protocol); + }, + .change_window_icon, + .change_window_title, + .clipboard_contents, + .color_operation, + .conemu_change_tab_title, + .conemu_guimacro, + .conemu_progress_report, + .conemu_show_message_box, + .conemu_sleep, + .conemu_wait_input, + .end_of_command, + .end_of_input, + .hyperlink_end, + .hyperlink_start, + .invalid, + .mouse_shape, + .prompt_end, + .prompt_start, + .report_pwd, + .show_desktop_notification, + => {}, } - self.state = .empty; - self.buf_start = 0; - self.buf_idx = 0; + self.state = .start; + self.fixed = null; + self.allocating = null; + self.writer = null; self.command = .invalid; - self.complete = false; - if (self.buf_dynamic) |ptr| { - const alloc = self.alloc.?; - ptr.deinit(alloc); - alloc.destroy(ptr); - self.buf_dynamic = null; + + if (std.valgrind.runningOnValgrind() > 0) { + // Initialize our undefined fields so Valgrind can catch it. + // https://github.com/ziglang/zig/issues/19148 + self.buffer = undefined; } } + /// Make sure that we have an allocator. If we don't, set the state to + /// invalid so that any additional OSC data is discarded. + pub inline fn ensureAllocator(self: *Parser) bool { + if (self.alloc != null) return true; + log.warn("An allocator is required to process OSC {t} but none was provided.", .{self.state}); + self.state = .invalid; + return false; + } + + /// Set up a fixed Writer to collect the rest of the OSC data. + pub inline fn writeToFixed(self: *Parser) void { + self.fixed = .fixed(&self.buffer); + self.writer = &self.fixed.?; + } + + /// Set up an allocating Writer to collect the rest of the OSC data. If we + /// don't have an allocator or setting up the allocator fails, fall back to + /// writing to a fixed buffer and hope that it's big enough. + pub inline fn writeToAllocating(self: *Parser) void { + const alloc = self.alloc orelse { + // We don't have an allocator - fall back to a fixed buffer and hope + // that it's big enough. + self.writeToFixed(); + return; + }; + + self.allocating = std.Io.Writer.Allocating.initCapacity(alloc, 2048) catch { + // The allocator failed for some reason, fall back to a fixed buffer + // and hope that it's big enough. + self.writeToFixed(); + return; + }; + + self.writer = &self.allocating.?.writer; + } + /// Consume the next character c and advance the parser state. - pub fn next(self: *Parser, c: u8) void { - // If our buffer is full then we're invalid, so we set our state - // accordingly and indicate the sequence is incomplete so that we - // don't accidentally issue a command when ending. - // - // We always keep space for 1 byte at the end to null-terminate - // values. - if (self.buf_idx >= self.buf.len - 1) { - @branchHint(.cold); - if (self.state != .invalid) { - log.warn( - "OSC sequence too long (> {d}), ignoring. state={}", - .{ self.buf.len, self.state }, - ); - } + pub inline fn next(self: *Parser, c: u8) void { + // If the state becomes invalid for any reason, just discard + // any further input. + if (self.state == .invalid) return; - self.state = .invalid; - - // We have to do this here because it will never reach the - // switch statement below, since our buf_idx will always be - // too high after this. - self.complete = false; + // If a writer has been initialized, we just accumulate the rest of the + // OSC sequence in the writer's buffer and skip the state machine. + if (self.writer) |writer| { + writer.writeByte(c) catch |err| switch (err) { + // We have overflowed our buffer or had some other error, set the + // state to invalid so that we discard any further input. + error.WriteFailed => self.state = .invalid, + }; return; } - // We store everything in the buffer so we can do a better job - // logging if we get to an invalid command. - self.buf[self.buf_idx] = c; - self.buf_idx += 1; - - // log.warn("state = {} c = {x}", .{ self.state, c }); - switch (self.state) { - // If we get something during the invalid state, we've - // ruined our entry. - .invalid => self.complete = false, + // handled above, so should never be here + .invalid => unreachable, - .empty => switch (c) { + .start => switch (c) { '0' => self.state = .@"0", '1' => self.state = .@"1", '2' => self.state = .@"2", @@ -565,27 +518,13 @@ pub const Parser = struct { else => self.state = .invalid, }, - .swallow => {}, - .@"0" => switch (c) { - ';' => { - self.command = .{ .change_window_title = undefined }; - self.complete = true; - self.state = .string; - self.temp_state = .{ .str = &self.command.change_window_title }; - self.buf_start = self.buf_idx; - }, + ';' => self.writeToFixed(), else => self.state = .invalid, }, .@"1" => switch (c) { - ';' => { - self.command = .{ .change_window_icon = undefined }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.change_window_icon }; - self.buf_start = self.buf_idx; - }, + ';' => self.writeToFixed(), '0' => self.state = .@"10", '1' => self.state = .@"11", '2' => self.state = .@"12", @@ -600,390 +539,162 @@ pub const Parser = struct { }, .@"10" => switch (c) { - ';' => osc_10: { - if (self.alloc == null) { - log.warn("OSC 10 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_10; - } - self.command = .{ .color_operation = .{ - .op = .osc_10, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, - '4' => { - self.state = .@"104"; - // If we have an allocator, then we can complete the OSC104 - if (self.alloc != null) self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), + '4' => self.state = .@"104", else => self.state = .invalid, }, .@"104" => switch (c) { - ';' => osc_104: { - if (self.alloc == null) { - log.warn("OSC 104 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_104; - } - self.command = .{ - .color_operation = .{ - .op = .osc_104, - }, - }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"11" => switch (c) { - ';' => osc_11: { - if (self.alloc == null) { - log.warn("OSC 11 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_11; - } - self.command = .{ .color_operation = .{ - .op = .osc_11, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, - '0'...'9' => blk: { - if (self.alloc == null) { - log.warn("OSC 11{c} requires an allocator, but none was provided", .{c}); - self.state = .invalid; - break :blk; - } + ';' => if (self.ensureAllocator()) self.writeToFixed(), + '0' => self.state = .@"110", + '1' => self.state = .@"111", + '2' => self.state = .@"112", + '3' => self.state = .@"113", + '4' => self.state = .@"114", + '5' => self.state = .@"115", + '6' => self.state = .@"116", + '7' => self.state = .@"117", + '8' => self.state = .@"118", + '9' => self.state = .@"119", + else => self.state = .invalid, + }, - self.command = .{ - .color_operation = .{ - .op = switch (c) { - '0' => .osc_110, - '1' => .osc_111, - '2' => .osc_112, - '3' => .osc_113, - '4' => .osc_114, - '5' => .osc_115, - '6' => .osc_116, - '7' => .osc_117, - '8' => .osc_118, - '9' => .osc_119, - else => unreachable, - }, - }, - }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + .@"110" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"111" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"112" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"113" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"114" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"115" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"116" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"117" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"118" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"119" => switch (c) { + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"12" => switch (c) { - ';' => osc_12: { - if (self.alloc == null) { - log.warn("OSC 12 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_12; - } - self.command = .{ .color_operation = .{ - .op = .osc_12, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"13" => switch (c) { - ';' => osc_13: { - if (self.alloc == null) { - log.warn("OSC 13 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_13; - } - self.command = .{ .color_operation = .{ - .op = .osc_13, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), '3' => self.state = .@"133", else => self.state = .invalid, }, .@"133" => switch (c) { - ';' => self.state = .semantic_prompt, + ';' => self.writeToFixed(), else => self.state = .invalid, }, .@"14" => switch (c) { - ';' => osc_14: { - if (self.alloc == null) { - log.warn("OSC 14 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_14; - } - self.command = .{ .color_operation = .{ - .op = .osc_14, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"15" => switch (c) { - ';' => osc_15: { - if (self.alloc == null) { - log.warn("OSC 15 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_15; - } - self.command = .{ .color_operation = .{ - .op = .osc_15, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"16" => switch (c) { - ';' => osc_16: { - if (self.alloc == null) { - log.warn("OSC 16 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_16; - } - self.command = .{ .color_operation = .{ - .op = .osc_16, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"17" => switch (c) { - ';' => osc_17: { - if (self.alloc == null) { - log.warn("OSC 17 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_17; - } - self.command = .{ .color_operation = .{ - .op = .osc_17, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"18" => switch (c) { - ';' => osc_18: { - if (self.alloc == null) { - log.warn("OSC 18 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_18; - } - self.command = .{ .color_operation = .{ - .op = .osc_18, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"19" => switch (c) { - ';' => osc_19: { - if (self.alloc == null) { - log.warn("OSC 19 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_19; - } - self.command = .{ .color_operation = .{ - .op = .osc_19, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, - .osc_color => {}, - .@"2" => switch (c) { + ';' => self.writeToFixed(), '1' => self.state = .@"21", '2' => self.state = .@"22", - ';' => { - self.command = .{ .change_window_title = undefined }; - self.complete = true; - self.state = .string; - self.temp_state = .{ .str = &self.command.change_window_title }; - self.buf_start = self.buf_idx; - }, else => self.state = .invalid, }, .@"21" => switch (c) { - ';' => kitty: { - if (self.alloc == null) { - log.info("OSC 21 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :kitty; - } - - self.command = .{ - .kitty_color_protocol = .{ - .list = .empty, - }, - }; - - self.temp_state = .{ .key = "" }; - self.state = .kitty_color_protocol_key; - self.complete = true; - self.buf_start = self.buf_idx; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, - .kitty_color_protocol_key => switch (c) { - ';' => { - self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; - self.endKittyColorProtocolOption(.key_only, false); - self.state = .kitty_color_protocol_key; - self.buf_start = self.buf_idx; - }, - '=' => { - self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; - self.state = .kitty_color_protocol_value; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .kitty_color_protocol_value => switch (c) { - ';' => { - self.endKittyColorProtocolOption(.key_and_value, false); - self.state = .kitty_color_protocol_key; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - .@"22" => switch (c) { - ';' => { - self.command = .{ .mouse_shape = undefined }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.mouse_shape.value }; - self.buf_start = self.buf_idx; - }, + ';' => self.writeToFixed(), else => self.state = .invalid, }, .@"4" => switch (c) { - ';' => osc_4: { - if (self.alloc == null) { - log.info("OSC 4 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_4; - } - self.command = .{ - .color_operation = .{ - .op = .osc_4, - }, - }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, .@"5" => switch (c) { - ';' => osc_5: { - if (self.alloc == null) { - log.info("OSC 5 requires an allocator, but none was provided", .{}); - self.state = .invalid; - break :osc_5; - } - self.command = .{ - .color_operation = .{ - .op = .osc_5, - }, - }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.complete = true; - }, + ';' => if (self.ensureAllocator()) self.writeToFixed(), '2' => self.state = .@"52", else => self.state = .invalid, }, .@"52" => switch (c) { - ';' => { - self.command = .{ .clipboard_contents = undefined }; - self.state = .clipboard_kind; - }, - else => self.state = .invalid, - }, - - .clipboard_kind => switch (c) { - ';' => { - self.command.clipboard_contents.kind = 'c'; - self.temp_state = .{ .str = &self.command.clipboard_contents.data }; - self.buf_start = self.buf_idx; - self.prepAllocableString(); - - // See clipboard_kind_end - self.complete = true; - }, - else => { - self.command.clipboard_contents.kind = c; - self.state = .clipboard_kind_end; - }, - }, - - .clipboard_kind_end => switch (c) { - ';' => { - self.temp_state = .{ .str = &self.command.clipboard_contents.data }; - self.buf_start = self.buf_idx; - self.prepAllocableString(); - - // OSC 52 can have empty payloads (quoting xterm ctlseqs): - // "If the second parameter is neither a base64 string nor ?, - // then the selection is cleared." - self.complete = true; - }, + ';' => self.writeToAllocating(), else => self.state = .invalid, }, .@"7" => switch (c) { - ';' => { - self.command = .{ .report_pwd = .{ .value = "" } }; - self.complete = true; - self.state = .string; - self.temp_state = .{ .str = &self.command.report_pwd.value }; - self.buf_start = self.buf_idx; - }, + ';' => self.writeToFixed(), '7' => self.state = .@"77", else => self.state = .invalid, }, @@ -994,710 +705,22 @@ pub const Parser = struct { }, .@"777" => switch (c) { - ';' => { - self.state = .rxvt_extension; - self.buf_start = self.buf_idx; - }, + ';' => self.writeToFixed(), else => self.state = .invalid, }, .@"8" => switch (c) { - ';' => { - self.command = .{ .hyperlink_start = .{ - .uri = "", - } }; - - self.state = .hyperlink_param_key; - self.buf_start = self.buf_idx; - }, + ';' => self.writeToFixed(), else => self.state = .invalid, }, - .hyperlink_param_key => switch (c) { - ';' => { - self.complete = true; - self.state = .hyperlink_uri; - self.buf_start = self.buf_idx; - }, - '=' => { - self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; - self.state = .hyperlink_param_value; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .hyperlink_param_value => switch (c) { - ':' => { - self.endHyperlinkOptionValue(); - self.state = .hyperlink_param_key; - self.buf_start = self.buf_idx; - }, - ';' => { - self.endHyperlinkOptionValue(); - self.state = .string; - self.temp_state = .{ .str = &self.command.hyperlink_start.uri }; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .hyperlink_uri => {}, - - .rxvt_extension => switch (c) { - 'a'...'z' => {}, - ';' => { - const ext = self.buf[self.buf_start .. self.buf_idx - 1]; - if (!std.mem.eql(u8, ext, "notify")) { - @branchHint(.cold); - log.warn("unknown rxvt extension: {s}", .{ext}); - self.state = .invalid; - return; - } - - self.command = .{ .show_desktop_notification = undefined }; - self.buf_start = self.buf_idx; - self.state = .notification_title; - }, - else => self.state = .invalid, - }, - - .notification_title => switch (c) { - ';' => { - self.buf[self.buf_idx - 1] = 0; - self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1 :0]; - self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; - self.buf_start = self.buf_idx; - self.state = .string; - }, - else => {}, - }, - .@"9" => switch (c) { - ';' => { - self.buf_start = self.buf_idx; - self.state = .osc_9; - }, + ';' => self.writeToFixed(), else => self.state = .invalid, }, - - .osc_9 => switch (c) { - '1' => { - self.state = .conemu_sleep; - // This will end up being either a ConEmu sleep OSC 9;1, - // or a desktop notification OSC 9 that begins with '1', so - // mark as complete. - self.complete = true; - }, - '2' => { - self.state = .conemu_message_box; - // This will end up being either a ConEmu message box OSC 9;2, - // or a desktop notification OSC 9 that begins with '2', so - // mark as complete. - self.complete = true; - }, - '3' => { - self.state = .conemu_tab; - // This will end up being either a ConEmu message box OSC 9;3, - // or a desktop notification OSC 9 that begins with '3', so - // mark as complete. - self.complete = true; - }, - '4' => { - self.state = .conemu_progress_prestate; - // This will end up being either a ConEmu progress report - // OSC 9;4, or a desktop notification OSC 9 that begins with - // '4', so mark as complete. - self.complete = true; - }, - '5' => { - // Note that sending an OSC 9 desktop notification that - // starts with 5 is impossible due to this. - self.state = .swallow; - self.command = .conemu_wait_input; - self.complete = true; - }, - '6' => { - self.state = .conemu_guimacro; - // This will end up being either a ConEmu GUI macro OSC 9;6, - // or a desktop notification OSC 9 that begins with '6', so - // mark as complete. - self.complete = true; - }, - - // Todo: parse out other ConEmu operating system commands. Even - // if we don't support them we probably don't want them showing - // up as desktop notifications. - - else => self.showDesktopNotification(), - }, - - .conemu_sleep => switch (c) { - ';' => { - self.command = .{ .conemu_sleep = .{ .duration_ms = 100 } }; - self.buf_start = self.buf_idx; - self.complete = true; - self.state = .conemu_sleep_value; - }, - - // OSC 9;1 is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .conemu_sleep_value => switch (c) { - else => self.complete = true, - }, - - .conemu_message_box => switch (c) { - ';' => { - self.command = .{ .conemu_show_message_box = undefined }; - self.temp_state = .{ .str = &self.command.conemu_show_message_box }; - self.buf_start = self.buf_idx; - self.complete = true; - self.prepAllocableString(); - }, - - // OSC 9;2 is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .conemu_tab => switch (c) { - ';' => { - self.state = .conemu_tab_txt; - self.command = .{ .conemu_change_tab_title = .reset }; - self.buf_start = self.buf_idx; - self.complete = true; - }, - - // OSC 9;3 is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .conemu_tab_txt => { - self.command = .{ .conemu_change_tab_title = .{ .value = undefined } }; - self.temp_state = .{ .str = &self.command.conemu_change_tab_title.value }; - self.complete = true; - self.prepAllocableString(); - }, - - .conemu_progress_prestate => switch (c) { - ';' => { - self.command = .{ .conemu_progress_report = .{ - .state = undefined, - } }; - self.state = .conemu_progress_state; - }, - - // OSC 9;4 is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .conemu_progress_state => switch (c) { - '0' => { - self.command.conemu_progress_report.state = .remove; - self.state = .swallow; - self.complete = true; - }, - '1' => { - self.command.conemu_progress_report.state = .set; - self.command.conemu_progress_report.progress = 0; - self.state = .conemu_progress_prevalue; - }, - '2' => { - self.command.conemu_progress_report.state = .@"error"; - self.complete = true; - self.state = .conemu_progress_prevalue; - }, - '3' => { - self.command.conemu_progress_report.state = .indeterminate; - self.complete = true; - self.state = .swallow; - }, - '4' => { - self.command.conemu_progress_report.state = .pause; - self.complete = true; - self.state = .conemu_progress_prevalue; - }, - - // OSC 9;4; is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .conemu_progress_prevalue => switch (c) { - ';' => { - self.state = .conemu_progress_value; - }, - - // OSC 9;4;<0-4> is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .conemu_progress_value => switch (c) { - '0'...'9' => value: { - // No matter what substate we're in, a number indicates - // a completed ConEmu progress command. - self.complete = true; - - // If we aren't a set substate, then we don't care - // about the value. - const p = &self.command.conemu_progress_report; - switch (p.state) { - .remove, - .indeterminate, - => break :value, - .set, - .@"error", - .pause, - => {}, - } - - if (p.state == .set) - assert(p.progress != null) - else if (p.progress == null) - p.progress = 0; - - // If we're over 100% we're done. - if (p.progress.? >= 100) break :value; - - // If we're over 10 then any new digit forces us to - // be 100. - if (p.progress.? >= 10) - p.progress = 100 - else { - const d = std.fmt.charToDigit(c, 10) catch 0; - p.progress = @min(100, (p.progress.? * 10) + d); - } - }, - - else => { - self.state = .swallow; - self.complete = true; - }, - }, - - .conemu_guimacro => switch (c) { - ';' => { - self.command = .{ .conemu_guimacro = undefined }; - self.temp_state = .{ .str = &self.command.conemu_guimacro }; - self.buf_start = self.buf_idx; - self.state = .string; - self.complete = true; - }, - - // OSC 9;6 is a desktop - // notification. - else => self.showDesktopNotification(), - }, - - .semantic_prompt => switch (c) { - 'A' => { - self.state = .semantic_option_start; - self.command = .{ .prompt_start = .{} }; - self.complete = true; - }, - - 'B' => { - self.state = .semantic_option_start; - self.command = .{ .prompt_end = {} }; - self.complete = true; - }, - - 'C' => { - self.state = .semantic_option_start; - self.command = .{ .end_of_input = .{} }; - self.complete = true; - }, - - 'D' => { - self.state = .semantic_exit_code_start; - self.command = .{ .end_of_command = .{} }; - self.complete = true; - }, - - else => self.state = .invalid, - }, - - .semantic_option_start => switch (c) { - ';' => { - self.state = .semantic_option_key; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .semantic_option_key => switch (c) { - '=' => { - self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; - self.state = .semantic_option_value; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .semantic_option_value => switch (c) { - ';' => { - self.endSemanticOptionValue(); - self.state = .semantic_option_key; - self.buf_start = self.buf_idx; - }, - else => {}, - }, - - .semantic_exit_code_start => switch (c) { - ';' => { - // No longer complete, if ';' shows up we expect some code. - self.complete = false; - self.state = .semantic_exit_code; - self.temp_state = .{ .num = 0 }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .semantic_exit_code => switch (c) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { - self.complete = true; - - const idx = self.buf_idx - self.buf_start; - if (idx > 0) self.temp_state.num *|= 10; - self.temp_state.num +|= c - '0'; - }, - ';' => { - self.endSemanticExitCode(); - self.state = .semantic_option_key; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - - .allocable_string => { - const alloc = self.alloc.?; - const list = self.buf_dynamic.?; - list.append(alloc, c) catch { - self.state = .invalid; - return; - }; - - // Never consume buffer space for allocable strings - self.buf_idx -= 1; - - // We can complete at any time - self.complete = true; - }, - - .string => self.complete = true, } } - fn showDesktopNotification(self: *Parser) void { - self.command = .{ .show_desktop_notification = .{ - .title = "", - .body = undefined, - } }; - - self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; - self.state = .string; - // Set as complete as we've already seen one character that should be - // part of the notification. If we wait for another character to set - // `complete` when the state is `.string` we won't be able to send any - // single character notifications. - self.complete = true; - } - - fn prepAllocableString(self: *Parser) void { - assert(self.buf_dynamic == null); - - // We need an allocator. If we don't have an allocator, we - // pretend we're just a fixed buffer string and hope we fit! - const alloc = self.alloc orelse { - self.state = .string; - return; - }; - - // Allocate our dynamic buffer - const list = alloc.create(std.ArrayListUnmanaged(u8)) catch { - self.state = .string; - return; - }; - list.* = .{}; - - self.buf_dynamic = list; - self.state = .allocable_string; - } - - fn endHyperlink(self: *Parser) void { - switch (self.command) { - .hyperlink_start => |*v| { - self.buf[self.buf_idx] = 0; - const value = self.buf[self.buf_start..self.buf_idx :0]; - if (v.id == null and value.len == 0) { - self.command = .{ .hyperlink_end = {} }; - return; - } - - v.uri = value; - }, - - else => unreachable, - } - } - - fn endHyperlinkOptionValue(self: *Parser) void { - const value: [:0]const u8 = if (self.buf_start == self.buf_idx) - "" - else buf: { - self.buf[self.buf_idx - 1] = 0; - break :buf self.buf[self.buf_start .. self.buf_idx - 1 :0]; - }; - - if (mem.eql(u8, self.temp_state.key, "id")) { - switch (self.command) { - .hyperlink_start => |*v| { - // We treat empty IDs as null ids so that we can - // auto-assign. - if (value.len > 0) v.id = value; - }, - else => {}, - } - } else log.info("unknown hyperlink option: {s}", .{self.temp_state.key}); - } - - fn endSemanticOptionValue(self: *Parser) void { - const value = value: { - self.buf[self.buf_idx] = 0; - defer self.buf_idx += 1; - break :value self.buf[self.buf_start..self.buf_idx :0]; - }; - - if (mem.eql(u8, self.temp_state.key, "aid")) { - switch (self.command) { - .prompt_start => |*v| v.aid = value, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "cmdline")) { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - switch (self.command) { - .end_of_input => |*v| v.cmdline = string_encoding.printfQDecode(value) catch null, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "cmdline_url")) { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - switch (self.command) { - .end_of_input => |*v| v.cmdline = string_encoding.urlPercentDecode(value) catch null, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "redraw")) { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - switch (self.command) { - .prompt_start => |*v| { - const valid = if (value.len == 1) valid: { - switch (value[0]) { - '0' => v.redraw = false, - '1' => v.redraw = true, - else => break :valid false, - } - - break :valid true; - } else false; - - if (!valid) { - log.info("OSC 133 A invalid redraw value: {s}", .{value}); - } - }, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "special_key")) { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - switch (self.command) { - .prompt_start => |*v| { - const valid = if (value.len == 1) valid: { - switch (value[0]) { - '0' => v.special_key = false, - '1' => v.special_key = true, - else => break :valid false, - } - - break :valid true; - } else false; - - if (!valid) { - log.info("OSC 133 A invalid special_key value: {s}", .{value}); - } - }, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "click_events")) { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - switch (self.command) { - .prompt_start => |*v| { - const valid = if (value.len == 1) valid: { - switch (value[0]) { - '0' => v.click_events = false, - '1' => v.click_events = true, - else => break :valid false, - } - - break :valid true; - } else false; - - if (!valid) { - log.info("OSC 133 A invalid click_events value: {s}", .{value}); - } - }, - else => {}, - } - } else if (mem.eql(u8, self.temp_state.key, "k")) { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // The "k" marks the kind of prompt, or "primary" if we don't know. - // This can be used to distinguish between the first (initial) prompt, - // a continuation, etc. - switch (self.command) { - .prompt_start => |*v| if (value.len == 1) { - v.kind = switch (value[0]) { - 'c' => .continuation, - 's' => .secondary, - 'r' => .right, - 'i' => .primary, - else => .primary, - }; - }, - else => {}, - } - } else log.info("unknown semantic prompts option: {s}", .{self.temp_state.key}); - } - - fn endSemanticExitCode(self: *Parser) void { - switch (self.command) { - .end_of_command => |*v| v.exit_code = @truncate(self.temp_state.num), - else => {}, - } - } - - fn endString(self: *Parser) void { - self.buf[self.buf_idx] = 0; - defer self.buf_idx += 1; - self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx :0]; - } - - fn endConEmuSleepValue(self: *Parser) void { - switch (self.command) { - .conemu_sleep => |*v| v.duration_ms = value: { - const str = self.buf[self.buf_start..self.buf_idx]; - if (str.len == 0) break :value 100; - - if (std.fmt.parseUnsigned(u16, str, 10)) |num| { - break :value @min(num, 10_000); - } else |_| { - break :value 100; - } - }, - else => {}, - } - } - - fn endKittyColorProtocolOption(self: *Parser, kind: enum { key_only, key_and_value }, final: bool) void { - if (self.temp_state.key.len == 0) { - @branchHint(.cold); - log.warn("zero length key in kitty color protocol", .{}); - return; - } - - const key = kitty_color.Kind.parse(self.temp_state.key) orelse { - @branchHint(.cold); - log.warn("unknown key in kitty color protocol: {s}", .{self.temp_state.key}); - return; - }; - - const value = value: { - if (self.buf_start == self.buf_idx) break :value ""; - if (final) break :value std.mem.trim(u8, self.buf[self.buf_start..self.buf_idx], " "); - break :value std.mem.trim(u8, self.buf[self.buf_start .. self.buf_idx - 1], " "); - }; - - switch (self.command) { - .kitty_color_protocol => |*v| { - // Cap our allocation amount for our list. - if (v.list.items.len >= @as(usize, kitty_color.Kind.max) * 2) { - @branchHint(.cold); - self.state = .invalid; - log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); - return; - } - - // Asserted when the command is set to kitty_color_protocol - // that we have an allocator. - const alloc = self.alloc.?; - - if (kind == .key_only or value.len == 0) { - v.list.append(alloc, .{ .reset = key }) catch |err| { - @branchHint(.cold); - log.warn("unable to append kitty color protocol option: {}", .{err}); - return; - }; - } else if (mem.eql(u8, "?", value)) { - v.list.append(alloc, .{ .query = key }) catch |err| { - @branchHint(.cold); - log.warn("unable to append kitty color protocol option: {}", .{err}); - return; - }; - } else { - v.list.append(alloc, .{ - .set = .{ - .key = key, - .color = RGB.parse(value) catch |err| switch (err) { - error.InvalidFormat => { - log.warn("invalid color format in kitty color protocol: {s}", .{value}); - return; - }, - }, - }, - }) catch |err| { - @branchHint(.cold); - log.warn("unable to append kitty color protocol option: {}", .{err}); - return; - }; - } - }, - else => {}, - } - } - - fn endOscColor(self: *Parser) void { - const alloc = self.alloc.?; - assert(self.command == .color_operation); - const data = self.buf[self.buf_start..self.buf_idx]; - self.command.color_operation.requests = osc_color.parse( - alloc, - self.command.color_operation.op, - data, - ) catch |err| list: { - log.info( - "failed to parse OSC color request err={} data={s}", - .{ err, data }, - ); - break :list .{}; - }; - } - - fn endAllocableString(self: *Parser) void { - const alloc = self.alloc.?; - const list = self.buf_dynamic.?; - list.append(alloc, 0) catch { - @branchHint(.cold); - log.warn("allocation failed on allocable string termination", .{}); - self.temp_state.str.* = ""; - return; - }; - - self.temp_state.str.* = list.items[0 .. list.items.len - 1 :0]; - } - /// 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 @@ -1706,64 +729,767 @@ pub const Parser = struct { /// The returned pointer is only valid until the next call to the parser. /// Callers should copy out any data they wish to retain across calls. pub fn end(self: *Parser, terminator_ch: ?u8) ?*Command { - if (!self.complete) { - if (comptime !builtin.is_test) log.warn( - "invalid OSC command: {s}", - .{self.buf[0..self.buf_idx]}, - ); + return switch (self.state) { + .start => null, + .invalid => null, + .@"0" => self.parseChangeWindowTitle(terminator_ch), + .@"1" => self.parseChangeWindowIcon(terminator_ch), + .@"2" => self.parseChangeWindowTitle(terminator_ch), + .@"4" => self.parseOscColor(terminator_ch), + .@"5" => self.parseOscColor(terminator_ch), + .@"7" => self.parseReportPwd(terminator_ch), + .@"8" => self.parseHyperlink(terminator_ch), + .@"9" => self.parseOsc9(terminator_ch), + .@"10" => self.parseOscColor(terminator_ch), + .@"11" => self.parseOscColor(terminator_ch), + .@"12" => self.parseOscColor(terminator_ch), + .@"13" => self.parseOscColor(terminator_ch), + .@"14" => self.parseOscColor(terminator_ch), + .@"15" => self.parseOscColor(terminator_ch), + .@"16" => self.parseOscColor(terminator_ch), + .@"17" => self.parseOscColor(terminator_ch), + .@"18" => self.parseOscColor(terminator_ch), + .@"19" => self.parseOscColor(terminator_ch), + .@"21" => self.parseKittyColorProtocol(terminator_ch), + .@"22" => self.parseMouseShape(terminator_ch), + .@"52" => self.parseClipboardOperation(terminator_ch), + .@"77" => null, + .@"104" => self.parseOscColor(terminator_ch), + .@"110" => self.parseOscColor(terminator_ch), + .@"111" => self.parseOscColor(terminator_ch), + .@"112" => self.parseOscColor(terminator_ch), + .@"113" => self.parseOscColor(terminator_ch), + .@"114" => self.parseOscColor(terminator_ch), + .@"115" => self.parseOscColor(terminator_ch), + .@"116" => self.parseOscColor(terminator_ch), + .@"117" => self.parseOscColor(terminator_ch), + .@"118" => self.parseOscColor(terminator_ch), + .@"119" => self.parseOscColor(terminator_ch), + .@"133" => self.parseSemanticPrompt(terminator_ch), + .@"777" => self.parseRxvtExtension(terminator_ch), + }; + } + + /// Parse OSC 0 and OSC 2 + fn parseChangeWindowTitle(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + self.command = .{ + .change_window_title = data[0 .. data.len - 1 :0], + }; + return &self.command; + } + + /// Parse OSC 1 + fn parseChangeWindowIcon(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + self.command = .{ + .change_window_icon = data[0 .. data.len - 1 :0], + }; + return &self.command; + } + + /// Parse OSCs 4, 5, 10-19, 104, 110-119 + fn parseOscColor(self: *Parser, terminator_ch: ?u8) ?*Command { + const alloc = self.alloc orelse { + self.state = .invalid; + return null; + }; + // If we've collected any extra data parse that, otherwise use an empty + // string. + const data = data: { + const writer = self.writer orelse break :data ""; + break :data writer.buffered(); + }; + // Check and make sure that we're parsing the correct OSCs + const op: osc_color.Operation = switch (self.state) { + .@"4" => .osc_4, + .@"5" => .osc_5, + .@"10" => .osc_10, + .@"11" => .osc_11, + .@"12" => .osc_12, + .@"13" => .osc_13, + .@"14" => .osc_14, + .@"15" => .osc_15, + .@"16" => .osc_16, + .@"17" => .osc_17, + .@"18" => .osc_18, + .@"19" => .osc_19, + .@"104" => .osc_104, + .@"110" => .osc_110, + .@"111" => .osc_111, + .@"112" => .osc_112, + .@"113" => .osc_113, + .@"114" => .osc_114, + .@"115" => .osc_115, + .@"116" => .osc_116, + .@"117" => .osc_117, + .@"118" => .osc_118, + .@"119" => .osc_119, + else => { + self.state = .invalid; + return null; + }, + }; + self.command = .{ + .color_operation = .{ + .op = op, + .requests = osc_color.parse(alloc, op, data) catch |err| list: { + log.info( + "failed to parse OSC {t} color request err={} data={s}", + .{ self.state, err, data }, + ); + break :list .{}; + }, + .terminator = .init(terminator_ch), + }, + }; + return &self.command; + } + + /// Parse OSC 7 + fn parseReportPwd(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + self.command = .{ + .report_pwd = .{ + .value = data[0 .. data.len - 1 :0], + }, + }; + return &self.command; + } + + /// Parse OSC 8 hyperlinks + fn parseHyperlink(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + const s = std.mem.indexOfScalar(u8, data, ';') orelse { + self.state = .invalid; + return null; + }; + + self.command = .{ + .hyperlink_start = .{ + .uri = data[s + 1 .. data.len - 1 :0], + }, + }; + + data[s] = 0; + const kvs = data[0 .. s + 1]; + std.mem.replaceScalar(u8, kvs, ':', 0); + var kv_start: usize = 0; + while (kv_start < kvs.len) { + const kv_end = std.mem.indexOfScalarPos(u8, kvs, kv_start + 1, 0) orelse break; + const kv = data[kv_start .. kv_end + 1]; + const v = std.mem.indexOfScalar(u8, kv, '=') orelse break; + const key = kv[0..v]; + const value = kv[v + 1 .. kv.len - 1 :0]; + if (std.mem.eql(u8, key, "id")) { + if (value.len > 0) self.command.hyperlink_start.id = value; + } else { + log.warn("unknown hyperlink option: '{s}'", .{key}); + } + kv_start = kv_end + 1; + } + + if (self.command.hyperlink_start.uri.len == 0) { + if (self.command.hyperlink_start.id != null) { + self.state = .invalid; + return null; + } + self.command = .hyperlink_end; + } + + return &self.command; + } + + /// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension. + fn parseOsc9(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + + // Check first to see if this is a ConEmu OSC + // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC + conemu: { + var data = writer.buffered(); + if (data.len == 0) break :conemu; + switch (data[0]) { + // Check for OSC 9;1 9;10 9;12 + '1' => { + if (data.len < 2) break :conemu; + switch (data[1]) { + // OSC 9;1 + ';' => { + self.command = .{ + .conemu_sleep = .{ + .duration_ms = if (std.fmt.parseUnsigned(u16, data[2..], 10)) |num| @min(num, 10_000) else |_| 100, + }, + }; + return &self.command; + }, + // OSC 9;10 + '0' => { + self.state = .invalid; + return null; + }, + // OSC 9;12 + '2' => { + self.command = .{ + .prompt_start = .{}, + }; + return &self.command; + }, + else => break :conemu, + } + }, + // OSC 9;2 + '2' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + data = writer.buffered(); + self.command = .{ + .conemu_show_message_box = data[2 .. data.len - 1 :0], + }; + return &self.command; + }, + // OSC 9;3 + '3' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + if (data.len == 2) { + self.command = .{ + .conemu_change_tab_title = .reset, + }; + return &self.command; + } + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + data = writer.buffered(); + self.command = .{ + .conemu_change_tab_title = .{ + .value = data[2 .. data.len - 1 :0], + }, + }; + return &self.command; + }, + // OSC 9;4 + '4' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + if (data.len < 3) break :conemu; + switch (data[2]) { + '0' => { + self.command = .{ + .conemu_progress_report = .{ + .state = .remove, + }, + }; + }, + '1' => { + self.command = .{ + .conemu_progress_report = .{ + .state = .set, + .progress = 0, + }, + }; + }, + '2' => { + self.command = .{ + .conemu_progress_report = .{ + .state = .@"error", + }, + }; + }, + '3' => { + self.command = .{ + .conemu_progress_report = .{ + .state = .indeterminate, + }, + }; + }, + '4' => { + self.command = .{ + .conemu_progress_report = .{ + .state = .pause, + }, + }; + }, + else => break :conemu, + } + switch (self.command.conemu_progress_report.state) { + .remove, .indeterminate => {}, + .set, .@"error", .pause => progress: { + if (data.len < 4) break :progress; + if (data[3] != ';') break :progress; + // parse the progress value + self.command.conemu_progress_report.progress = value: { + break :value @intCast(std.math.clamp( + std.fmt.parseUnsigned(usize, data[4..], 10) catch break :value null, + 0, + 100, + )); + }; + }, + } + return &self.command; + }, + // OSC 9;5 + '5' => { + self.command = .conemu_wait_input; + return &self.command; + }, + // OSC 9;6 + '6' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + data = writer.buffered(); + self.command = .{ + .conemu_guimacro = data[2 .. data.len - 1 :0], + }; + return &self.command; + }, + // OSC 9;7 + '7' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + self.state = .invalid; + return null; + }, + // OSC 9;8 + '8' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + self.state = .invalid; + return null; + }, + // OSC 9;9 + '9' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + self.state = .invalid; + return null; + }, + else => break :conemu, + } + } + + // If it's not a ConEmu OSC, it's an iTerm2 notification + + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + self.command = .{ + .show_desktop_notification = .{ + .title = "", + .body = data[0 .. data.len - 1 :0], + }, + }; + return &self.command; + } + + /// Parse OSC 21, the Kitty Color Protocol. + fn parseKittyColorProtocol(self: *Parser, terminator_ch: ?u8) ?*Command { + assert(self.state == .@"21"); + const alloc = self.alloc orelse { + self.state = .invalid; + return null; + }; + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + self.command = .{ + .kitty_color_protocol = .{ + .list = .empty, + .terminator = .init(terminator_ch), + }, + }; + const list = &self.command.kitty_color_protocol.list; + const data = writer.buffered(); + var kv_it = std.mem.splitScalar(u8, data, ';'); + while (kv_it.next()) |kv| { + if (list.items.len >= @as(usize, kitty_color.Kind.max) * 2) { + log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); + self.state = .invalid; + return null; + } + var it = std.mem.splitScalar(u8, kv, '='); + const k = it.next() orelse continue; + if (k.len == 0) { + log.warn("zero length key in kitty color protocol", .{}); + continue; + } + const key = kitty_color.Kind.parse(k) orelse { + log.warn("unknown key in kitty color protocol: {s}", .{k}); + continue; + }; + const value = std.mem.trim(u8, it.rest(), " "); + if (value.len == 0) { + list.append(alloc, .{ .reset = key }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + continue; + }; + } else if (mem.eql(u8, "?", value)) { + list.append(alloc, .{ .query = key }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + continue; + }; + } else { + list.append(alloc, .{ + .set = .{ + .key = key, + .color = RGB.parse(value) catch |err| switch (err) { + error.InvalidFormat => { + log.warn("invalid color format in kitty color protocol: {s}", .{value}); + continue; + }, + }, + }, + }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + continue; + }; + } + } + return &self.command; + } + + // Parse OSC 22 + fn parseMouseShape(self: *Parser, _: ?u8) ?*Command { + assert(self.state == .@"22"); + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + self.command = .{ + .mouse_shape = .{ + .value = data[0 .. data.len - 1 :0], + }, + }; + return &self.command; + } + + /// Parse OSC 52 + fn parseClipboardOperation(self: *Parser, _: ?u8) ?*Command { + assert(self.state == .@"52"); + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + if (data.len == 1) { + self.state = .invalid; return null; } + if (data[0] == ';') { + self.command = .{ + .clipboard_contents = .{ + .kind = 'c', + .data = data[1 .. data.len - 1 :0], + }, + }; + } else { + if (data.len < 2) { + self.state = .invalid; + return null; + } + if (data[1] != ';') { + self.state = .invalid; + return null; + } + self.command = .{ + .clipboard_contents = .{ + .kind = data[0], + .data = data[2 .. data.len - 1 :0], + }, + }; + } + return &self.command; + } - // Other cleanup we may have to do depending on state. - switch (self.state) { - .allocable_string => self.endAllocableString(), - .semantic_exit_code => self.endSemanticExitCode(), - .semantic_option_value => self.endSemanticOptionValue(), - .hyperlink_uri => self.endHyperlink(), - .string => self.endString(), - .conemu_sleep_value => self.endConEmuSleepValue(), - .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), - .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), - .osc_color => self.endOscColor(), - - // 104 abruptly ended turns into a reset palette command. - .@"104" => { - self.command = .{ .color_operation = .{ - .op = .osc_104, - } }; - self.state = .osc_color; - self.buf_start = self.buf_idx; - self.endOscColor(); + /// Parse OSC 133, semantic prompts + fn parseSemanticPrompt(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + if (data.len == 0) { + self.state = .invalid; + return null; + } + switch (data[0]) { + 'A' => prompt_start: { + self.command = .{ + .prompt_start = .{}, + }; + if (data.len == 1) break :prompt_start; + if (data[1] != ';') { + self.state = .invalid; + return null; + } + var it = SemanticPromptKVIterator.init(writer) catch { + self.state = .invalid; + return null; + }; + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key, "aid")) { + self.command.prompt_start.aid = kv.value; + } else if (std.mem.eql(u8, kv.key, "redraw")) redraw: { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + // Kitty supports a "redraw" option for prompt_start. I can't find + // this documented anywhere but can see in the code that this is used + // by shell environments to tell the terminal that the shell will NOT + // redraw the prompt so we should attempt to resize it. + self.command.prompt_start.redraw = (value: { + if (kv.value.len != 1) break :value null; + switch (kv.value[0]) { + '0' => break :value false, + '1' => break :value true, + else => break :value null, + } + }) orelse { + log.info("OSC 133 A: invalid redraw value: {s}", .{kv.value}); + break :redraw; + }; + } else if (std.mem.eql(u8, kv.key, "special_key")) redraw: { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + self.command.prompt_start.special_key = (value: { + if (kv.value.len != 1) break :value null; + switch (kv.value[0]) { + '0' => break :value false, + '1' => break :value true, + else => break :value null, + } + }) orelse { + log.info("OSC 133 A invalid special_key value: {s}", .{kv.value}); + break :redraw; + }; + } else if (std.mem.eql(u8, kv.key, "click_events")) redraw: { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + self.command.prompt_start.click_events = (value: { + if (kv.value.len != 1) break :value null; + switch (kv.value[0]) { + '0' => break :value false, + '1' => break :value true, + else => break :value null, + } + }) orelse { + log.info("OSC 133 A invalid click_events value: {s}", .{kv.value}); + break :redraw; + }; + } else if (std.mem.eql(u8, kv.key, "k")) k: { + // The "k" marks the kind of prompt, or "primary" if we don't know. + // This can be used to distinguish between the first (initial) prompt, + // a continuation, etc. + if (kv.value.len != 1) break :k; + self.command.prompt_start.kind = switch (kv.value[0]) { + 'c' => .continuation, + 's' => .secondary, + 'r' => .right, + 'i' => .primary, + else => .primary, + }; + } else log.info("OSC 133 A: unknown semantic prompt option: {s}", .{kv.key}); + } }, - - // We received OSC 9;X ST, but nothing else, finish off as a - // desktop notification with "X" as the body. - .conemu_sleep, - .conemu_message_box, - .conemu_tab, - .conemu_progress_prestate, - .conemu_progress_state, - .conemu_guimacro, - => { - self.showDesktopNotification(); - self.endString(); + 'B' => prompt_end: { + self.command = .prompt_end; + if (data.len == 1) break :prompt_end; + if (data[1] != ';') { + self.state = .invalid; + return null; + } + var it = SemanticPromptKVIterator.init(writer) catch { + self.state = .invalid; + return null; + }; + while (it.next()) |kv| { + log.info("OSC 133 B: unknown semantic prompt option: {s}", .{kv.key}); + } }, + 'C' => end_of_input: { + self.command = .{ + .end_of_input = .{}, + }; + if (data.len == 1) break :end_of_input; + if (data[1] != ';') { + self.state = .invalid; + return null; + } + var it = SemanticPromptKVIterator.init(writer) catch { + self.state = .invalid; + return null; + }; + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key, "cmdline")) { + self.command.end_of_input.cmdline = string_encoding.printfQDecode(kv.value) catch null; + } else if (std.mem.eql(u8, kv.key, "cmdline_url")) { + self.command.end_of_input.cmdline = string_encoding.urlPercentDecode(kv.value) catch null; + } else { + log.info("OSC 133 C: unknown semantic prompt option: {s}", .{kv.key}); + } + } + }, + 'D' => { + const exit_code: ?u8 = exit_code: { + if (data.len == 1) break :exit_code null; + if (data[1] != ';') { + self.state = .invalid; + return null; + } + break :exit_code std.fmt.parseUnsigned(u8, data[2..], 10) catch null; + }; + self.command = .{ + .end_of_command = .{ + .exit_code = exit_code, + }, + }; + }, + else => { + self.state = .invalid; + return null; + }, + } + return &self.command; + } - // A ConEmu progress report that has reached these states is - // complete, don't do anything to them. - .conemu_progress_prevalue, - .conemu_progress_value, - => {}, + const SemanticPromptKVIterator = struct { + index: usize, + string: []u8, - else => {}, + pub const SemanticPromptKV = struct { + key: [:0]u8, + value: [:0]u8, + }; + + pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!SemanticPromptKVIterator { + // add a semicolon to make it easier to find and sentinel terminate the values + try writer.writeByte(';'); + return .{ + .index = 0, + .string = writer.buffered()[2..], + }; } - switch (self.command) { - .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch), - .color_operation => |*c| c.terminator = .init(terminator_ch), - else => {}, - } + pub fn next(self: *SemanticPromptKVIterator) ?SemanticPromptKV { + if (self.index >= self.string.len) return null; + const kv = kv: { + const index = std.mem.indexOfScalarPos(u8, self.string, self.index, ';') orelse { + self.index = self.string.len; + return null; + }; + self.string[index] = 0; + const kv = self.string[self.index..index :0]; + self.index = index + 1; + break :kv kv; + }; + + const key = key: { + const index = std.mem.indexOfScalar(u8, kv, '=') orelse break :key kv; + kv[index] = 0; + const key = kv[0..index :0]; + break :key key; + }; + + const value = kv[key.len + 1 .. :0]; + + return .{ + .key = key, + .value = value, + }; + } + }; + + /// Parse OSC 777 + fn parseRxvtExtension(self: *Parser, _: ?u8) ?*Command { + const writer = self.writer orelse { + self.state = .invalid; + return null; + }; + // ensure that we are sentinel terminated + writer.writeByte(0) catch { + self.state = .invalid; + return null; + }; + const data = writer.buffered(); + const k = std.mem.indexOfScalar(u8, data, ';') orelse { + self.state = .invalid; + return null; + }; + const ext = data[0..k]; + if (!std.mem.eql(u8, ext, "notify")) { + log.warn("unknown rxvt extension: {s}", .{ext}); + self.state = .invalid; + return null; + } + const t = std.mem.indexOfScalarPos(u8, data, k + 1, ';') orelse { + log.warn("rxvt notify extension is missing the title", .{}); + self.state = .invalid; + return null; + }; + data[t] = 0; + const title = data[k + 1 .. t :0]; + const body = data[t + 1 .. data.len - 1 :0]; + self.command = .{ + .show_desktop_notification = .{ + .title = title, + .body = body, + }, + }; return &self.command; } }; @@ -1794,7 +1520,6 @@ test "OSC 0: longer than buffer" { for (input) |ch| p.next(ch); try testing.expect(p.end(null) == null); - try testing.expect(p.complete == false); } test "OSC 0: one shorter than buffer length" { @@ -1803,7 +1528,7 @@ test "OSC 0: one shorter than buffer length" { var p: Parser = .init(null); const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); + const title = "a" ** (Parser.MAX_BUF - 1); const input = prefix ++ title; for (input) |ch| p.next(ch); @@ -1818,13 +1543,12 @@ test "OSC 0: exactly at buffer length" { var p: Parser = .init(null); const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - prefix.len); + const title = "a" ** Parser.MAX_BUF; const input = prefix ++ title; for (input) |ch| p.next(ch); // This should be null because we always reserve space for a null terminator. try testing.expect(p.end(null) == null); - try testing.expect(p.complete == false); } test "OSC 1: change_window_icon" { @@ -2731,7 +2455,7 @@ test "OSC 21: kitty color protocol reset after invalid" { p.reset(); - try testing.expectEqual(Parser.State.empty, p.state); + try testing.expectEqual(Parser.State.start, p.state); p.next('X'); try testing.expectEqual(Parser.State.invalid, p.state); @@ -2874,6 +2598,20 @@ test "OSC 133: prompt_start with single option" { try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); } +test "OSC 133: prompt_start with '=' in aid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=a=b;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("a=b", cmd.prompt_start.aid.?); + try testing.expect(!cmd.prompt_start.redraw); +} + test "OSC 133: prompt_start with redraw disabled" { const testing = std.testing; From 2805c1e405b8b3e0e96a468b1014107e1b51eac1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 7 Jan 2026 12:36:23 -0600 Subject: [PATCH 411/605] osc: collapse switch cases --- src/terminal/osc.zig | 209 ++++++++++++++----------------------------- 1 file changed, 67 insertions(+), 142 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index d81244b9f..571d123d8 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -518,11 +518,6 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"0" => switch (c) { - ';' => self.writeToFixed(), - else => self.state = .invalid, - }, - .@"1" => switch (c) { ';' => self.writeToFixed(), '0' => self.state = .@"10", @@ -564,57 +559,26 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"110" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"111" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"112" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"113" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"114" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"115" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"116" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"117" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"118" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"119" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"12" => switch (c) { + .@"4", + .@"12", + .@"14", + .@"15", + .@"16", + .@"17", + .@"18", + .@"19", + .@"21", + .@"110", + .@"111", + .@"112", + .@"113", + .@"114", + .@"115", + .@"116", + .@"117", + .@"118", + .@"119", + => switch (c) { ';' => if (self.ensureAllocator()) self.writeToFixed(), else => self.state = .invalid, }, @@ -625,41 +589,6 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"133" => switch (c) { - ';' => self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"14" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"15" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"16" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"17" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"18" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"19" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - .@"2" => switch (c) { ';' => self.writeToFixed(), '1' => self.state = .@"21", @@ -667,21 +596,6 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"21" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"22" => switch (c) { - ';' => self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"4" => switch (c) { - ';' => if (self.ensureAllocator()) self.writeToFixed(), - else => self.state = .invalid, - }, - .@"5" => switch (c) { ';' => if (self.ensureAllocator()) self.writeToFixed(), '2' => self.state = .@"52", @@ -704,17 +618,13 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"777" => switch (c) { - ';' => self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"8" => switch (c) { - ';' => self.writeToFixed(), - else => self.state = .invalid, - }, - - .@"9" => switch (c) { + .@"0", + .@"133", + .@"22", + .@"777", + .@"8", + .@"9", + => switch (c) { ';' => self.writeToFixed(), else => self.state = .invalid, }, @@ -731,41 +641,56 @@ pub const Parser = struct { pub fn end(self: *Parser, terminator_ch: ?u8) ?*Command { return switch (self.state) { .start => null, + .invalid => null, - .@"0" => self.parseChangeWindowTitle(terminator_ch), + + .@"0", + .@"2", + => self.parseChangeWindowTitle(terminator_ch), + .@"1" => self.parseChangeWindowIcon(terminator_ch), - .@"2" => self.parseChangeWindowTitle(terminator_ch), - .@"4" => self.parseOscColor(terminator_ch), - .@"5" => self.parseOscColor(terminator_ch), + + .@"4", + .@"5", + .@"10", + .@"11", + .@"12", + .@"13", + .@"14", + .@"15", + .@"16", + .@"17", + .@"18", + .@"19", + .@"104", + .@"110", + .@"111", + .@"112", + .@"113", + .@"114", + .@"115", + .@"116", + .@"117", + .@"118", + .@"119", + => self.parseOscColor(terminator_ch), + .@"7" => self.parseReportPwd(terminator_ch), + .@"8" => self.parseHyperlink(terminator_ch), + .@"9" => self.parseOsc9(terminator_ch), - .@"10" => self.parseOscColor(terminator_ch), - .@"11" => self.parseOscColor(terminator_ch), - .@"12" => self.parseOscColor(terminator_ch), - .@"13" => self.parseOscColor(terminator_ch), - .@"14" => self.parseOscColor(terminator_ch), - .@"15" => self.parseOscColor(terminator_ch), - .@"16" => self.parseOscColor(terminator_ch), - .@"17" => self.parseOscColor(terminator_ch), - .@"18" => self.parseOscColor(terminator_ch), - .@"19" => self.parseOscColor(terminator_ch), + .@"21" => self.parseKittyColorProtocol(terminator_ch), + .@"22" => self.parseMouseShape(terminator_ch), + .@"52" => self.parseClipboardOperation(terminator_ch), + .@"77" => null, - .@"104" => self.parseOscColor(terminator_ch), - .@"110" => self.parseOscColor(terminator_ch), - .@"111" => self.parseOscColor(terminator_ch), - .@"112" => self.parseOscColor(terminator_ch), - .@"113" => self.parseOscColor(terminator_ch), - .@"114" => self.parseOscColor(terminator_ch), - .@"115" => self.parseOscColor(terminator_ch), - .@"116" => self.parseOscColor(terminator_ch), - .@"117" => self.parseOscColor(terminator_ch), - .@"118" => self.parseOscColor(terminator_ch), - .@"119" => self.parseOscColor(terminator_ch), + .@"133" => self.parseSemanticPrompt(terminator_ch), + .@"777" => self.parseRxvtExtension(terminator_ch), }; } From 0b9b17cbe0c14a7f4ce56840b5708af9563ebbea Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 8 Jan 2026 13:40:21 -0600 Subject: [PATCH 412/605] osc: remove pub from internal parser functions --- src/terminal/osc.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 571d123d8..dd31224e2 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -451,7 +451,7 @@ pub const Parser = struct { /// Make sure that we have an allocator. If we don't, set the state to /// invalid so that any additional OSC data is discarded. - pub inline fn ensureAllocator(self: *Parser) bool { + inline fn ensureAllocator(self: *Parser) bool { if (self.alloc != null) return true; log.warn("An allocator is required to process OSC {t} but none was provided.", .{self.state}); self.state = .invalid; @@ -459,7 +459,7 @@ pub const Parser = struct { } /// Set up a fixed Writer to collect the rest of the OSC data. - pub inline fn writeToFixed(self: *Parser) void { + inline fn writeToFixed(self: *Parser) void { self.fixed = .fixed(&self.buffer); self.writer = &self.fixed.?; } @@ -467,7 +467,7 @@ pub const Parser = struct { /// Set up an allocating Writer to collect the rest of the OSC data. If we /// don't have an allocator or setting up the allocator fails, fall back to /// writing to a fixed buffer and hope that it's big enough. - pub inline fn writeToAllocating(self: *Parser) void { + inline fn writeToAllocating(self: *Parser) void { const alloc = self.alloc orelse { // We don't have an allocator - fall back to a fixed buffer and hope // that it's big enough. From 6ee1b3998e55fe66584b968aab342b422d8bb502 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 8 Jan 2026 13:50:33 -0600 Subject: [PATCH 413/605] osc: no defaults on Parser fields --- src/terminal/osc.zig | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index dd31224e2..64b409cdd 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -315,22 +315,22 @@ pub const Parser = struct { /// Optional allocator used to accept data longer than MAX_BUF. /// This only applies to some commands (e.g. OSC 52) that can /// reasonably exceed MAX_BUF. - alloc: ?Allocator = null, + alloc: ?Allocator, /// Current state of the parser. - state: State = .start, + state: State, /// Buffer for temporary storage of OSC data - buffer: [MAX_BUF]u8 = undefined, + buffer: [MAX_BUF]u8, /// Fixed writer for accumulating OSC data - fixed: ?std.Io.Writer = null, + fixed: ?std.Io.Writer, /// Allocating writer for accumulating OSC data - allocating: ?std.Io.Writer.Allocating = null, + allocating: ?std.Io.Writer.Allocating, /// Pointer to the active writer for accumulating OSC data - writer: ?*std.Io.Writer = null, + writer: ?*std.Io.Writer, /// The command that is the result of parsing. - command: Command = .invalid, + command: Command, pub const State = enum { start, From f180f1c9b8b5e4f1fca505ace5485b13762bef32 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 8 Jan 2026 14:12:16 -0600 Subject: [PATCH 414/605] osc: remove inline from Parser.next --- src/benchmark/OscParser.zig | 2 +- src/terminal/Parser.zig | 2 +- src/terminal/osc.zig | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig index bd82a3534..d4b416de8 100644 --- a/src/benchmark/OscParser.zig +++ b/src/benchmark/OscParser.zig @@ -100,7 +100,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { error.ReadFailed => return error.BenchmarkFailed, }; - for (osc_buf[0..len]) |c| self.parser.next(c); + for (osc_buf[0..len]) |c| @call(.always_inline, Parser.next, .{ &self.parser, c }); std.mem.doNotOptimizeAway(self.parser.end(std.ascii.control_code.bel)); self.parser.reset(); } diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 980906e49..34a23787f 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -359,7 +359,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { break :param null; }, .osc_put => osc_put: { - self.osc_parser.next(c); + @call(.always_inline, osc.Parser.next, .{ &self.osc_parser, c }); break :osc_put null; }, .csi_dispatch => csi_dispatch: { diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 64b409cdd..56184df46 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -486,7 +486,7 @@ pub const Parser = struct { } /// Consume the next character c and advance the parser state. - pub inline fn next(self: *Parser, c: u8) void { + pub fn next(self: *Parser, c: u8) void { // If the state becomes invalid for any reason, just discard // any further input. if (self.state == .invalid) return; From caa6b958d77dbfe62abd4ef69989fd76a719286a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 14:07:43 -0800 Subject: [PATCH 415/605] apprt/embedded: escape the initial input string Fixes #10214 --- src/apprt/embedded.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index d1d38c24d..6c1d46722 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -542,13 +542,20 @@ pub const Surface = struct { // If we have an initial input then we set it. if (opts.initial_input) |c_input| { const alloc = config.arenaAlloc(); + + // We need to escape the string because the "raw" field + // expects a Zig string. + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try std.zig.stringEscape( + std.mem.sliceTo(c_input, 0), + &buf.writer, + ); + config.input.list.clearRetainingCapacity(); try config.input.list.append( alloc, - .{ .raw = try alloc.dupeZ(u8, std.mem.sliceTo( - c_input, - 0, - )) }, + .{ .raw = try buf.toOwnedSliceSentinel(0) }, ); } From 794c47425e77bc1c3563e7cdd5fdb7d5f4e88303 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 14:27:35 -0800 Subject: [PATCH 416/605] terminal: PageList shouldn't allow any scrolling with max_size=0 Partial #10227 This fixes the scrollbar part of #10227, but not the search part. The way PageList works is that max_size is advisory: we always allocate on page boundaries so we always have _some_ extra space (usually, unless you ask for a byte-perfect max size). Normally this is fine, it doesn't cause any real issues. But with the introduction of scrollbars (and search), we were exposing this hidden space to the user. To fix this, the easiest approach is to special-case the zero-scrollback scenario, since it is already documented that scrollback limit is not _exact_ and is subject to some minimum allocations. But with zero-scrollback we really expect NOTHING. --- src/terminal/PageList.zig | 69 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 910083b7b..bebe6b700 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2000,6 +2000,12 @@ pub const Scroll = union(enum) { pub fn scroll(self: *PageList, behavior: Scroll) void { defer self.assertIntegrity(); + // Special case no-scrollback mode to never allow scrolling. + if (self.explicit_max_size == 0) { + self.viewport = .active; + return; + } + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -2322,6 +2328,17 @@ pub const Scrollbar = struct { /// is (arbitrary pins are expensive). The caller should take care to only /// call this as needed and not too frequently. pub fn scrollbar(self: *PageList) Scrollbar { + // If we have no scrollback, special case no scrollbar. + // We need to do this because the way PageList works is that + // it always has SOME extra space (due to the way we allocate by page). + // So even with no scrollback we have some growth. It is architecturally + // much simpler to just hide that for no-scrollback cases. + if (self.explicit_max_size == 0) return .{ + .total = self.rows, + .offset = 0, + .len = self.rows, + }; + return .{ .total = self.total_rows, .offset = self.viewportRowOffset(), @@ -4762,7 +4779,8 @@ test "PageList grow prune required with a single page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 0); + // Need scrollback > 0 to have a scrollbar to test + var s = try init(alloc, 80, 24, null); defer s.deinit(); // This block is all test setup. There is nothing required about this @@ -4810,6 +4828,47 @@ test "PageList grow prune required with a single page" { }, s.scrollbar()); } +test "PageList scrollbar with max_size 0 after grow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Grow some rows (simulates normal terminal output) + try s.growRows(10); + + const sb = s.scrollbar(); + + // With no scrollback (max_size = 0), total should equal rows + try testing.expectEqual(s.rows, sb.total); + + // With no scrollback, offset should be 0 (nowhere to scroll back to) + try testing.expectEqual(@as(usize, 0), sb.offset); +} + +test "PageList scroll with max_size 0 no history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + try s.growRows(10); + + // Remember initial viewport position + const pt_before = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + + // Try to scroll backwards into "history" - should be no-op + s.scroll(.{ .delta_row = -5 }); + try testing.expect(s.viewport == .active); + + // Scroll to top - should also be no-op with no scrollback + s.scroll(.{ .top = {} }); + const pt_after = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(pt_before, pt_after); +} + test "PageList scroll top" { const testing = std.testing; const alloc = testing.allocator; @@ -5785,8 +5844,8 @@ test "PageList grow prune scrollback" { const testing = std.testing; const alloc = testing.allocator; - // Zero here forces minimum max size to effectively two pages. - var s = try init(alloc, 80, 24, 0); + // Use std_size to limit scrollback so pruning is triggered. + var s = try init(alloc, 80, 24, std_size); defer s.deinit(); // Grow to capacity @@ -5854,8 +5913,8 @@ test "PageList grow prune scrollback with viewport pin not in pruned page" { const testing = std.testing; const alloc = testing.allocator; - // Zero here forces minimum max size to effectively two pages. - var s = try init(alloc, 80, 24, 0); + // Use std_size to limit scrollback so pruning is triggered. + var s = try init(alloc, 80, 24, std_size); defer s.deinit(); // Grow to capacity of first page From 5bfbadbc7044977fcfe6767b79857ead625b6463 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 8 Jan 2026 20:22:22 -0800 Subject: [PATCH 417/605] terminal/search: screen search prunes history for no-scrollback screens The big comment in `search/screen.zig` describes the solution well. The problem is that our search is discrete by page and a page can contain some amount of history as well. For zero-scrollback screens, we need to fully prune any history lines. For everyone else, everything in the PageList is scrollable and visible so we should search it. --- src/terminal/highlight.zig | 9 ++++ src/terminal/search/screen.zig | 79 +++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 582ef6f06..bc3a1758e 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -180,6 +180,15 @@ pub const Flattened = struct { }; } + pub fn endPin(self: Flattened) Pin { + const slice = self.chunks.slice(); + return .{ + .node = slice.items(.node)[slice.len - 1], + .x = self.bot_x, + .y = slice.items(.end)[slice.len - 1] - 1, + }; + } + /// Convert to an Untracked highlight. pub fn untracked(self: Flattened) Untracked { // Note: we don't use startPin/endPin here because it is slightly diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 0ae7f8a1f..179e7da87 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -412,6 +412,12 @@ pub const ScreenSearch = struct { // pages then we need to re-search the pages and add it to // our history results. + // If our screen has no scrollback then we have no history. + if (self.screen.no_scrollback) { + assert(self.history == null); + break :history; + } + const history_: ?*HistorySearch = if (self.history) |*h| state: { // If our start pin became garbage, it means we pruned all // the way up through it, so we have no history anymore. @@ -575,8 +581,43 @@ pub const ScreenSearch = struct { }, } - // Active area search was successful. Now we have to fixup our - // selection if we had one. + // If we have no scrollback, we need to prune any active results + // that aren't in the actual active area. We only do this for the + // no scrollback scenario because with scrollback we actually + // rely on our active search searching by page to find history + // items as well. This is all related to the fact that PageList + // scrollback limits are discrete by page size except we special + // case zero. + if (self.screen.no_scrollback and + self.active_results.items.len > 0) + active_prune: { + const items = self.active_results.items; + const tl = self.screen.pages.getTopLeft(.active); + for (0.., items) |i, *hl| { + if (!tl.before(hl.endPin())) { + // Deinit because its going to be pruned no matter + // what at some point for not being in the active area. + hl.deinit(alloc); + continue; + } + + // In the active area! Since our results are sorted + // that means everything after this is also in the active + // area, so we prune up to this i. + if (i > 0) self.active_results.replaceRangeAssumeCapacity( + 0, + i, + &.{}, + ); + + break :active_prune; + } + + // None are in the active area... + self.active_results.clearRetainingCapacity(); + } + + // Now we have to fixup our selection if we had one. fixup: { const old_idx = old_selection_idx orelse break :fixup; const m = if (self.selected) |*m| m else break :fixup; @@ -1333,3 +1374,37 @@ test "select prev with history" { } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } + +test "screen search no scrollback has no history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = 0, + }); + defer t.deinit(alloc); + + // Alt screen has no scrollback + _ = try t.switchScreen(.alternate); + + var s = t.vtStream(); + defer s.deinit(); + + // This will probably stop working at some point and we'll have + // no way to test it using public APIs, but at the time of writing + // this test, CSI 22 J (scroll complete) pushes into scrollback + // with alt screen. + try s.nextSlice("Fizz\r\n"); + try s.nextSlice("\x1b[22J"); + try s.nextSlice("hello."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(0, matches.len); +} From 93b4b08b5270d10c440b64483bd9ba39e2787082 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 8 Jan 2026 23:07:57 -0600 Subject: [PATCH 418/605] osc: refactor parsing helper functions into separate files Following up on #9950, refactor the parsing helper functions into separate files. --- src/terminal/osc.zig | 2337 +---------------- src/terminal/osc/parsers.zig | 27 + .../osc/parsers/change_window_icon.zig | 33 + .../osc/parsers/change_window_title.zig | 119 + .../osc/parsers/clipboard_operation.zig | 106 + src/terminal/osc/{ => parsers}/color.zig | 123 +- src/terminal/osc/parsers/hyperlink.zig | 164 ++ src/terminal/osc/parsers/kitty_color.zig | 212 ++ src/terminal/osc/parsers/mouse_shape.zig | 39 + src/terminal/osc/parsers/osc9.zig | 766 ++++++ src/terminal/osc/parsers/report_pwd.zig | 48 + src/terminal/osc/parsers/rxvt_extension.zig | 59 + src/terminal/osc/parsers/semantic_prompt.zig | 694 +++++ src/terminal/stream_readonly.zig | 2 +- 14 files changed, 2384 insertions(+), 2345 deletions(-) create mode 100644 src/terminal/osc/parsers.zig create mode 100644 src/terminal/osc/parsers/change_window_icon.zig create mode 100644 src/terminal/osc/parsers/change_window_title.zig create mode 100644 src/terminal/osc/parsers/clipboard_operation.zig rename src/terminal/osc/{ => parsers}/color.zig (86%) create mode 100644 src/terminal/osc/parsers/hyperlink.zig create mode 100644 src/terminal/osc/parsers/kitty_color.zig create mode 100644 src/terminal/osc/parsers/mouse_shape.zig create mode 100644 src/terminal/osc/parsers/osc9.zig create mode 100644 src/terminal/osc/parsers/report_pwd.zig create mode 100644 src/terminal/osc/parsers/rxvt_extension.zig create mode 100644 src/terminal/osc/parsers/semantic_prompt.zig diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 56184df46..1f4489961 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -12,11 +12,9 @@ const mem = std.mem; const assert = @import("../quirks.zig").inlineAssert; const Allocator = mem.Allocator; const LibEnum = @import("../lib/enum.zig").Enum; -const RGB = @import("color.zig").RGB; const kitty_color = @import("kitty/color.zig"); -const osc_color = @import("osc/color.zig"); -const string_encoding = @import("../os/string_encoding.zig"); -pub const color = osc_color; +const parsers = @import("osc/parsers.zig"); +pub const color = parsers.color; const log = std.log.scoped(.osc); @@ -146,8 +144,8 @@ pub const Command = union(Key) { /// /// 4, 5, 10-19, 104, 105, 110-119 color_operation: struct { - op: osc_color.Operation, - requests: osc_color.List = .{}, + op: color.Operation, + requests: color.List = .{}, terminator: Terminator = .st, }, @@ -310,7 +308,7 @@ pub const Terminator = enum { pub const Parser = struct { /// Maximum size of a "normal" OSC. - const MAX_BUF = 2048; + pub const MAX_BUF = 2048; /// Optional allocator used to accept data longer than MAX_BUF. /// This only applies to some commands (e.g. OSC 52) that can @@ -646,9 +644,9 @@ pub const Parser = struct { .@"0", .@"2", - => self.parseChangeWindowTitle(terminator_ch), + => parsers.change_window_title.parse(self, terminator_ch), - .@"1" => self.parseChangeWindowIcon(terminator_ch), + .@"1" => parsers.change_window_icon.parse(self, terminator_ch), .@"4", .@"5", @@ -673,2330 +671,29 @@ pub const Parser = struct { .@"117", .@"118", .@"119", - => self.parseOscColor(terminator_ch), + => parsers.color.parse(self, terminator_ch), - .@"7" => self.parseReportPwd(terminator_ch), + .@"7" => parsers.report_pwd.parse(self, terminator_ch), - .@"8" => self.parseHyperlink(terminator_ch), + .@"8" => parsers.hyperlink.parse(self, terminator_ch), - .@"9" => self.parseOsc9(terminator_ch), + .@"9" => parsers.osc9.parse(self, terminator_ch), - .@"21" => self.parseKittyColorProtocol(terminator_ch), + .@"21" => parsers.kitty_color.parse(self, terminator_ch), - .@"22" => self.parseMouseShape(terminator_ch), + .@"22" => parsers.mouse_shape.parse(self, terminator_ch), - .@"52" => self.parseClipboardOperation(terminator_ch), + .@"52" => parsers.clipboard_operation.parse(self, terminator_ch), .@"77" => null, - .@"133" => self.parseSemanticPrompt(terminator_ch), + .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), - .@"777" => self.parseRxvtExtension(terminator_ch), + .@"777" => parsers.rxvt_extension.parse(self, terminator_ch), }; } - - /// Parse OSC 0 and OSC 2 - fn parseChangeWindowTitle(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - self.command = .{ - .change_window_title = data[0 .. data.len - 1 :0], - }; - return &self.command; - } - - /// Parse OSC 1 - fn parseChangeWindowIcon(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - self.command = .{ - .change_window_icon = data[0 .. data.len - 1 :0], - }; - return &self.command; - } - - /// Parse OSCs 4, 5, 10-19, 104, 110-119 - fn parseOscColor(self: *Parser, terminator_ch: ?u8) ?*Command { - const alloc = self.alloc orelse { - self.state = .invalid; - return null; - }; - // If we've collected any extra data parse that, otherwise use an empty - // string. - const data = data: { - const writer = self.writer orelse break :data ""; - break :data writer.buffered(); - }; - // Check and make sure that we're parsing the correct OSCs - const op: osc_color.Operation = switch (self.state) { - .@"4" => .osc_4, - .@"5" => .osc_5, - .@"10" => .osc_10, - .@"11" => .osc_11, - .@"12" => .osc_12, - .@"13" => .osc_13, - .@"14" => .osc_14, - .@"15" => .osc_15, - .@"16" => .osc_16, - .@"17" => .osc_17, - .@"18" => .osc_18, - .@"19" => .osc_19, - .@"104" => .osc_104, - .@"110" => .osc_110, - .@"111" => .osc_111, - .@"112" => .osc_112, - .@"113" => .osc_113, - .@"114" => .osc_114, - .@"115" => .osc_115, - .@"116" => .osc_116, - .@"117" => .osc_117, - .@"118" => .osc_118, - .@"119" => .osc_119, - else => { - self.state = .invalid; - return null; - }, - }; - self.command = .{ - .color_operation = .{ - .op = op, - .requests = osc_color.parse(alloc, op, data) catch |err| list: { - log.info( - "failed to parse OSC {t} color request err={} data={s}", - .{ self.state, err, data }, - ); - break :list .{}; - }, - .terminator = .init(terminator_ch), - }, - }; - return &self.command; - } - - /// Parse OSC 7 - fn parseReportPwd(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - self.command = .{ - .report_pwd = .{ - .value = data[0 .. data.len - 1 :0], - }, - }; - return &self.command; - } - - /// Parse OSC 8 hyperlinks - fn parseHyperlink(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - const s = std.mem.indexOfScalar(u8, data, ';') orelse { - self.state = .invalid; - return null; - }; - - self.command = .{ - .hyperlink_start = .{ - .uri = data[s + 1 .. data.len - 1 :0], - }, - }; - - data[s] = 0; - const kvs = data[0 .. s + 1]; - std.mem.replaceScalar(u8, kvs, ':', 0); - var kv_start: usize = 0; - while (kv_start < kvs.len) { - const kv_end = std.mem.indexOfScalarPos(u8, kvs, kv_start + 1, 0) orelse break; - const kv = data[kv_start .. kv_end + 1]; - const v = std.mem.indexOfScalar(u8, kv, '=') orelse break; - const key = kv[0..v]; - const value = kv[v + 1 .. kv.len - 1 :0]; - if (std.mem.eql(u8, key, "id")) { - if (value.len > 0) self.command.hyperlink_start.id = value; - } else { - log.warn("unknown hyperlink option: '{s}'", .{key}); - } - kv_start = kv_end + 1; - } - - if (self.command.hyperlink_start.uri.len == 0) { - if (self.command.hyperlink_start.id != null) { - self.state = .invalid; - return null; - } - self.command = .hyperlink_end; - } - - return &self.command; - } - - /// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension. - fn parseOsc9(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - - // Check first to see if this is a ConEmu OSC - // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC - conemu: { - var data = writer.buffered(); - if (data.len == 0) break :conemu; - switch (data[0]) { - // Check for OSC 9;1 9;10 9;12 - '1' => { - if (data.len < 2) break :conemu; - switch (data[1]) { - // OSC 9;1 - ';' => { - self.command = .{ - .conemu_sleep = .{ - .duration_ms = if (std.fmt.parseUnsigned(u16, data[2..], 10)) |num| @min(num, 10_000) else |_| 100, - }, - }; - return &self.command; - }, - // OSC 9;10 - '0' => { - self.state = .invalid; - return null; - }, - // OSC 9;12 - '2' => { - self.command = .{ - .prompt_start = .{}, - }; - return &self.command; - }, - else => break :conemu, - } - }, - // OSC 9;2 - '2' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - data = writer.buffered(); - self.command = .{ - .conemu_show_message_box = data[2 .. data.len - 1 :0], - }; - return &self.command; - }, - // OSC 9;3 - '3' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - if (data.len == 2) { - self.command = .{ - .conemu_change_tab_title = .reset, - }; - return &self.command; - } - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - data = writer.buffered(); - self.command = .{ - .conemu_change_tab_title = .{ - .value = data[2 .. data.len - 1 :0], - }, - }; - return &self.command; - }, - // OSC 9;4 - '4' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - if (data.len < 3) break :conemu; - switch (data[2]) { - '0' => { - self.command = .{ - .conemu_progress_report = .{ - .state = .remove, - }, - }; - }, - '1' => { - self.command = .{ - .conemu_progress_report = .{ - .state = .set, - .progress = 0, - }, - }; - }, - '2' => { - self.command = .{ - .conemu_progress_report = .{ - .state = .@"error", - }, - }; - }, - '3' => { - self.command = .{ - .conemu_progress_report = .{ - .state = .indeterminate, - }, - }; - }, - '4' => { - self.command = .{ - .conemu_progress_report = .{ - .state = .pause, - }, - }; - }, - else => break :conemu, - } - switch (self.command.conemu_progress_report.state) { - .remove, .indeterminate => {}, - .set, .@"error", .pause => progress: { - if (data.len < 4) break :progress; - if (data[3] != ';') break :progress; - // parse the progress value - self.command.conemu_progress_report.progress = value: { - break :value @intCast(std.math.clamp( - std.fmt.parseUnsigned(usize, data[4..], 10) catch break :value null, - 0, - 100, - )); - }; - }, - } - return &self.command; - }, - // OSC 9;5 - '5' => { - self.command = .conemu_wait_input; - return &self.command; - }, - // OSC 9;6 - '6' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - data = writer.buffered(); - self.command = .{ - .conemu_guimacro = data[2 .. data.len - 1 :0], - }; - return &self.command; - }, - // OSC 9;7 - '7' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - self.state = .invalid; - return null; - }, - // OSC 9;8 - '8' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - self.state = .invalid; - return null; - }, - // OSC 9;9 - '9' => { - if (data.len < 2) break :conemu; - if (data[1] != ';') break :conemu; - self.state = .invalid; - return null; - }, - else => break :conemu, - } - } - - // If it's not a ConEmu OSC, it's an iTerm2 notification - - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - self.command = .{ - .show_desktop_notification = .{ - .title = "", - .body = data[0 .. data.len - 1 :0], - }, - }; - return &self.command; - } - - /// Parse OSC 21, the Kitty Color Protocol. - fn parseKittyColorProtocol(self: *Parser, terminator_ch: ?u8) ?*Command { - assert(self.state == .@"21"); - const alloc = self.alloc orelse { - self.state = .invalid; - return null; - }; - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - self.command = .{ - .kitty_color_protocol = .{ - .list = .empty, - .terminator = .init(terminator_ch), - }, - }; - const list = &self.command.kitty_color_protocol.list; - const data = writer.buffered(); - var kv_it = std.mem.splitScalar(u8, data, ';'); - while (kv_it.next()) |kv| { - if (list.items.len >= @as(usize, kitty_color.Kind.max) * 2) { - log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); - self.state = .invalid; - return null; - } - var it = std.mem.splitScalar(u8, kv, '='); - const k = it.next() orelse continue; - if (k.len == 0) { - log.warn("zero length key in kitty color protocol", .{}); - continue; - } - const key = kitty_color.Kind.parse(k) orelse { - log.warn("unknown key in kitty color protocol: {s}", .{k}); - continue; - }; - const value = std.mem.trim(u8, it.rest(), " "); - if (value.len == 0) { - list.append(alloc, .{ .reset = key }) catch |err| { - log.warn("unable to append kitty color protocol option: {}", .{err}); - continue; - }; - } else if (mem.eql(u8, "?", value)) { - list.append(alloc, .{ .query = key }) catch |err| { - log.warn("unable to append kitty color protocol option: {}", .{err}); - continue; - }; - } else { - list.append(alloc, .{ - .set = .{ - .key = key, - .color = RGB.parse(value) catch |err| switch (err) { - error.InvalidFormat => { - log.warn("invalid color format in kitty color protocol: {s}", .{value}); - continue; - }, - }, - }, - }) catch |err| { - log.warn("unable to append kitty color protocol option: {}", .{err}); - continue; - }; - } - } - return &self.command; - } - - // Parse OSC 22 - fn parseMouseShape(self: *Parser, _: ?u8) ?*Command { - assert(self.state == .@"22"); - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - self.command = .{ - .mouse_shape = .{ - .value = data[0 .. data.len - 1 :0], - }, - }; - return &self.command; - } - - /// Parse OSC 52 - fn parseClipboardOperation(self: *Parser, _: ?u8) ?*Command { - assert(self.state == .@"52"); - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - if (data.len == 1) { - self.state = .invalid; - return null; - } - if (data[0] == ';') { - self.command = .{ - .clipboard_contents = .{ - .kind = 'c', - .data = data[1 .. data.len - 1 :0], - }, - }; - } else { - if (data.len < 2) { - self.state = .invalid; - return null; - } - if (data[1] != ';') { - self.state = .invalid; - return null; - } - self.command = .{ - .clipboard_contents = .{ - .kind = data[0], - .data = data[2 .. data.len - 1 :0], - }, - }; - } - return &self.command; - } - - /// Parse OSC 133, semantic prompts - fn parseSemanticPrompt(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - if (data.len == 0) { - self.state = .invalid; - return null; - } - switch (data[0]) { - 'A' => prompt_start: { - self.command = .{ - .prompt_start = .{}, - }; - if (data.len == 1) break :prompt_start; - if (data[1] != ';') { - self.state = .invalid; - return null; - } - var it = SemanticPromptKVIterator.init(writer) catch { - self.state = .invalid; - return null; - }; - while (it.next()) |kv| { - if (std.mem.eql(u8, kv.key, "aid")) { - self.command.prompt_start.aid = kv.value; - } else if (std.mem.eql(u8, kv.key, "redraw")) redraw: { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. - self.command.prompt_start.redraw = (value: { - if (kv.value.len != 1) break :value null; - switch (kv.value[0]) { - '0' => break :value false, - '1' => break :value true, - else => break :value null, - } - }) orelse { - log.info("OSC 133 A: invalid redraw value: {s}", .{kv.value}); - break :redraw; - }; - } else if (std.mem.eql(u8, kv.key, "special_key")) redraw: { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - self.command.prompt_start.special_key = (value: { - if (kv.value.len != 1) break :value null; - switch (kv.value[0]) { - '0' => break :value false, - '1' => break :value true, - else => break :value null, - } - }) orelse { - log.info("OSC 133 A invalid special_key value: {s}", .{kv.value}); - break :redraw; - }; - } else if (std.mem.eql(u8, kv.key, "click_events")) redraw: { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - self.command.prompt_start.click_events = (value: { - if (kv.value.len != 1) break :value null; - switch (kv.value[0]) { - '0' => break :value false, - '1' => break :value true, - else => break :value null, - } - }) orelse { - log.info("OSC 133 A invalid click_events value: {s}", .{kv.value}); - break :redraw; - }; - } else if (std.mem.eql(u8, kv.key, "k")) k: { - // The "k" marks the kind of prompt, or "primary" if we don't know. - // This can be used to distinguish between the first (initial) prompt, - // a continuation, etc. - if (kv.value.len != 1) break :k; - self.command.prompt_start.kind = switch (kv.value[0]) { - 'c' => .continuation, - 's' => .secondary, - 'r' => .right, - 'i' => .primary, - else => .primary, - }; - } else log.info("OSC 133 A: unknown semantic prompt option: {s}", .{kv.key}); - } - }, - 'B' => prompt_end: { - self.command = .prompt_end; - if (data.len == 1) break :prompt_end; - if (data[1] != ';') { - self.state = .invalid; - return null; - } - var it = SemanticPromptKVIterator.init(writer) catch { - self.state = .invalid; - return null; - }; - while (it.next()) |kv| { - log.info("OSC 133 B: unknown semantic prompt option: {s}", .{kv.key}); - } - }, - 'C' => end_of_input: { - self.command = .{ - .end_of_input = .{}, - }; - if (data.len == 1) break :end_of_input; - if (data[1] != ';') { - self.state = .invalid; - return null; - } - var it = SemanticPromptKVIterator.init(writer) catch { - self.state = .invalid; - return null; - }; - while (it.next()) |kv| { - if (std.mem.eql(u8, kv.key, "cmdline")) { - self.command.end_of_input.cmdline = string_encoding.printfQDecode(kv.value) catch null; - } else if (std.mem.eql(u8, kv.key, "cmdline_url")) { - self.command.end_of_input.cmdline = string_encoding.urlPercentDecode(kv.value) catch null; - } else { - log.info("OSC 133 C: unknown semantic prompt option: {s}", .{kv.key}); - } - } - }, - 'D' => { - const exit_code: ?u8 = exit_code: { - if (data.len == 1) break :exit_code null; - if (data[1] != ';') { - self.state = .invalid; - return null; - } - break :exit_code std.fmt.parseUnsigned(u8, data[2..], 10) catch null; - }; - self.command = .{ - .end_of_command = .{ - .exit_code = exit_code, - }, - }; - }, - else => { - self.state = .invalid; - return null; - }, - } - return &self.command; - } - - const SemanticPromptKVIterator = struct { - index: usize, - string: []u8, - - pub const SemanticPromptKV = struct { - key: [:0]u8, - value: [:0]u8, - }; - - pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!SemanticPromptKVIterator { - // add a semicolon to make it easier to find and sentinel terminate the values - try writer.writeByte(';'); - return .{ - .index = 0, - .string = writer.buffered()[2..], - }; - } - - pub fn next(self: *SemanticPromptKVIterator) ?SemanticPromptKV { - if (self.index >= self.string.len) return null; - - const kv = kv: { - const index = std.mem.indexOfScalarPos(u8, self.string, self.index, ';') orelse { - self.index = self.string.len; - return null; - }; - self.string[index] = 0; - const kv = self.string[self.index..index :0]; - self.index = index + 1; - break :kv kv; - }; - - const key = key: { - const index = std.mem.indexOfScalar(u8, kv, '=') orelse break :key kv; - kv[index] = 0; - const key = kv[0..index :0]; - break :key key; - }; - - const value = kv[key.len + 1 .. :0]; - - return .{ - .key = key, - .value = value, - }; - } - }; - - /// Parse OSC 777 - fn parseRxvtExtension(self: *Parser, _: ?u8) ?*Command { - const writer = self.writer orelse { - self.state = .invalid; - return null; - }; - // ensure that we are sentinel terminated - writer.writeByte(0) catch { - self.state = .invalid; - return null; - }; - const data = writer.buffered(); - const k = std.mem.indexOfScalar(u8, data, ';') orelse { - self.state = .invalid; - return null; - }; - const ext = data[0..k]; - if (!std.mem.eql(u8, ext, "notify")) { - log.warn("unknown rxvt extension: {s}", .{ext}); - self.state = .invalid; - return null; - } - const t = std.mem.indexOfScalarPos(u8, data, k + 1, ';') orelse { - log.warn("rxvt notify extension is missing the title", .{}); - self.state = .invalid; - return null; - }; - data[t] = 0; - const title = data[k + 1 .. t :0]; - const body = data[t + 1 .. data.len - 1 :0]; - self.command = .{ - .show_desktop_notification = .{ - .title = title, - .body = body, - }, - }; - return &self.command; - } }; test { - _ = osc_color; -} - -test "OSC 0: change_window_title" { - const testing = std.testing; - - var p: Parser = .init(null); - p.next('0'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("ab", cmd.change_window_title); -} - -test "OSC 0: longer than buffer" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); - for (input) |ch| p.next(ch); - - try testing.expect(p.end(null) == null); -} - -test "OSC 0: one shorter than buffer length" { - const testing = std.testing; - - var p: Parser = .init(null); - - const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - 1); - const input = prefix ++ title; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings(title, cmd.change_window_title); -} - -test "OSC 0: exactly at buffer length" { - const testing = std.testing; - - var p: Parser = .init(null); - - const prefix = "0;"; - const title = "a" ** Parser.MAX_BUF; - const input = prefix ++ title; - for (input) |ch| p.next(ch); - - // This should be null because we always reserve space for a null terminator. - try testing.expect(p.end(null) == null); -} - -test "OSC 1: change_window_icon" { - const testing = std.testing; - - var p: Parser = .init(null); - p.next('1'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_icon); - try testing.expectEqualStrings("ab", cmd.change_window_icon); -} - -test "OSC 2: change_window_title with 2" { - const testing = std.testing; - - var p: Parser = .init(null); - p.next('2'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("ab", cmd.change_window_title); -} - -test "OSC 2: change_window_title with utf8" { - const testing = std.testing; - - var p: Parser = .init(null); - p.next('2'); - p.next(';'); - // '—' EM DASH U+2014 (E2 80 94) - p.next(0xE2); - p.next(0x80); - p.next(0x94); - - p.next(' '); - // '‐' HYPHEN U+2010 (E2 80 90) - // Intententionally chosen to conflict with the 0x90 C1 control - p.next(0xE2); - p.next(0x80); - p.next(0x90); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("— ‐", cmd.change_window_title); -} - -test "OSC 2: change_window_title empty" { - const testing = std.testing; - - var p: Parser = .init(null); - p.next('2'); - p.next(';'); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings("", cmd.change_window_title); -} - -test "OSC 4: empty param" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "4;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b'); - try testing.expect(cmd == null); -} - -// See src/terminal/osc/color.zig for more OSC 4 tests. - -// See src/terminal/osc/color.zig for OSC 5 tests. - -test "OSC 7: report pwd" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "7;file:///tmp/example"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .report_pwd); - try testing.expectEqualStrings("file:///tmp/example", cmd.report_pwd.value); -} - -test "OSC 7: report pwd empty" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "7;"; - for (input) |ch| p.next(ch); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .report_pwd); - try testing.expectEqualStrings("", cmd.report_pwd.value); -} - -test "OSC 8: hyperlink" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;;http://example.com"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_start); - try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); -} - -test "OSC 8: hyperlink with id set" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;id=foo;http://example.com"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_start); - try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); - try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); -} - -test "OSC 8: hyperlink with empty id" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;id=;http://example.com"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_start); - try testing.expectEqual(null, cmd.hyperlink_start.id); - try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); -} - -test "OSC 8: hyperlink with incomplete key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;id;http://example.com"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_start); - try testing.expectEqual(null, cmd.hyperlink_start.id); - try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); -} - -test "OSC 8: hyperlink with empty key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;=value;http://example.com"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_start); - try testing.expectEqual(null, cmd.hyperlink_start.id); - try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); -} - -test "OSC 8: hyperlink with empty key and id" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;=value:id=foo;http://example.com"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_start); - try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); - try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); -} - -test "OSC 8: hyperlink with empty uri" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;id=foo;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b'); - try testing.expect(cmd == null); -} - -test "OSC 8: hyperlink end" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "8;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .hyperlink_end); -} - -test "OSC 9: show desktop notification" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;Hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("", cmd.show_desktop_notification.title); - try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); -} - -test "OSC 9: show single character desktop notification" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;H"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("", cmd.show_desktop_notification.title); - try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); -} - -test "OSC 9;1: ConEmu sleep" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;1;420"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); -} - -test "OSC 9;1: ConEmu sleep with no value default to 100ms" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;1;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); -} - -test "OSC 9;1: conemu sleep cannot exceed 10000ms" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;1;12345"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); -} - -test "OSC 9;1: conemu sleep invalid input" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;1;foo"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); -} - -test "OSC 9;1: conemu sleep -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;1"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); -} - -test "OSC 9;1: conemu sleep -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;1a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); -} - -test "OSC 9;2: ConEmu message box" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;2;hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); -} - -test "OSC 9;2: ConEmu message box invalid input" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); -} - -test "OSC 9;2: ConEmu message box empty message" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;2;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings("", cmd.conemu_show_message_box); -} - -test "OSC 9;2: ConEmu message box spaces only message" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;2; "; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); -} - -test "OSC 9;2: message box -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); -} - -test "OSC 9;2: message box -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;2a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); -} - -test "OSC 9;3: ConEmu change tab title" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;3;foo bar"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_change_tab_title); - try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); -} - -test "OSC 9;3: ConEmu change tab title reset" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;3;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - const expected_command: Command = .{ .conemu_change_tab_title = .reset }; - try testing.expectEqual(expected_command, cmd); -} - -test "OSC 9;3: ConEmu change tab title spaces only" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;3; "; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_change_tab_title); - try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); -} - -test "OSC 9;3: change tab title -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;3"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); -} - -test "OSC 9;3: message box -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;3a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); -} - -test "OSC 9;4: ConEmu progress set" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;1;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC 9;4: ConEmu progress set overflow" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;1;900"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(100, cmd.conemu_progress_report.progress); -} - -test "OSC 9;4: ConEmu progress set single digit" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;1;9"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expect(cmd.conemu_progress_report.progress == 9); -} - -test "OSC 9;4: ConEmu progress set double digit" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;1;94"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(94, cmd.conemu_progress_report.progress); -} - -test "OSC 9;4: ConEmu progress set extra semicolon ignored" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;1;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(100, cmd.conemu_progress_report.progress); -} - -test "OSC 9;4: ConEmu progress remove with no progress" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;0;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC 9;4: ConEmu progress remove with double semicolon" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;0;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC 9;4: ConEmu progress remove ignores progress" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;0;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC 9;4: ConEmu progress remove extra semicolon" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;0;100;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); -} - -test "OSC 9;4: ConEmu progress error" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .@"error"); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC 9;4: ConEmu progress error with progress" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;2;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .@"error"); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC 9;4: progress pause" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;4"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .pause); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC 9;4: ConEmu progress pause with progress" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;4;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .pause); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC 9;4: progress -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); -} - -test "OSC 9;4: progress -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); -} - -test "OSC 9;4: progress -> desktop notification 3" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;5"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); -} - -test "OSC 9;4: progress -> desktop notification 4" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;4;5a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); -} - -test "OSC 9;5: ConEmu wait input" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;5"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_wait_input); -} - -test "OSC 9;5: ConEmu wait ignores trailing characters" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "9;5;foo"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_wait_input); -} - -test "OSC 9;6: ConEmu guimacro 1" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "9;6;a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_guimacro); - try testing.expectEqualStrings("a", cmd.conemu_guimacro); -} - -test "OSC: 9;6: ConEmu guimacro 2" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "9;6;ab"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_guimacro); - try testing.expectEqualStrings("ab", cmd.conemu_guimacro); -} - -test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "9;6"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); -} - -// See src/terminal/osc/color.zig for OSC 10 tests. - -// See src/terminal/osc/color.zig for OSC 11 tests. - -// See src/terminal/osc/color.zig for OSC 12 tests. - -// See src/terminal/osc/color.zig for OSC 13 tests. - -// See src/terminal/osc/color.zig for OSC 14 tests. - -// See src/terminal/osc/color.zig for OSC 15 tests. - -// See src/terminal/osc/color.zig for OSC 16 tests. - -// See src/terminal/osc/color.zig for OSC 17 tests. - -// See src/terminal/osc/color.zig for OSC 18 tests. - -// See src/terminal/osc/color.zig for OSC 19 tests. - -test "OSC 21: kitty color protocol" { - const testing = std.testing; - const Kind = kitty_color.Kind; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_color_protocol); - try testing.expectEqual(@as(usize, 9), cmd.kitty_color_protocol.list.items.len); - { - const item = cmd.kitty_color_protocol.list.items[0]; - try testing.expect(item == .query); - try testing.expectEqual(Kind{ .special = .foreground }, item.query); - } - { - const item = cmd.kitty_color_protocol.list.items[1]; - try testing.expect(item == .set); - try testing.expectEqual(Kind{ .special = .background }, item.set.key); - try testing.expectEqual(@as(u8, 0xf0), item.set.color.r); - try testing.expectEqual(@as(u8, 0xf8), item.set.color.g); - try testing.expectEqual(@as(u8, 0xff), item.set.color.b); - } - { - const item = cmd.kitty_color_protocol.list.items[2]; - try testing.expect(item == .set); - try testing.expectEqual(Kind{ .special = .cursor }, item.set.key); - try testing.expectEqual(@as(u8, 0xf0), item.set.color.r); - try testing.expectEqual(@as(u8, 0xf8), item.set.color.g); - try testing.expectEqual(@as(u8, 0xff), item.set.color.b); - } - { - const item = cmd.kitty_color_protocol.list.items[3]; - try testing.expect(item == .reset); - try testing.expectEqual(Kind{ .special = .cursor_text }, item.reset); - } - { - const item = cmd.kitty_color_protocol.list.items[4]; - try testing.expect(item == .reset); - try testing.expectEqual(Kind{ .special = .visual_bell }, item.reset); - } - { - const item = cmd.kitty_color_protocol.list.items[5]; - try testing.expect(item == .query); - try testing.expectEqual(Kind{ .special = .selection_background }, item.query); - } - { - const item = cmd.kitty_color_protocol.list.items[6]; - try testing.expect(item == .set); - try testing.expectEqual(Kind{ .special = .selection_background }, item.set.key); - try testing.expectEqual(@as(u8, 0xaa), item.set.color.r); - try testing.expectEqual(@as(u8, 0xbb), item.set.color.g); - try testing.expectEqual(@as(u8, 0xcc), item.set.color.b); - } - { - const item = cmd.kitty_color_protocol.list.items[7]; - try testing.expect(item == .query); - try testing.expectEqual(Kind{ .palette = 2 }, item.query); - } - { - const item = cmd.kitty_color_protocol.list.items[8]; - try testing.expect(item == .set); - try testing.expectEqual(Kind{ .palette = 3 }, item.set.key); - try testing.expectEqual(@as(u8, 0xff), item.set.color.r); - try testing.expectEqual(@as(u8, 0xff), item.set.color.g); - try testing.expectEqual(@as(u8, 0xff), item.set.color.b); - } -} - -test "OSC 21: kitty color protocol without allocator" { - const testing = std.testing; - - var p: Parser = .init(null); - defer p.deinit(); - - const input = "21;foreground=?"; - for (input) |ch| p.next(ch); - try testing.expect(p.end('\x1b') == null); -} - -test "OSC 21: kitty color protocol double reset" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_color_protocol); - - p.reset(); - p.reset(); -} - -test "OSC 21: kitty color protocol reset after invalid" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_color_protocol); - - p.reset(); - - try testing.expectEqual(Parser.State.start, p.state); - p.next('X'); - try testing.expectEqual(Parser.State.invalid, p.state); - - p.reset(); -} - -test "OSC 21: kitty color protocol no key" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "21;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_color_protocol); - try testing.expectEqual(0, cmd.kitty_color_protocol.list.items.len); -} - -test "OSC 22: pointer cursor" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "22;pointer"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .mouse_shape); - try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); -} - -test "OSC 52: get/set clipboard" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC 52: get/set clipboard (optional parameter)" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "52;;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC 52: get/set clipboard with allocator" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC 52: clear clipboard" { - const testing = std.testing; - - var p: Parser = .init(null); - defer p.deinit(); - - const input = "52;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expectEqualStrings("", cmd.clipboard_contents.data); -} - -// See src/terminal/osc/color.zig for OSC 104 tests. - -// See src/terminal/osc/color.zig for OSC 105 tests. - -// See src/terminal/osc/color.zig for OSC 110 tests. - -// See src/terminal/osc/color.zig for OSC 111 tests. - -// See src/terminal/osc/color.zig for OSC 112 tests. - -// See src/terminal/osc/color.zig for OSC 113 tests. - -// See src/terminal/osc/color.zig for OSC 114 tests. - -// See src/terminal/osc/color.zig for OSC 115 tests. - -// See src/terminal/osc/color.zig for OSC 116 tests. - -// See src/terminal/osc/color.zig for OSC 117 tests. - -// See src/terminal/osc/color.zig for OSC 118 tests. - -// See src/terminal/osc/color.zig for OSC 119 tests. - -test "OSC 133: prompt_start" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.aid == null); - try testing.expect(cmd.prompt_start.redraw); -} - -test "OSC 133: prompt_start with single option" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;aid=14"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); -} - -test "OSC 133: prompt_start with '=' in aid" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;aid=a=b;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("a=b", cmd.prompt_start.aid.?); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC 133: prompt_start with redraw disabled" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC 133: prompt_start with redraw invalid value" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;redraw=42"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.redraw); - try testing.expect(cmd.prompt_start.kind == .primary); -} - -test "OSC 133: prompt_start with continuation" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;k=c"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .continuation); -} - -test "OSC 133: prompt_start with secondary" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;k=s"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .secondary); -} - -test "OSC 133: prompt_start with special_key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key=1"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == true); -} - -test "OSC 133: prompt_start with special_key invalid" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key=bobr"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == false); -} - -test "OSC 133: prompt_start with special_key 0" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == false); -} - -test "OSC 133: prompt_start with special_key empty" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key="; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == false); -} - -test "OSC 133: prompt_start with click_events true" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events=1"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == true); -} - -test "OSC 133: prompt_start with click_events false" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == false); -} - -test "OSC 133: prompt_start with click_events empty" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events="; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == false); -} - -test "OSC 133: end_of_command no exit code" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;D"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); -} - -test "OSC 133: end_of_command with exit code" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;D;25"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); - try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); -} - -test "OSC 133: prompt_end" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;B"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_end); -} - -test "OSC 133: end_of_input" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); -} - -test "OSC 133: end_of_input with cmdline 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=echo bobr\\ kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 3" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=echo bobr\\nkurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr\nkurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 4" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'echo bobr kurwa'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 5" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline='echo bobr kurwa'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 6" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline='echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 7" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 8" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 9" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 10" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline="; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%20kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 3" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%3bkurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr;kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 4" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%3kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 5" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 6" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 7" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa%20"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa ", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 8" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa%2"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 9" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa%2"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC: OSC 777 show desktop notification with title" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "777;notify;Title;Body"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); + _ = parsers; } diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig new file mode 100644 index 000000000..152276af2 --- /dev/null +++ b/src/terminal/osc/parsers.zig @@ -0,0 +1,27 @@ +const std = @import("std"); + +pub const change_window_icon = @import("parsers/change_window_icon.zig"); +pub const change_window_title = @import("parsers/change_window_title.zig"); +pub const clipboard_operation = @import("parsers/clipboard_operation.zig"); +pub const color = @import("parsers/color.zig"); +pub const hyperlink = @import("parsers/hyperlink.zig"); +pub const kitty_color = @import("parsers/kitty_color.zig"); +pub const mouse_shape = @import("parsers/mouse_shape.zig"); +pub const osc9 = @import("parsers/osc9.zig"); +pub const report_pwd = @import("parsers/report_pwd.zig"); +pub const rxvt_extension = @import("parsers/rxvt_extension.zig"); +pub const semantic_prompt = @import("parsers/semantic_prompt.zig"); + +test { + _ = change_window_icon; + _ = change_window_title; + _ = clipboard_operation; + _ = color; + _ = hyperlink; + _ = kitty_color; + _ = mouse_shape; + _ = osc9; + _ = report_pwd; + _ = rxvt_extension; + _ = semantic_prompt; +} diff --git a/src/terminal/osc/parsers/change_window_icon.zig b/src/terminal/osc/parsers/change_window_icon.zig new file mode 100644 index 000000000..aefe17696 --- /dev/null +++ b/src/terminal/osc/parsers/change_window_icon.zig @@ -0,0 +1,33 @@ +const std = @import("std"); +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +/// Parse OSC 1 +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + parser.command = .{ + .change_window_icon = data[0 .. data.len - 1 :0], + }; + return &parser.command; +} + +test "OSC 1: change_window_icon" { + const testing = std.testing; + + var p: Parser = .init(null); + p.next('1'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_icon); + try testing.expectEqualStrings("ab", cmd.change_window_icon); +} diff --git a/src/terminal/osc/parsers/change_window_title.zig b/src/terminal/osc/parsers/change_window_title.zig new file mode 100644 index 000000000..b0bf44dd3 --- /dev/null +++ b/src/terminal/osc/parsers/change_window_title.zig @@ -0,0 +1,119 @@ +const std = @import("std"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +/// Parse OSC 0 and OSC 2 +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + parser.command = .{ + .change_window_title = data[0 .. data.len - 1 :0], + }; + return &parser.command; +} + +test "OSC 0: change_window_title" { + const testing = std.testing; + + var p: Parser = .init(null); + p.next('0'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} + +test "OSC 0: longer than buffer" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); +} + +test "OSC 0: one shorter than buffer length" { + const testing = std.testing; + + var p: Parser = .init(null); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - 1); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings(title, cmd.change_window_title); +} + +test "OSC 0: exactly at buffer length" { + const testing = std.testing; + + var p: Parser = .init(null); + + const prefix = "0;"; + const title = "a" ** Parser.MAX_BUF; + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + // This should be null because we always reserve space for a null terminator. + try testing.expect(p.end(null) == null); +} +test "OSC 2: change_window_title with 2" { + const testing = std.testing; + + var p: Parser = .init(null); + p.next('2'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("ab", cmd.change_window_title); +} + +test "OSC 2: change_window_title with utf8" { + const testing = std.testing; + + var p: Parser = .init(null); + p.next('2'); + p.next(';'); + // '—' EM DASH U+2014 (E2 80 94) + p.next(0xE2); + p.next(0x80); + p.next(0x94); + + p.next(' '); + // '‐' HYPHEN U+2010 (E2 80 90) + // Intententionally chosen to conflict with the 0x90 C1 control + p.next(0xE2); + p.next(0x80); + p.next(0x90); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("— ‐", cmd.change_window_title); +} + +test "OSC 2: change_window_title empty" { + const testing = std.testing; + + var p: Parser = .init(null); + p.next('2'); + p.next(';'); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings("", cmd.change_window_title); +} diff --git a/src/terminal/osc/parsers/clipboard_operation.zig b/src/terminal/osc/parsers/clipboard_operation.zig new file mode 100644 index 000000000..59a8831bc --- /dev/null +++ b/src/terminal/osc/parsers/clipboard_operation.zig @@ -0,0 +1,106 @@ +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +/// Parse OSC 52 +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + assert(parser.state == .@"52"); + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + if (data.len == 1) { + parser.state = .invalid; + return null; + } + if (data[0] == ';') { + parser.command = .{ + .clipboard_contents = .{ + .kind = 'c', + .data = data[1 .. data.len - 1 :0], + }, + }; + } else { + if (data.len < 2) { + parser.state = .invalid; + return null; + } + if (data[1] != ';') { + parser.state = .invalid; + return null; + } + parser.command = .{ + .clipboard_contents = .{ + .kind = data[0], + .data = data[2 .. data.len - 1 :0], + }, + }; + } + return &parser.command; +} + +test "OSC 52: get/set clipboard" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: get/set clipboard (optional parameter)" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "52;;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: get/set clipboard with allocator" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: clear clipboard" { + const testing = std.testing; + + var p: Parser = .init(null); + defer p.deinit(); + + const input = "52;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expectEqualStrings("", cmd.clipboard_contents.data); +} diff --git a/src/terminal/osc/color.zig b/src/terminal/osc/parsers/color.zig similarity index 86% rename from src/terminal/osc/color.zig rename to src/terminal/osc/parsers/color.zig index 9fd81ed63..7d3dc68c0 100644 --- a/src/terminal/osc/color.zig +++ b/src/terminal/osc/parsers/color.zig @@ -1,10 +1,15 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const DynamicColor = @import("../color.zig").Dynamic; -const SpecialColor = @import("../color.zig").Special; -const RGB = @import("../color.zig").RGB; -pub const ParseError = Allocator.Error || error{ +const DynamicColor = @import("../../color.zig").Dynamic; +const SpecialColor = @import("../../color.zig").Special; +const RGB = @import("../../color.zig").RGB; +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_color); + +const ParseError = Allocator.Error || error{ MissingOperation, }; @@ -36,6 +41,76 @@ pub const Operation = enum { osc_119, }; +/// Parse OSCs 4, 5, 10-19, 104, 110-119 +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + const alloc = parser.alloc orelse { + parser.state = .invalid; + return null; + }; + // If we've collected any extra data parse that, otherwise use an empty + // string. + const data = data: { + const writer = parser.writer orelse break :data ""; + break :data writer.buffered(); + }; + // Check and make sure that we're parsing the correct OSCs + const op: Operation = switch (parser.state) { + .@"4" => .osc_4, + .@"5" => .osc_5, + .@"10" => .osc_10, + .@"11" => .osc_11, + .@"12" => .osc_12, + .@"13" => .osc_13, + .@"14" => .osc_14, + .@"15" => .osc_15, + .@"16" => .osc_16, + .@"17" => .osc_17, + .@"18" => .osc_18, + .@"19" => .osc_19, + .@"104" => .osc_104, + .@"110" => .osc_110, + .@"111" => .osc_111, + .@"112" => .osc_112, + .@"113" => .osc_113, + .@"114" => .osc_114, + .@"115" => .osc_115, + .@"116" => .osc_116, + .@"117" => .osc_117, + .@"118" => .osc_118, + .@"119" => .osc_119, + else => { + parser.state = .invalid; + return null; + }, + }; + parser.command = .{ + .color_operation = .{ + .op = op, + .requests = parseColor(alloc, op, data) catch |err| list: { + log.info( + "failed to parse OSC {t} color request err={} data={s}", + .{ parser.state, err, data }, + ); + break :list .{}; + }, + .terminator = .init(terminator_ch), + }, + }; + return &parser.command; +} + +test "OSC 4: empty param" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "4;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + /// Parse any color operation string. This should NOT include the operation /// itself, but only the body of the operation. e.g. for "4;a;b;c" the body /// should be "a;b;c" and the operation should be set accordingly. @@ -46,7 +121,7 @@ pub const Operation = enum { /// request) but grants us an easier to understand and testable implementation. /// /// If color changing ends up being a bottleneck we can optimize this later. -pub fn parse( +fn parseColor( alloc: Allocator, op: Operation, buf: []const u8, @@ -295,7 +370,7 @@ test "OSC 4:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_4, body); + var list = try parseColor(alloc, .osc_4, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -317,7 +392,7 @@ test "OSC 4:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_4, body); + var list = try parseColor(alloc, .osc_4, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -336,7 +411,7 @@ test "OSC 4:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_4, body); + var list = try parseColor(alloc, .osc_4, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -360,7 +435,7 @@ test "OSC 4:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_4, body); + var list = try parseColor(alloc, .osc_4, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -387,7 +462,7 @@ test "OSC 4:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_4, body); + var list = try parseColor(alloc, .osc_4, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -419,7 +494,7 @@ test "OSC 5:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_5, body); + var list = try parseColor(alloc, .osc_5, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -439,7 +514,7 @@ test "OSC 4: multiple requests" { // printf '\e]4;0;red;1;blue\e\\' { - var list = try parse( + var list = try parseColor( alloc, .osc_4, "0;red;1;blue", @@ -465,7 +540,7 @@ test "OSC 4: multiple requests" { // Multiple requests with same index overwrite each other // printf '\e]4;0;red;0;blue\e\\' { - var list = try parse( + var list = try parseColor( alloc, .osc_4, "0;red;0;blue", @@ -505,7 +580,7 @@ test "OSC 104:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_104, body); + var list = try parseColor(alloc, .osc_104, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -529,7 +604,7 @@ test "OSC 104:" { ); defer alloc.free(body); - var list = try parse(alloc, .osc_104, body); + var list = try parseColor(alloc, .osc_104, body); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -544,7 +619,7 @@ test "OSC 104: empty index" { const testing = std.testing; const alloc = testing.allocator; - var list = try parse(alloc, .osc_104, "0;;1"); + var list = try parseColor(alloc, .osc_104, "0;;1"); defer list.deinit(alloc); try testing.expectEqual(2, list.count()); try testing.expectEqual( @@ -561,7 +636,7 @@ test "OSC 104: invalid index" { const testing = std.testing; const alloc = testing.allocator; - var list = try parse(alloc, .osc_104, "ffff;1"); + var list = try parseColor(alloc, .osc_104, "ffff;1"); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -574,7 +649,7 @@ test "OSC 104: reset all" { const testing = std.testing; const alloc = testing.allocator; - var list = try parse(alloc, .osc_104, ""); + var list = try parseColor(alloc, .osc_104, ""); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -587,7 +662,7 @@ test "OSC 105: reset all" { const testing = std.testing; const alloc = testing.allocator; - var list = try parse(alloc, .osc_105, ""); + var list = try parseColor(alloc, .osc_105, ""); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -611,7 +686,7 @@ test "OSC 10: OSC 11: OSC 12: OSC: 13: OSC 14: OSC 15: OSC: 16: OSC 17: OSC 18: // Example script: // printf '\e]10;red\e\\' { - var list = try parse(alloc, op, "red"); + var list = try parseColor(alloc, op, "red"); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -632,7 +707,7 @@ test "OSC 10: OSC 11: OSC 12: OSC: 13: OSC 14: OSC 15: OSC: 16: OSC 17: OSC 18: // Example script: // printf '\e]11;red;blue\e\\' { - var list = try parse( + var list = try parseColor( alloc, .osc_11, "red;blue", @@ -671,7 +746,7 @@ test "OSC 110: OSC 111: OSC 112: OSC: 113: OSC 114: OSC 115: OSC: 116: OSC 117: // Example script: // printf '\e]110\e\\' { - var list = try parse(alloc, op, ""); + var list = try parseColor(alloc, op, ""); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -684,7 +759,7 @@ test "OSC 110: OSC 111: OSC 112: OSC: 113: OSC 114: OSC 115: OSC: 116: OSC 117: // // printf '\e]110;\e\\' { - var list = try parse(alloc, op, ";"); + var list = try parseColor(alloc, op, ";"); defer list.deinit(alloc); try testing.expectEqual(1, list.count()); try testing.expectEqual( @@ -697,7 +772,7 @@ test "OSC 110: OSC 111: OSC 112: OSC: 113: OSC 114: OSC 115: OSC: 116: OSC 117: // // printf '\e]110 \e\\' { - var list = try parse(alloc, op, " "); + var list = try parseColor(alloc, op, " "); defer list.deinit(alloc); try testing.expectEqual(0, list.count()); } diff --git a/src/terminal/osc/parsers/hyperlink.zig b/src/terminal/osc/parsers/hyperlink.zig new file mode 100644 index 000000000..cf328beb5 --- /dev/null +++ b/src/terminal/osc/parsers/hyperlink.zig @@ -0,0 +1,164 @@ +const std = @import("std"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_hyperlink); + +/// Parse OSC 8 hyperlinks +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + const s = std.mem.indexOfScalar(u8, data, ';') orelse { + parser.state = .invalid; + return null; + }; + + parser.command = .{ + .hyperlink_start = .{ + .uri = data[s + 1 .. data.len - 1 :0], + }, + }; + + data[s] = 0; + const kvs = data[0 .. s + 1]; + std.mem.replaceScalar(u8, kvs, ':', 0); + var kv_start: usize = 0; + while (kv_start < kvs.len) { + const kv_end = std.mem.indexOfScalarPos(u8, kvs, kv_start + 1, 0) orelse break; + const kv = data[kv_start .. kv_end + 1]; + const v = std.mem.indexOfScalar(u8, kv, '=') orelse break; + const key = kv[0..v]; + const value = kv[v + 1 .. kv.len - 1 :0]; + if (std.mem.eql(u8, key, "id")) { + if (value.len > 0) parser.command.hyperlink_start.id = value; + } else { + log.warn("unknown hyperlink option: '{s}'", .{key}); + } + kv_start = kv_end + 1; + } + + if (parser.command.hyperlink_start.uri.len == 0) { + if (parser.command.hyperlink_start.id != null) { + parser.state = .invalid; + return null; + } + parser.command = .hyperlink_end; + } + + return &parser.command; +} + +test "OSC 8: hyperlink" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC 8: hyperlink with id set" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;id=foo;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC 8: hyperlink with empty id" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;id=;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC 8: hyperlink with incomplete key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;id;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC 8: hyperlink with empty key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;=value;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC 8: hyperlink with empty key and id" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;=value:id=foo;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC 8: hyperlink with empty uri" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;id=foo;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + +test "OSC 8: hyperlink end" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "8;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .hyperlink_end); +} diff --git a/src/terminal/osc/parsers/kitty_color.zig b/src/terminal/osc/parsers/kitty_color.zig new file mode 100644 index 000000000..30a7fe77f --- /dev/null +++ b/src/terminal/osc/parsers/kitty_color.zig @@ -0,0 +1,212 @@ +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; +const kitty_color = @import("../../kitty/color.zig"); +const RGB = @import("../../color.zig").RGB; + +const log = std.log.scoped(.osc_kitty_color); + +/// Parse OSC 21, the Kitty Color Protocol. +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + assert(parser.state == .@"21"); + + const alloc = parser.alloc orelse { + parser.state = .invalid; + return null; + }; + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + parser.command = .{ + .kitty_color_protocol = .{ + .list = .empty, + .terminator = .init(terminator_ch), + }, + }; + const list = &parser.command.kitty_color_protocol.list; + const data = writer.buffered(); + var kv_it = std.mem.splitScalar(u8, data, ';'); + while (kv_it.next()) |kv| { + if (list.items.len >= @as(usize, kitty_color.Kind.max) * 2) { + log.warn("exceeded limit for number of keys in kitty color protocol, ignoring", .{}); + parser.state = .invalid; + return null; + } + var it = std.mem.splitScalar(u8, kv, '='); + const k = it.next() orelse continue; + if (k.len == 0) { + log.warn("zero length key in kitty color protocol", .{}); + continue; + } + const key = kitty_color.Kind.parse(k) orelse { + log.warn("unknown key in kitty color protocol: {s}", .{k}); + continue; + }; + const value = std.mem.trim(u8, it.rest(), " "); + if (value.len == 0) { + list.append(alloc, .{ .reset = key }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + continue; + }; + } else if (std.mem.eql(u8, "?", value)) { + list.append(alloc, .{ .query = key }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + continue; + }; + } else { + list.append(alloc, .{ + .set = .{ + .key = key, + .color = RGB.parse(value) catch |err| switch (err) { + error.InvalidFormat => { + log.warn("invalid color format in kitty color protocol: {s}", .{value}); + continue; + }, + }, + }, + }) catch |err| { + log.warn("unable to append kitty color protocol option: {}", .{err}); + continue; + }; + } + } + return &parser.command; +} + +test "OSC 21: kitty color protocol" { + const testing = std.testing; + const Kind = kitty_color.Kind; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_color_protocol); + try testing.expectEqual(@as(usize, 9), cmd.kitty_color_protocol.list.items.len); + { + const item = cmd.kitty_color_protocol.list.items[0]; + try testing.expect(item == .query); + try testing.expectEqual(Kind{ .special = .foreground }, item.query); + } + { + const item = cmd.kitty_color_protocol.list.items[1]; + try testing.expect(item == .set); + try testing.expectEqual(Kind{ .special = .background }, item.set.key); + try testing.expectEqual(@as(u8, 0xf0), item.set.color.r); + try testing.expectEqual(@as(u8, 0xf8), item.set.color.g); + try testing.expectEqual(@as(u8, 0xff), item.set.color.b); + } + { + const item = cmd.kitty_color_protocol.list.items[2]; + try testing.expect(item == .set); + try testing.expectEqual(Kind{ .special = .cursor }, item.set.key); + try testing.expectEqual(@as(u8, 0xf0), item.set.color.r); + try testing.expectEqual(@as(u8, 0xf8), item.set.color.g); + try testing.expectEqual(@as(u8, 0xff), item.set.color.b); + } + { + const item = cmd.kitty_color_protocol.list.items[3]; + try testing.expect(item == .reset); + try testing.expectEqual(Kind{ .special = .cursor_text }, item.reset); + } + { + const item = cmd.kitty_color_protocol.list.items[4]; + try testing.expect(item == .reset); + try testing.expectEqual(Kind{ .special = .visual_bell }, item.reset); + } + { + const item = cmd.kitty_color_protocol.list.items[5]; + try testing.expect(item == .query); + try testing.expectEqual(Kind{ .special = .selection_background }, item.query); + } + { + const item = cmd.kitty_color_protocol.list.items[6]; + try testing.expect(item == .set); + try testing.expectEqual(Kind{ .special = .selection_background }, item.set.key); + try testing.expectEqual(@as(u8, 0xaa), item.set.color.r); + try testing.expectEqual(@as(u8, 0xbb), item.set.color.g); + try testing.expectEqual(@as(u8, 0xcc), item.set.color.b); + } + { + const item = cmd.kitty_color_protocol.list.items[7]; + try testing.expect(item == .query); + try testing.expectEqual(Kind{ .palette = 2 }, item.query); + } + { + const item = cmd.kitty_color_protocol.list.items[8]; + try testing.expect(item == .set); + try testing.expectEqual(Kind{ .palette = 3 }, item.set.key); + try testing.expectEqual(@as(u8, 0xff), item.set.color.r); + try testing.expectEqual(@as(u8, 0xff), item.set.color.g); + try testing.expectEqual(@as(u8, 0xff), item.set.color.b); + } +} + +test "OSC 21: kitty color protocol without allocator" { + const testing = std.testing; + + var p: Parser = .init(null); + defer p.deinit(); + + const input = "21;foreground=?"; + for (input) |ch| p.next(ch); + try testing.expect(p.end('\x1b') == null); +} + +test "OSC 21: kitty color protocol double reset" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_color_protocol); + + p.reset(); + p.reset(); +} + +test "OSC 21: kitty color protocol reset after invalid" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_color_protocol); + + p.reset(); + + try testing.expectEqual(Parser.State.start, p.state); + p.next('X'); + try testing.expectEqual(Parser.State.invalid, p.state); + + p.reset(); +} + +test "OSC 21: kitty color protocol no key" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "21;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_color_protocol); + try testing.expectEqual(0, cmd.kitty_color_protocol.list.items.len); +} diff --git a/src/terminal/osc/parsers/mouse_shape.zig b/src/terminal/osc/parsers/mouse_shape.zig new file mode 100644 index 000000000..91c5ab270 --- /dev/null +++ b/src/terminal/osc/parsers/mouse_shape.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +// Parse OSC 22 +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + assert(parser.state == .@"22"); + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + parser.command = .{ + .mouse_shape = .{ + .value = data[0 .. data.len - 1 :0], + }, + }; + return &parser.command; +} + +test "OSC 22: pointer cursor" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "22;pointer"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .mouse_shape); + try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); +} diff --git a/src/terminal/osc/parsers/osc9.zig b/src/terminal/osc/parsers/osc9.zig new file mode 100644 index 000000000..1ca7ba5a0 --- /dev/null +++ b/src/terminal/osc/parsers/osc9.zig @@ -0,0 +1,766 @@ +const std = @import("std"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +/// Parse OSC 9, which could be an iTerm2 notification or a ConEmu extension. +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + + // Check first to see if this is a ConEmu OSC + // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC + conemu: { + var data = writer.buffered(); + if (data.len == 0) break :conemu; + switch (data[0]) { + // Check for OSC 9;1 9;10 9;12 + '1' => { + if (data.len < 2) break :conemu; + switch (data[1]) { + // OSC 9;1 + ';' => { + parser.command = .{ + .conemu_sleep = .{ + .duration_ms = if (std.fmt.parseUnsigned(u16, data[2..], 10)) |num| @min(num, 10_000) else |_| 100, + }, + }; + return &parser.command; + }, + // OSC 9;10 + '0' => { + parser.state = .invalid; + return null; + }, + // OSC 9;12 + '2' => { + parser.command = .{ + .prompt_start = .{}, + }; + return &parser.command; + }, + else => break :conemu, + } + }, + // OSC 9;2 + '2' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .conemu_show_message_box = data[2 .. data.len - 1 :0], + }; + return &parser.command; + }, + // OSC 9;3 + '3' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + if (data.len == 2) { + parser.command = .{ + .conemu_change_tab_title = .reset, + }; + return &parser.command; + } + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .conemu_change_tab_title = .{ + .value = data[2 .. data.len - 1 :0], + }, + }; + return &parser.command; + }, + // OSC 9;4 + '4' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + if (data.len < 3) break :conemu; + switch (data[2]) { + '0' => { + parser.command = .{ + .conemu_progress_report = .{ + .state = .remove, + }, + }; + }, + '1' => { + parser.command = .{ + .conemu_progress_report = .{ + .state = .set, + .progress = 0, + }, + }; + }, + '2' => { + parser.command = .{ + .conemu_progress_report = .{ + .state = .@"error", + }, + }; + }, + '3' => { + parser.command = .{ + .conemu_progress_report = .{ + .state = .indeterminate, + }, + }; + }, + '4' => { + parser.command = .{ + .conemu_progress_report = .{ + .state = .pause, + }, + }; + }, + else => break :conemu, + } + switch (parser.command.conemu_progress_report.state) { + .remove, .indeterminate => {}, + .set, .@"error", .pause => progress: { + if (data.len < 4) break :progress; + if (data[3] != ';') break :progress; + // parse the progress value + parser.command.conemu_progress_report.progress = value: { + break :value @intCast(std.math.clamp( + std.fmt.parseUnsigned(usize, data[4..], 10) catch break :value null, + 0, + 100, + )); + }; + }, + } + return &parser.command; + }, + // OSC 9;5 + '5' => { + parser.command = .conemu_wait_input; + return &parser.command; + }, + // OSC 9;6 + '6' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .conemu_guimacro = data[2 .. data.len - 1 :0], + }; + return &parser.command; + }, + // OSC 9;7 + '7' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + parser.state = .invalid; + return null; + }, + // OSC 9;8 + '8' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + parser.state = .invalid; + return null; + }, + // OSC 9;9 + '9' => { + if (data.len < 2) break :conemu; + if (data[1] != ';') break :conemu; + parser.state = .invalid; + return null; + }, + else => break :conemu, + } + } + + // If it's not a ConEmu OSC, it's an iTerm2 notification + + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + parser.command = .{ + .show_desktop_notification = .{ + .title = "", + .body = data[0 .. data.len - 1 :0], + }, + }; + return &parser.command; +} + +test "OSC 9: show desktop notification" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("", cmd.show_desktop_notification.title); + try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); +} + +test "OSC 9: show single character desktop notification" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;H"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("", cmd.show_desktop_notification.title); + try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); +} + +test "OSC 9;1: ConEmu sleep" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;420"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: ConEmu sleep with no value default to 100ms" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep cannot exceed 10000ms" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;12345"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep invalid input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); +} + +test "OSC 9;1: conemu sleep -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;1a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: ConEmu message box" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2;hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); +} + +test "OSC 9;2: ConEmu message box invalid input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: ConEmu message box empty message" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings("", cmd.conemu_show_message_box); +} + +test "OSC 9;2: ConEmu message box spaces only message" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); +} + +test "OSC 9;2: message box -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: message box -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;2a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); +} + +test "OSC 9;3: ConEmu change tab title" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3;foo bar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_change_tab_title); + try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); +} + +test "OSC 9;3: ConEmu change tab title reset" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + const expected_command: Command = .{ .conemu_change_tab_title = .reset }; + try testing.expectEqual(expected_command, cmd); +} + +test "OSC 9;3: ConEmu change tab title spaces only" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_change_tab_title); + try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); +} + +test "OSC 9;3: change tab title -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); +} + +test "OSC 9;3: message box -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;3a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: ConEmu progress set" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: ConEmu progress set overflow" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;900"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(100, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress set single digit" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;9"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expect(cmd.conemu_progress_report.progress == 9); +} + +test "OSC 9;4: ConEmu progress set double digit" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;94"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(94, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress set extra semicolon ignored" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(100, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress remove with no progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove with double semicolon" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove ignores progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove extra semicolon" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;0;100;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); +} + +test "OSC 9;4: ConEmu progress error" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .@"error"); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress error with progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;2;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .@"error"); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: progress pause" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .pause); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress pause with progress" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;4;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .pause); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: progress -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;4;5a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); +} + +test "OSC 9;5: ConEmu wait input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_wait_input); +} + +test "OSC 9;5: ConEmu wait ignores trailing characters" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "9;5;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_wait_input); +} + +test "OSC 9;6: ConEmu guimacro 1" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;6;a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_guimacro); + try testing.expectEqualStrings("a", cmd.conemu_guimacro); +} + +test "OSC: 9;6: ConEmu guimacro 2" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;6;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_guimacro); + try testing.expectEqualStrings("ab", cmd.conemu_guimacro); +} + +test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;6"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); +} diff --git a/src/terminal/osc/parsers/report_pwd.zig b/src/terminal/osc/parsers/report_pwd.zig new file mode 100644 index 000000000..080b9cbb0 --- /dev/null +++ b/src/terminal/osc/parsers/report_pwd.zig @@ -0,0 +1,48 @@ +const std = @import("std"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +/// Parse OSC 7 +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + parser.command = .{ + .report_pwd = .{ + .value = data[0 .. data.len - 1 :0], + }, + }; + return &parser.command; +} + +test "OSC 7: report pwd" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "7;file:///tmp/example"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .report_pwd); + try testing.expectEqualStrings("file:///tmp/example", cmd.report_pwd.value); +} + +test "OSC 7: report pwd empty" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "7;"; + for (input) |ch| p.next(ch); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .report_pwd); + try testing.expectEqualStrings("", cmd.report_pwd.value); +} diff --git a/src/terminal/osc/parsers/rxvt_extension.zig b/src/terminal/osc/parsers/rxvt_extension.zig new file mode 100644 index 000000000..94a0961d2 --- /dev/null +++ b/src/terminal/osc/parsers/rxvt_extension.zig @@ -0,0 +1,59 @@ +const std = @import("std"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_rxvt_extension); + +/// Parse OSC 777 +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + // ensure that we are sentinel terminated + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + const k = std.mem.indexOfScalar(u8, data, ';') orelse { + parser.state = .invalid; + return null; + }; + const ext = data[0..k]; + if (!std.mem.eql(u8, ext, "notify")) { + log.warn("unknown rxvt extension: {s}", .{ext}); + parser.state = .invalid; + return null; + } + const t = std.mem.indexOfScalarPos(u8, data, k + 1, ';') orelse { + log.warn("rxvt notify extension is missing the title", .{}); + parser.state = .invalid; + return null; + }; + data[t] = 0; + const title = data[k + 1 .. t :0]; + const body = data[t + 1 .. data.len - 1 :0]; + parser.command = .{ + .show_desktop_notification = .{ + .title = title, + .body = body, + }, + }; + return &parser.command; +} + +test "OSC: OSC 777 show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "777;notify;Title;Body"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); +} diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig new file mode 100644 index 000000000..510fe3447 --- /dev/null +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -0,0 +1,694 @@ +const std = @import("std"); + +const string_encoding = @import("../../../os/string_encoding.zig"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_semantic_prompt); + +/// Parse OSC 133, semantic prompts +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + if (data.len == 0) { + parser.state = .invalid; + return null; + } + switch (data[0]) { + 'A' => prompt_start: { + parser.command = .{ + .prompt_start = .{}, + }; + if (data.len == 1) break :prompt_start; + if (data[1] != ';') { + parser.state = .invalid; + return null; + } + var it = SemanticPromptKVIterator.init(writer) catch { + parser.state = .invalid; + return null; + }; + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key, "aid")) { + parser.command.prompt_start.aid = kv.value; + } else if (std.mem.eql(u8, kv.key, "redraw")) redraw: { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + // Kitty supports a "redraw" option for prompt_start. I can't find + // this documented anywhere but can see in the code that this is used + // by shell environments to tell the terminal that the shell will NOT + // redraw the prompt so we should attempt to resize it. + parser.command.prompt_start.redraw = (value: { + if (kv.value.len != 1) break :value null; + switch (kv.value[0]) { + '0' => break :value false, + '1' => break :value true, + else => break :value null, + } + }) orelse { + log.info("OSC 133 A: invalid redraw value: {s}", .{kv.value}); + break :redraw; + }; + } else if (std.mem.eql(u8, kv.key, "special_key")) redraw: { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + parser.command.prompt_start.special_key = (value: { + if (kv.value.len != 1) break :value null; + switch (kv.value[0]) { + '0' => break :value false, + '1' => break :value true, + else => break :value null, + } + }) orelse { + log.info("OSC 133 A invalid special_key value: {s}", .{kv.value}); + break :redraw; + }; + } else if (std.mem.eql(u8, kv.key, "click_events")) redraw: { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + parser.command.prompt_start.click_events = (value: { + if (kv.value.len != 1) break :value null; + switch (kv.value[0]) { + '0' => break :value false, + '1' => break :value true, + else => break :value null, + } + }) orelse { + log.info("OSC 133 A invalid click_events value: {s}", .{kv.value}); + break :redraw; + }; + } else if (std.mem.eql(u8, kv.key, "k")) k: { + // The "k" marks the kind of prompt, or "primary" if we don't know. + // This can be used to distinguish between the first (initial) prompt, + // a continuation, etc. + if (kv.value.len != 1) break :k; + parser.command.prompt_start.kind = switch (kv.value[0]) { + 'c' => .continuation, + 's' => .secondary, + 'r' => .right, + 'i' => .primary, + else => .primary, + }; + } else log.info("OSC 133 A: unknown semantic prompt option: {s}", .{kv.key}); + } + }, + 'B' => prompt_end: { + parser.command = .prompt_end; + if (data.len == 1) break :prompt_end; + if (data[1] != ';') { + parser.state = .invalid; + return null; + } + var it = SemanticPromptKVIterator.init(writer) catch { + parser.state = .invalid; + return null; + }; + while (it.next()) |kv| { + log.info("OSC 133 B: unknown semantic prompt option: {s}", .{kv.key}); + } + }, + 'C' => end_of_input: { + parser.command = .{ + .end_of_input = .{}, + }; + if (data.len == 1) break :end_of_input; + if (data[1] != ';') { + parser.state = .invalid; + return null; + } + var it = SemanticPromptKVIterator.init(writer) catch { + parser.state = .invalid; + return null; + }; + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key, "cmdline")) { + parser.command.end_of_input.cmdline = string_encoding.printfQDecode(kv.value) catch null; + } else if (std.mem.eql(u8, kv.key, "cmdline_url")) { + parser.command.end_of_input.cmdline = string_encoding.urlPercentDecode(kv.value) catch null; + } else { + log.info("OSC 133 C: unknown semantic prompt option: {s}", .{kv.key}); + } + } + }, + 'D' => { + const exit_code: ?u8 = exit_code: { + if (data.len == 1) break :exit_code null; + if (data[1] != ';') { + parser.state = .invalid; + return null; + } + break :exit_code std.fmt.parseUnsigned(u8, data[2..], 10) catch null; + }; + parser.command = .{ + .end_of_command = .{ + .exit_code = exit_code, + }, + }; + }, + else => { + parser.state = .invalid; + return null; + }, + } + return &parser.command; +} + +const SemanticPromptKVIterator = struct { + index: usize, + string: []u8, + + pub const SemanticPromptKV = struct { + key: [:0]u8, + value: [:0]u8, + }; + + pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!SemanticPromptKVIterator { + // add a semicolon to make it easier to find and sentinel terminate the values + try writer.writeByte(';'); + return .{ + .index = 0, + .string = writer.buffered()[2..], + }; + } + + pub fn next(self: *SemanticPromptKVIterator) ?SemanticPromptKV { + if (self.index >= self.string.len) return null; + + const kv = kv: { + const index = std.mem.indexOfScalarPos(u8, self.string, self.index, ';') orelse { + self.index = self.string.len; + return null; + }; + self.string[index] = 0; + const kv = self.string[self.index..index :0]; + self.index = index + 1; + break :kv kv; + }; + + const key = key: { + const index = std.mem.indexOfScalar(u8, kv, '=') orelse break :key kv; + kv[index] = 0; + const key = kv[0..index :0]; + break :key key; + }; + + const value = kv[key.len + 1 .. :0]; + + return .{ + .key = key, + .value = value, + }; + } +}; + +test "OSC 133: prompt_start" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.aid == null); + try testing.expect(cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with single option" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=14"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); +} + +test "OSC 133: prompt_start with '=' in aid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=a=b;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("a=b", cmd.prompt_start.aid.?); + try testing.expect(!cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with redraw disabled" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(!cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with redraw invalid value" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=42"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.redraw); + try testing.expect(cmd.prompt_start.kind == .primary); +} + +test "OSC 133: prompt_start with continuation" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;k=c"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .continuation); +} + +test "OSC 133: prompt_start with secondary" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;k=s"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .secondary); +} + +test "OSC 133: prompt_start with special_key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == true); +} + +test "OSC 133: prompt_start with special_key invalid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key=bobr"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with special_key 0" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with special_key empty" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;special_key="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with click_events true" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == true); +} + +test "OSC 133: prompt_start with click_events false" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + +test "OSC 133: prompt_start with click_events empty" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + +test "OSC 133: end_of_command no exit code" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_command); +} + +test "OSC 133: end_of_command with exit code" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D;25"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_command); + try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); +} + +test "OSC 133: prompt_end" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;B"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_end); +} + +test "OSC 133: end_of_input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); +} + +test "OSC 133: end_of_input with cmdline 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=echo bobr\\ kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=echo bobr\\nkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr\nkurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline='echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline='echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 7" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 9" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 10" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%20kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%3bkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr;kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%3kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 7" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa%20"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa ", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 9" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index c33dba1bb..1ee4f3f08 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -4,7 +4,7 @@ const stream = @import("stream.zig"); const Action = stream.Action; const Screen = @import("Screen.zig"); const modes = @import("modes.zig"); -const osc_color = @import("osc/color.zig"); +const osc_color = @import("osc/parsers/color.zig"); const kitty_color = @import("kitty/color.zig"); const Terminal = @import("Terminal.zig"); From 856ef1fc1bed143a84c88c32812758828520de7e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 06:32:37 -0800 Subject: [PATCH 419/605] input: change the key_is_binding to return some information --- include/ghostty.h | 11 ++++++++++- src/Surface.zig | 41 +++++++++++++++++++++++++---------------- src/apprt/embedded.zig | 7 ++++++- src/input/Binding.zig | 21 +++++++++++++++++++++ 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index ff28a0cff..b884ebc08 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -102,6 +102,13 @@ typedef enum { GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, } ghostty_input_mods_e; +typedef enum { + GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0, + GHOSTTY_BINDING_FLAGS_ALL = 1 << 1, + GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2, + GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3, +} ghostty_binding_flags_e; + typedef enum { GHOSTTY_ACTION_RELEASE, GHOSTTY_ACTION_PRESS, @@ -1058,7 +1065,9 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); -bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key_is_binding(ghostty_surface_t, + ghostty_input_key_s, + ghostty_binding_flags_e*); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); diff --git a/src/Surface.zig b/src/Surface.zig index cc727826f..9998922b9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2579,7 +2579,7 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { pub fn keyEventIsBinding( self: *Surface, event_orig: input.KeyEvent, -) bool { +) ?input.Binding.Flags { // Apply key remappings for consistency with keyCallback var event = event_orig; if (self.config.key_remaps.isRemapped(event_orig.mods)) { @@ -2587,26 +2587,35 @@ pub fn keyEventIsBinding( } switch (event.action) { - .release => return false, + .release => return null, .press, .repeat => {}, } - // If we're in a sequence, check the sequence set - if (self.keyboard.sequence_set) |set| { - return set.getEvent(event) != null; - } - - // Check active key tables (inner-most to outer-most) - const table_items = self.keyboard.table_stack.items; - for (0..table_items.len) |i| { - const rev_i: usize = table_items.len - 1 - i; - if (table_items[rev_i].set.getEvent(event) != null) { - return true; + // Look up our entry + const entry: input.Binding.Set.Entry = entry: { + // If we're in a sequence, check the sequence set + if (self.keyboard.sequence_set) |set| { + break :entry set.getEvent(event) orelse return null; } - } - // Check the root set - return self.config.keybind.set.getEvent(event) != null; + // Check active key tables (inner-most to outer-most) + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + if (table_items[rev_i].set.getEvent(event)) |entry| { + break :entry entry; + } + } + + // Check the root set + break :entry self.config.keybind.set.getEvent(event) orelse return null; + }; + + // Return flags based on the + return switch (entry.value_ptr.*) { + .leader => .{}, + inline .leaf, .leaf_chained => |v| v.flags, + }; } /// Called for any key events. This handles keybindings, encoding and diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 6c1d46722..364a1bec1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1751,13 +1751,18 @@ pub const CAPI = struct { export fn ghostty_surface_key_is_binding( surface: *Surface, event: KeyEvent, + c_flags: ?*input.Binding.Flags.C, ) bool { const core_event = event.keyEvent().core() orelse { log.warn("error processing key event", .{}); return false; }; - return surface.core_surface.keyEventIsBinding(core_event); + const flags = surface.core_surface.keyEventIsBinding( + core_event, + ) orelse return false; + if (c_flags) |ptr| ptr.* = flags.cval(); + return true; } /// Send raw text to the terminal. This is treated like a paste diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 3197bb7d1..08475c7e1 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -45,6 +45,27 @@ pub const Flags = packed struct { /// performed. If the action can't be performed then the binding acts as /// if it doesn't exist. performable: bool = false, + + /// C type + pub const C = u8; + + /// Converts this to a C-compatible value. + /// + /// Sync with ghostty.h for enums. + pub fn cval(self: Flags) C { + const Backing = @typeInfo(Flags).@"struct".backing_integer.?; + return @as(Backing, @bitCast(self)); + } + + test "cval" { + const testing = std.testing; + try testing.expectEqual(@as(u8, 0b0001), (Flags{}).cval()); + try testing.expectEqual(@as(u8, 0b0000), (Flags{ .consumed = false }).cval()); + try testing.expectEqual(@as(u8, 0b0011), (Flags{ .all = true }).cval()); + try testing.expectEqual(@as(u8, 0b0101), (Flags{ .global = true }).cval()); + try testing.expectEqual(@as(u8, 0b1001), (Flags{ .performable = true }).cval()); + try testing.expectEqual(@as(u8, 0b1111), (Flags{ .consumed = true, .all = true, .global = true, .performable = true }).cval()); + } }; /// Full binding parser. The binding parser is implemented as an iterator From 49768c646496200110a60f3be0b6ffe2a369e476 Mon Sep 17 00:00:00 2001 From: Maciek Borzecki Date: Fri, 9 Jan 2026 15:48:41 +0100 Subject: [PATCH 420/605] snap: fix handling of nonexistent last_revision file Assuming /bin/sh is symlinked to bash, the handling of special builtin 'source' is slightly different between bash and bash-in-POSIX-mode (as a result of being invoked through /bin/sh). Specifically errors in builtin 'source' cannot be masked with `|| true`. Compare $ ls -l /bin/sh lrwxrwxrwx 1 root root 4 Dec 11 11:00 /bin/sh -> bash $ /bin/sh -c 'set -e ; source nofile || true; echo ok' /bin/sh: line 1: source: nofile: file not found $ /bin/bash -c 'set -e ; source nofile || true; echo ok' /bin/bash: line 1: nofile: No such file or directory ok Thus ghostty from snap would not start at all when $SNAP_USER_DATA/.last_revision does not exist causign the launcher script to exit prematurely. Signed-off-by: Maciek Borzecki --- snap/local/launcher | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/snap/local/launcher b/snap/local/launcher index 89e0d1709..71b92f5bb 100755 --- a/snap/local/launcher +++ b/snap/local/launcher @@ -14,7 +14,14 @@ if [ -z "$XDG_DATA_HOME" ]; then export XDG_DATA_HOME="$SNAP_REAL_HOME/.local/share" fi -source "$SNAP_USER_DATA/.last_revision" 2>/dev/null || true +if [ -f "$SNAP_USER_DATA/.last_revision" ]; then + if ! source "$SNAP_USER_DATA/.last_revision" 2>/dev/null; then + # file exist but sourcing it fails, so it's likely + # not good anyway + rm -f "$SNAP_USER_DATA/.last_revision" + fi +fi + if [ "$LAST_REVISION" = "$SNAP_REVISION" ]; then needs_update=false else From f34c69147a7c583a7cde667f7cd586a55c5b592b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 06:58:48 -0800 Subject: [PATCH 421/605] macos: use the new binding flags information value to trigger menu --- macos/Sources/Ghostty/Ghostty.Input.swift | 26 +++++++++++ macos/Sources/Ghostty/Ghostty.Surface.swift | 20 +++++++++ .../Surface View/SurfaceView_AppKit.swift | 45 +++++++++++-------- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 44011d5b9..4b3fb9937 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -100,6 +100,32 @@ extension Ghostty { ] } +// MARK: Ghostty.Input.BindingFlags + +extension Ghostty.Input { + /// `ghostty_binding_flags_e` + struct BindingFlags: OptionSet, Sendable { + let rawValue: UInt32 + + static let consumed = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) + static let all = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_ALL.rawValue) + static let global = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_GLOBAL.rawValue) + static let performable = BindingFlags(rawValue: GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + init(cFlags: ghostty_binding_flags_e) { + self.rawValue = cFlags.rawValue + } + + var cFlags: ghostty_binding_flags_e { + ghostty_binding_flags_e(rawValue) + } + } +} + // MARK: Ghostty.Input.KeyEvent extension Ghostty.Input { diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index e86952e50..7cb32ed71 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -62,6 +62,26 @@ extension Ghostty { } } + /// Check if a key event matches a keybinding. + /// + /// This checks whether the given key event would trigger a keybinding in the terminal. + /// If it matches, returns the binding flags indicating properties of the matched binding. + /// + /// - Parameter event: The key event to check + /// - Returns: The binding flags if a binding matches, or nil if no binding matches + @MainActor + func keyIsBinding(_ event: ghostty_input_key_s) -> Input.BindingFlags? { + var flags = ghostty_binding_flags_e(0) + guard ghostty_surface_key_is_binding(surface, event, &flags) else { return nil } + return Input.BindingFlags(cFlags: flags) + } + + /// See `keyIsBinding(_ event: ghostty_input_key_s)`. + @MainActor + func keyIsBinding(_ event: Input.KeyEvent) -> Input.BindingFlags? { + event.withCValue { keyIsBinding($0) } + } + /// Whether the terminal has captured mouse input. /// /// When the mouse is captured, the terminal application is receiving mouse events diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index ca74c7815..80de2e823 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1184,7 +1184,7 @@ extension Ghostty { // We only care about key down events. It might not even be possible // to receive any other event type here. guard event.type == .keyDown else { return false } - + // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the // the first responder (I don't know why). This prevents us from handling it. @@ -1194,26 +1194,35 @@ extension Ghostty { if (!focused) { return false } - - // Let the menu system handle this event if we're not in a key sequence or key table. - // This allows the menu bar to flash for shortcuts like Command+V. - if keySequence.isEmpty && keyTables.isEmpty { - if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { - return true + + // Get information about if this is a binding. + let bindingFlags = surfaceModel.flatMap { surface in + var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + return (event.characters ?? "").withCString { ptr in + ghosttyEvent.text = ptr + return surface.keyIsBinding(ghosttyEvent) } } - - // If the menu didn't handle it, check Ghostty bindings for custom shortcuts. - 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 + + // If this is a binding then we want to perform it. + if let bindingFlags { + // Attempt to trigger a menu item for this key binding. We only do this if: + // - We're not in a key sequence or table (those are separate bindings) + // - The binding is NOT `all` (menu uses FirstResponder chain) + // - The binding is NOT `performable` (menu will always consume) + // - The binding is `consumed` (unconsumed bindings should pass through + // to the terminal, so we must not intercept them for the menu) + if keySequence.isEmpty, + keyTables.isEmpty, + bindingFlags.isDisjoint(with: [.all, .performable]), + bindingFlags.contains(.consumed) { + if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { + return true + } } + + self.keyDown(with: event) + return true } let equivalent: String From 115351db87cc1c1c89e2d217e82e48d484a4b25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=BCller?= Date: Fri, 9 Jan 2026 16:19:50 +0100 Subject: [PATCH 422/605] docs: bell border feature is available on macOS As of commit fe55d90 and PR #8768 this feature is also available on macOS. --- src/config/Config.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2f0bef6ff..b0ef26554 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2934,8 +2934,6 @@ keybind: Keybinds = .{}, /// Display a border around the alerted surface until the terminal is /// re-focused or interacted with (such as on keyboard input). /// -/// GTK only. -/// /// Example: `audio`, `no-audio`, `system`, `no-system` /// /// Available since: 1.2.0 From c179de62a70ebd848a63bfbb34459d47b0ac918d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 08:27:27 -0800 Subject: [PATCH 423/605] extract deepEqual --- src/config/Config.zig | 94 +---------- src/datastruct/comparison.zig | 297 +++++++++++++++++++++++++++++++++- src/datastruct/main.zig | 2 + 3 files changed, 297 insertions(+), 96 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2f0bef6ff..ac52595be 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -17,6 +17,7 @@ const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const global_state = &@import("../global.zig").state; +const deepEqual = @import("../datastruct/comparison.zig").deepEqual; const fontpkg = @import("../font/main.zig"); const inputpkg = @import("../input.zig"); const internal_os = @import("../os/main.zig"); @@ -4120,7 +4121,7 @@ pub fn changeConditionalState( // Conditional set contains the keys that this config uses. So we // only continue if we use this key. - if (self._conditional_set.contains(key) and !equalField( + if (self._conditional_set.contains(key) and !deepEqual( @TypeOf(@field(self._conditional_state, field.name)), @field(self._conditional_state, field.name), @field(new, field.name), @@ -4826,7 +4827,7 @@ pub fn changed(self: *const Config, new: *const Config, comptime key: Key) bool const old_value = @field(self, field.name); const new_value = @field(new, field.name); - return !equalField(field.type, old_value, new_value); + return !deepEqual(field.type, old_value, new_value); } /// This yields a key for every changed field between old and new. @@ -4854,91 +4855,6 @@ pub const ChangeIterator = struct { } }; -/// A config-specific helper to determine if two values of the same -/// type are equal. This isn't the same as std.mem.eql or std.testing.equals -/// because we expect structs to implement their own equality. -/// -/// This also doesn't support ALL Zig types, because we only add to it -/// as we need types for the config. -fn equalField(comptime T: type, old: T, new: T) bool { - // Do known named types first - switch (T) { - inline []const u8, - [:0]const u8, - => return std.mem.eql(u8, old, new), - - []const [:0]const u8, - => { - if (old.len != new.len) return false; - for (old, new) |a, b| { - if (!std.mem.eql(u8, a, b)) return false; - } - - return true; - }, - - else => {}, - } - - // Back into types of types - switch (@typeInfo(T)) { - .void => return true, - - inline .bool, - .int, - .float, - .@"enum", - => return old == new, - - .optional => |info| { - if (old == null and new == null) return true; - if (old == null or new == null) return false; - return equalField(info.child, old.?, new.?); - }, - - .@"struct" => |info| { - if (@hasDecl(T, "equal")) return old.equal(new); - - // If a struct doesn't declare an "equal" function, we fall back - // to a recursive field-by-field compare. - inline for (info.fields) |field_info| { - if (!equalField( - field_info.type, - @field(old, field_info.name), - @field(new, field_info.name), - )) return false; - } - return true; - }, - - .@"union" => |info| { - if (@hasDecl(T, "equal")) return old.equal(new); - - const tag_type = info.tag_type.?; - const old_tag = std.meta.activeTag(old); - const new_tag = std.meta.activeTag(new); - if (old_tag != new_tag) return false; - - inline for (info.fields) |field_info| { - if (@field(tag_type, field_info.name) == old_tag) { - return equalField( - field_info.type, - @field(old, field_info.name), - @field(new, field_info.name), - ); - } - } - - unreachable; - }, - - else => { - @compileLog(T); - @compileError("unsupported field type"); - }, - } -} - /// This runs a heuristic to determine if we are likely running /// Ghostty in a CLI environment. We need this to change some behaviors. /// We should keep the set of behaviors that depend on this as small @@ -6885,7 +6801,7 @@ pub const Keybinds = struct { const self_leaf = self_entry.value_ptr.*.leaf; const other_leaf = other_entry.value_ptr.*.leaf; - if (!equalField( + if (!deepEqual( inputpkg.Binding.Set.Leaf, self_leaf, other_leaf, @@ -6899,7 +6815,7 @@ pub const Keybinds = struct { if (self_chain.flags != other_chain.flags) return false; if (self_chain.actions.items.len != other_chain.actions.items.len) return false; for (self_chain.actions.items, other_chain.actions.items) |a1, a2| { - if (!equalField( + if (!deepEqual( inputpkg.Binding.Action, a1, a2, diff --git a/src/datastruct/comparison.zig b/src/datastruct/comparison.zig index 4427c143c..6a862bb9c 100644 --- a/src/datastruct/comparison.zig +++ b/src/datastruct/comparison.zig @@ -1,11 +1,102 @@ // The contents of this file is largely based on testing.zig from the Zig 0.15.1 // stdlib, distributed under the MIT license, copyright (c) Zig contributors const std = @import("std"); +const testing = std.testing; + +/// A deep equality comparison function that works for most types. We +/// add types as necessary. It defers to `equal` decls on types that support +/// decls. +pub fn deepEqual(comptime T: type, old: T, new: T) bool { + // Do known named types first + switch (T) { + inline []const u8, + [:0]const u8, + => return std.mem.eql(u8, old, new), + + []const [:0]const u8, + => { + if (old.len != new.len) return false; + for (old, new) |a, b| { + if (!std.mem.eql(u8, a, b)) return false; + } + + return true; + }, + + else => {}, + } + + // Back into types of types + switch (@typeInfo(T)) { + .void => return true, + + inline .bool, + .int, + .float, + .@"enum", + => return old == new, + + .optional => |info| { + if (old == null and new == null) return true; + if (old == null or new == null) return false; + return deepEqual(info.child, old.?, new.?); + }, + + .array => |info| for (old, new) |old_elem, new_elem| { + if (!deepEqual( + info.child, + old_elem, + new_elem, + )) return false; + } else return true, + + .@"struct" => |info| { + if (@hasDecl(T, "equal")) return old.equal(new); + + // If a struct doesn't declare an "equal" function, we fall back + // to a recursive field-by-field compare. + inline for (info.fields) |field_info| { + if (!deepEqual( + field_info.type, + @field(old, field_info.name), + @field(new, field_info.name), + )) return false; + } + return true; + }, + + .@"union" => |info| { + if (@hasDecl(T, "equal")) return old.equal(new); + + const tag_type = info.tag_type.?; + const old_tag = std.meta.activeTag(old); + const new_tag = std.meta.activeTag(new); + if (old_tag != new_tag) return false; + + inline for (info.fields) |field_info| { + if (@field(tag_type, field_info.name) == old_tag) { + return deepEqual( + field_info.type, + @field(old, field_info.name), + @field(new, field_info.name), + ); + } + } + + unreachable; + }, + + else => { + @compileLog(T); + @compileError("unsupported field type"); + }, + } +} /// Generic, recursive equality testing utility using approximate comparison for /// floats and equality for everything else /// -/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`. +/// Based on `testing.expectEqual` and `testing.expectEqualSlices`. /// /// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`. pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void { @@ -59,7 +150,7 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void { if (union_info.tag_type == null) { // untagged unions can only be compared bitwise, // so expectEqual is all we need - std.testing.expectEqual(expected, actual) catch { + testing.expectEqual(expected, actual) catch { return error.TestExpectedApproxEqual; }; } @@ -69,7 +160,7 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void { const expectedTag = @as(Tag, expected); const actualTag = @as(Tag, actual); - std.testing.expectEqual(expectedTag, actualTag) catch { + testing.expectEqual(expectedTag, actualTag) catch { return error.TestExpectedApproxEqual; }; @@ -84,23 +175,23 @@ fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void { }; // we only reach this point if there's at least one null or error, // in which case expectEqual is all we need - std.testing.expectEqual(expected, actual) catch { + testing.expectEqual(expected, actual) catch { return error.TestExpectedApproxEqual; }; }, // fall back to expectEqual for everything else - else => std.testing.expectEqual(expected, actual) catch { + else => testing.expectEqual(expected, actual) catch { return error.TestExpectedApproxEqual; }, } } -/// Copy of std.testing.print (not public) +/// Copy of testing.print (not public) fn print(comptime fmt: []const u8, args: anytype) void { if (@inComptime()) { @compileError(std.fmt.comptimePrint(fmt, args)); - } else if (std.testing.backend_can_print) { + } else if (testing.backend_can_print) { std.debug.print(fmt, args); } } @@ -145,3 +236,195 @@ test "expectApproxEqual struct" { try expectApproxEqual(a, b); } + +test "deepEqual void" { + try testing.expect(deepEqual(void, {}, {})); +} + +test "deepEqual bool" { + try testing.expect(deepEqual(bool, true, true)); + try testing.expect(deepEqual(bool, false, false)); + try testing.expect(!deepEqual(bool, true, false)); + try testing.expect(!deepEqual(bool, false, true)); +} + +test "deepEqual int" { + try testing.expect(deepEqual(i32, 42, 42)); + try testing.expect(deepEqual(i32, -100, -100)); + try testing.expect(!deepEqual(i32, 42, 43)); + try testing.expect(deepEqual(u64, 0, 0)); + try testing.expect(!deepEqual(u64, 0, 1)); +} + +test "deepEqual float" { + try testing.expect(deepEqual(f32, 1.0, 1.0)); + try testing.expect(!deepEqual(f32, 1.0, 1.1)); + try testing.expect(deepEqual(f64, 3.14159, 3.14159)); + try testing.expect(!deepEqual(f64, 3.14159, 3.14158)); +} + +test "deepEqual enum" { + const Color = enum { red, green, blue }; + try testing.expect(deepEqual(Color, .red, .red)); + try testing.expect(deepEqual(Color, .blue, .blue)); + try testing.expect(!deepEqual(Color, .red, .green)); + try testing.expect(!deepEqual(Color, .green, .blue)); +} + +test "deepEqual []const u8" { + try testing.expect(deepEqual([]const u8, "hello", "hello")); + try testing.expect(deepEqual([]const u8, "", "")); + try testing.expect(!deepEqual([]const u8, "hello", "world")); + try testing.expect(!deepEqual([]const u8, "hello", "hell")); + try testing.expect(!deepEqual([]const u8, "hello", "hello!")); +} + +test "deepEqual [:0]const u8" { + try testing.expect(deepEqual([:0]const u8, "foo", "foo")); + try testing.expect(!deepEqual([:0]const u8, "foo", "bar")); + try testing.expect(!deepEqual([:0]const u8, "foo", "fo")); +} + +test "deepEqual []const [:0]const u8" { + const a: []const [:0]const u8 = &.{ "one", "two", "three" }; + const b: []const [:0]const u8 = &.{ "one", "two", "three" }; + const c: []const [:0]const u8 = &.{ "one", "two" }; + const d: []const [:0]const u8 = &.{ "one", "two", "four" }; + const e: []const [:0]const u8 = &.{}; + + try testing.expect(deepEqual([]const [:0]const u8, a, b)); + try testing.expect(!deepEqual([]const [:0]const u8, a, c)); + try testing.expect(!deepEqual([]const [:0]const u8, a, d)); + try testing.expect(deepEqual([]const [:0]const u8, e, e)); + try testing.expect(!deepEqual([]const [:0]const u8, a, e)); +} + +test "deepEqual optional" { + try testing.expect(deepEqual(?i32, null, null)); + try testing.expect(deepEqual(?i32, 42, 42)); + try testing.expect(!deepEqual(?i32, null, 42)); + try testing.expect(!deepEqual(?i32, 42, null)); + try testing.expect(!deepEqual(?i32, 42, 43)); +} + +test "deepEqual optional nested" { + const Nested = struct { x: i32, y: i32 }; + try testing.expect(deepEqual(?Nested, null, null)); + try testing.expect(deepEqual(?Nested, .{ .x = 1, .y = 2 }, .{ .x = 1, .y = 2 })); + try testing.expect(!deepEqual(?Nested, .{ .x = 1, .y = 2 }, .{ .x = 1, .y = 3 })); + try testing.expect(!deepEqual(?Nested, .{ .x = 1, .y = 2 }, null)); +} + +test "deepEqual array" { + try testing.expect(deepEqual([3]i32, .{ 1, 2, 3 }, .{ 1, 2, 3 })); + try testing.expect(!deepEqual([3]i32, .{ 1, 2, 3 }, .{ 1, 2, 4 })); + try testing.expect(!deepEqual([3]i32, .{ 1, 2, 3 }, .{ 0, 2, 3 })); + try testing.expect(deepEqual([0]i32, .{}, .{})); +} + +test "deepEqual nested array" { + const a = [2][2]i32{ .{ 1, 2 }, .{ 3, 4 } }; + const b = [2][2]i32{ .{ 1, 2 }, .{ 3, 4 } }; + const c = [2][2]i32{ .{ 1, 2 }, .{ 3, 5 } }; + + try testing.expect(deepEqual([2][2]i32, a, b)); + try testing.expect(!deepEqual([2][2]i32, a, c)); +} + +test "deepEqual struct" { + const Point = struct { x: i32, y: i32 }; + try testing.expect(deepEqual(Point, .{ .x = 10, .y = 20 }, .{ .x = 10, .y = 20 })); + try testing.expect(!deepEqual(Point, .{ .x = 10, .y = 20 }, .{ .x = 10, .y = 21 })); + try testing.expect(!deepEqual(Point, .{ .x = 10, .y = 20 }, .{ .x = 11, .y = 20 })); +} + +test "deepEqual struct nested" { + const Inner = struct { value: i32 }; + const Outer = struct { a: Inner, b: Inner }; + + const x = Outer{ .a = .{ .value = 1 }, .b = .{ .value = 2 } }; + const y = Outer{ .a = .{ .value = 1 }, .b = .{ .value = 2 } }; + const z = Outer{ .a = .{ .value = 1 }, .b = .{ .value = 3 } }; + + try testing.expect(deepEqual(Outer, x, y)); + try testing.expect(!deepEqual(Outer, x, z)); +} + +test "deepEqual struct with equal decl" { + const Custom = struct { + value: i32, + + pub fn equal(self: @This(), other: @This()) bool { + return @mod(self.value, 10) == @mod(other.value, 10); + } + }; + + try testing.expect(deepEqual(Custom, .{ .value = 5 }, .{ .value = 15 })); + try testing.expect(deepEqual(Custom, .{ .value = 100 }, .{ .value = 0 })); + try testing.expect(!deepEqual(Custom, .{ .value = 5 }, .{ .value = 6 })); +} + +test "deepEqual union" { + const Value = union(enum) { + int: i32, + float: f32, + none, + }; + + try testing.expect(deepEqual(Value, .{ .int = 42 }, .{ .int = 42 })); + try testing.expect(!deepEqual(Value, .{ .int = 42 }, .{ .int = 43 })); + try testing.expect(!deepEqual(Value, .{ .int = 42 }, .{ .float = 42.0 })); + try testing.expect(deepEqual(Value, .none, .none)); + try testing.expect(!deepEqual(Value, .none, .{ .int = 0 })); +} + +test "deepEqual union with equal decl" { + const Value = union(enum) { + num: i32, + str: []const u8, + + pub fn equal(self: @This(), other: @This()) bool { + return switch (self) { + .num => |n| switch (other) { + .num => |m| @mod(n, 10) == @mod(m, 10), + else => false, + }, + .str => |s| switch (other) { + .str => |t| s.len == t.len, + else => false, + }, + }; + } + }; + + try testing.expect(deepEqual(Value, .{ .num = 5 }, .{ .num = 25 })); + try testing.expect(!deepEqual(Value, .{ .num = 5 }, .{ .num = 6 })); + try testing.expect(deepEqual(Value, .{ .str = "abc" }, .{ .str = "xyz" })); + try testing.expect(!deepEqual(Value, .{ .str = "abc" }, .{ .str = "ab" })); +} + +test "deepEqual array of structs" { + const Item = struct { id: i32, name: []const u8 }; + const a = [2]Item{ .{ .id = 1, .name = "one" }, .{ .id = 2, .name = "two" } }; + const b = [2]Item{ .{ .id = 1, .name = "one" }, .{ .id = 2, .name = "two" } }; + const c = [2]Item{ .{ .id = 1, .name = "one" }, .{ .id = 2, .name = "TWO" } }; + + try testing.expect(deepEqual([2]Item, a, b)); + try testing.expect(!deepEqual([2]Item, a, c)); +} + +test "deepEqual struct with optional field" { + const Config = struct { name: []const u8, port: ?u16 }; + + try testing.expect(deepEqual(Config, .{ .name = "app", .port = 8080 }, .{ .name = "app", .port = 8080 })); + try testing.expect(deepEqual(Config, .{ .name = "app", .port = null }, .{ .name = "app", .port = null })); + try testing.expect(!deepEqual(Config, .{ .name = "app", .port = 8080 }, .{ .name = "app", .port = null })); + try testing.expect(!deepEqual(Config, .{ .name = "app", .port = 8080 }, .{ .name = "app", .port = 8081 })); +} + +test "deepEqual struct with array field" { + const Data = struct { values: [3]i32 }; + + try testing.expect(deepEqual(Data, .{ .values = .{ 1, 2, 3 } }, .{ .values = .{ 1, 2, 3 } })); + try testing.expect(!deepEqual(Data, .{ .values = .{ 1, 2, 3 } }, .{ .values = .{ 1, 2, 4 } })); +} diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 64a29269e..bfee23427 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -19,4 +19,6 @@ pub const SplitTree = split_tree.SplitTree; test { @import("std").testing.refAllDecls(@This()); + + _ = @import("comparison.zig"); } From 201198c74a8f6facc844c1564d1ebc48fa779bd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 08:27:27 -0800 Subject: [PATCH 424/605] input: do value comparison for Set hash maps We previously only compared the hashes for triggers and actions for hash map equality. I'm genuinely surprised this never bit us before because it can result in false positives when two different values have the same hash. Fix that up! --- src/input/Binding.zig | 64 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 08475c7e1..14db736ae 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -8,6 +8,7 @@ const assert = @import("../quirks.zig").inlineAssert; const build_config = @import("../build_config.zig"); const uucode = @import("uucode"); const EntryFormatter = @import("../config/formatter.zig").EntryFormatter; +const deepEqual = @import("../datastruct/comparison.zig").deepEqual; const key = @import("key.zig"); const key_mods = @import("key_mods.zig"); const KeyEvent = key.KeyEvent; @@ -1587,6 +1588,24 @@ pub const Action = union(enum) { }, } } + + /// Compares two actions for equality. + pub fn equal(self: Action, other: Action) bool { + if (std.meta.activeTag(self) != std.meta.activeTag(other)) return false; + return switch (self) { + inline else => |field_self, tag| { + const field_other = @field(other, @tagName(tag)); + return deepEqual( + @TypeOf(field_self), + field_self, + field_other, + ); + }, + }; + } + + /// For the Set.Context + const bindingSetEqual = equal; }; /// Trigger is the associated key state that can trigger an action. @@ -1908,7 +1927,7 @@ pub const Trigger = struct { } /// Returns true if two triggers are equal. - pub fn eql(self: Trigger, other: Trigger) bool { + pub fn equal(self: Trigger, other: Trigger) bool { if (self.mods != other.mods) return false; const self_tag = std.meta.activeTag(self.key); const other_tag = std.meta.activeTag(other.key); @@ -1920,6 +1939,26 @@ pub const Trigger = struct { }; } + /// Returns true if two triggers are equal using folded codepoints. + pub fn foldedEqual(self: Trigger, other: Trigger) bool { + if (self.mods != other.mods) return false; + const self_tag = std.meta.activeTag(self.key); + const other_tag = std.meta.activeTag(other.key); + if (self_tag != other_tag) return false; + return switch (self.key) { + .physical => |v| v == other.key.physical, + .unicode => |v| deepEqual( + [3]u21, + foldedCodepoint(v), + foldedCodepoint(other.key.unicode), + ), + .catch_all => true, + }; + } + + /// For the Set.Context + const bindingSetEqual = foldedEqual; + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ @@ -2674,7 +2713,7 @@ pub const Set = struct { // If our value is not the same as the old trigger, we can // ignore it because our reverse mapping points somewhere else. - if (!entry.value_ptr.eql(old)) return; + if (!entry.value_ptr.equal(old)) return; // It is the same trigger, so let's now go through our bindings // and try to find another trigger that maps to the same action. @@ -2745,7 +2784,8 @@ pub const Set = struct { } pub fn eql(ctx: @This(), a: KeyType, b: KeyType) bool { - return ctx.hash(a) == ctx.hash(b); + _ = ctx; + return a.bindingSetEqual(b); } }; } @@ -3110,63 +3150,63 @@ test "parse: all triggers" { } } -test "Trigger: eql" { +test "Trigger: equal" { const testing = std.testing; // Equal physical keys { const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; - try testing.expect(t1.eql(t2)); + try testing.expect(t1.equal(t2)); } // Different physical keys { const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; const t2: Trigger = .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true } }; - try testing.expect(!t1.eql(t2)); + try testing.expect(!t1.equal(t2)); } // Different mods { const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }; - try testing.expect(!t1.eql(t2)); + try testing.expect(!t1.equal(t2)); } // Equal unicode keys { const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; const t2: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; - try testing.expect(t1.eql(t2)); + try testing.expect(t1.equal(t2)); } // Different unicode keys { const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; const t2: Trigger = .{ .key = .{ .unicode = 'b' }, .mods = .{} }; - try testing.expect(!t1.eql(t2)); + try testing.expect(!t1.equal(t2)); } // Different key types { const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; const t2: Trigger = .{ .key = .{ .physical = .key_a }, .mods = .{} }; - try testing.expect(!t1.eql(t2)); + try testing.expect(!t1.equal(t2)); } // catch_all { const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; const t2: Trigger = .{ .key = .catch_all, .mods = .{} }; - try testing.expect(t1.eql(t2)); + try testing.expect(t1.equal(t2)); } // catch_all with different mods { const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; const t2: Trigger = .{ .key = .catch_all, .mods = .{ .alt = true } }; - try testing.expect(!t1.eql(t2)); + try testing.expect(!t1.equal(t2)); } } From 0e9ce7e450fbe4ef296a71d569c92fdd9c1756e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 08:46:35 -0800 Subject: [PATCH 425/605] input: change our binding set to use array hash map This is recommended for ongoing performance: https://github.com/ziglang/zig/issues/17851 Likely not an issue for this particular use case which is why it never bit us; we don't actively modify this map much once it is created. But, its still good hygiene and ArrayHashMap made some of the API usage nicer. --- src/config/Config.zig | 8 ++++---- src/input/Binding.zig | 43 +++++++++++++++++++++++++------------------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index ac52595be..4cd918ea6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6830,7 +6830,7 @@ pub const Keybinds = struct { /// Like formatEntry but has an option to include docs. pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void { - if (self.set.bindings.size == 0 and self.tables.count() == 0) { + if (self.set.bindings.count() == 0 and self.tables.count() == 0) { try formatter.formatEntry(void, {}); return; } @@ -6932,8 +6932,8 @@ pub const Keybinds = struct { // Note they turn into translated keys because they match // their ASCII mapping. const want = - \\keybind = ctrl+z>2=goto_tab:2 \\keybind = ctrl+z>1=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 \\ ; try std.testing.expectEqualStrings(want, buf.written()); @@ -6957,9 +6957,9 @@ pub const Keybinds = struct { // NB: This does not currently retain the order of the keybinds. const want = - \\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+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 14db736ae..57414d764 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1998,18 +1998,18 @@ pub const Trigger = struct { /// The use case is that this will be called on EVERY key input to look /// for an associated action so it must be fast. pub const Set = struct { - const HashMap = std.HashMapUnmanaged( + const HashMap = std.ArrayHashMapUnmanaged( Trigger, Value, Context(Trigger), - std.hash_map.default_max_load_percentage, + true, ); - const ReverseMap = std.HashMapUnmanaged( + const ReverseMap = std.ArrayHashMapUnmanaged( Action, Trigger, Context(Action), - std.hash_map.default_max_load_percentage, + true, ); /// The set of bindings. @@ -2503,11 +2503,10 @@ pub const Set = struct { // update the reverse mapping to remove the old action. .leaf => if (track_reverse) { const t_hash = t.hash(); - var it = self.reverse.iterator(); - while (it.next()) |reverse_entry| it: { - if (t_hash == reverse_entry.value_ptr.hash()) { - self.reverse.removeByPtr(reverse_entry.key_ptr); - break :it; + for (0.., self.reverse.values()) |i, *value| { + if (t_hash == value.hash()) { + self.reverse.swapRemoveAt(i); + break; } } }, @@ -2523,7 +2522,7 @@ pub const Set = struct { .action = action, .flags = flags, } }; - errdefer _ = self.bindings.remove(t); + errdefer _ = self.bindings.swapRemove(t); if (track_reverse) try self.reverse.put(alloc, action, t); errdefer if (track_reverse) self.reverse.remove(action); @@ -2663,7 +2662,7 @@ pub const Set = struct { self.chain_parent = null; var entry = self.bindings.get(t) orelse return; - _ = self.bindings.remove(t); + _ = self.bindings.swapRemove(t); switch (entry) { // For a leader removal, we need to deallocate our child set. @@ -2733,7 +2732,7 @@ pub const Set = struct { // No other trigger points to this action so we remove // the reverse mapping completely. - _ = self.reverse.remove(action); + _ = self.reverse.swapRemove(action); } /// Deep clone the set. @@ -2766,9 +2765,8 @@ pub const Set = struct { // We need to clone the action keys in the reverse map since // they may contain allocated values. - { - var it = result.reverse.keyIterator(); - while (it.next()) |action| action.* = try action.clone(alloc); + for (result.reverse.keys()) |*action| { + action.* = try action.clone(alloc); } return result; @@ -2778,13 +2776,22 @@ pub const Set = struct { /// gets the hash key and checks for equality. fn Context(comptime KeyType: type) type { return struct { - pub fn hash(ctx: @This(), k: KeyType) u64 { + pub fn hash(ctx: @This(), k: KeyType) u32 { _ = ctx; - return k.hash(); + // This seems crazy at first glance but this is also how + // the Zig standard library handles hashing for array + // hash maps! + return @truncate(k.hash()); } - pub fn eql(ctx: @This(), a: KeyType, b: KeyType) bool { + pub fn eql( + ctx: @This(), + a: KeyType, + b: KeyType, + b_index: usize, + ) bool { _ = ctx; + _ = b_index; return a.bindingSetEqual(b); } }; From d94ba5cf105948ddd2b2fc4160a2a4cdb472df4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20M=C3=BCller?= Date: Fri, 9 Jan 2026 18:46:00 +0100 Subject: [PATCH 426/605] docs: add bell border feature version availability Commit 22fc90f (PR #8222) on GTK and commit fe55d90 (PR #8768) on macOS. --- src/config/Config.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index b0ef26554..7958c52f1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2934,6 +2934,8 @@ keybind: Keybinds = .{}, /// Display a border around the alerted surface until the terminal is /// re-focused or interacted with (such as on keyboard input). /// +/// Available since: 1.2.0 on GTK, 1.2.1 on macOS +/// /// Example: `audio`, `no-audio`, `system`, `no-system` /// /// Available since: 1.2.0 From 6f1544b4a3c68c80dd70dfb9acd6edc4d55cac60 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 20:17:33 -0800 Subject: [PATCH 427/605] os: add mach VM tags --- src/os/mach.zig | 35 +++++++++++++++++++++++++++++++++++ src/os/main.zig | 4 ++++ 2 files changed, 39 insertions(+) create mode 100644 src/os/mach.zig diff --git a/src/os/mach.zig b/src/os/mach.zig new file mode 100644 index 000000000..3f90c05c5 --- /dev/null +++ b/src/os/mach.zig @@ -0,0 +1,35 @@ +const std = @import("std"); + +/// macOS virtual memory tags for use with mach_vm_map/mach_vm_allocate. +/// These identify memory regions in tools like vmmap and Instruments. +pub const VMTag = enum(u8) { + application_specific_1 = 240, + application_specific_2 = 241, + application_specific_3 = 242, + application_specific_4 = 243, + application_specific_5 = 244, + application_specific_6 = 245, + application_specific_7 = 246, + application_specific_8 = 247, + application_specific_9 = 248, + application_specific_10 = 249, + application_specific_11 = 250, + application_specific_12 = 251, + application_specific_13 = 252, + application_specific_14 = 253, + application_specific_15 = 254, + application_specific_16 = 255, + + // We ignore the rest because we never realistic set them. + _, + + /// Converts the tag to the format expected by mach_vm_map/mach_vm_allocate. + /// Equivalent to C macro: VM_MAKE_TAG(tag) + pub fn make(self: VMTag) i32 { + return @bitCast(@as(u32, @intFromEnum(self)) << 24); + } +}; + +test "VMTag.make" { + try std.testing.expectEqual(@as(i32, @bitCast(@as(u32, 240) << 24)), VMTag.application_specific_1.make()); +} diff --git a/src/os/main.zig b/src/os/main.zig index c105f6143..2aadabac5 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -23,6 +23,7 @@ pub const args = @import("args.zig"); pub const cgroup = @import("cgroup.zig"); pub const hostname = @import("hostname.zig"); pub const i18n = @import("i18n.zig"); +pub const mach = @import("mach.zig"); pub const path = @import("path.zig"); pub const passwd = @import("passwd.zig"); pub const xdg = @import("xdg.zig"); @@ -73,5 +74,8 @@ test { if (comptime builtin.os.tag == .linux) { _ = kernel_info; + } else if (comptime builtin.os.tag.isDarwin()) { + _ = mach; + _ = macos; } } From b426a682973202bdc09328c7bc5751ac9d6fa66c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 20:17:33 -0800 Subject: [PATCH 428/605] os: mach taggedPageAllocator --- src/os/mach.zig | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/os/mach.zig b/src/os/mach.zig index 3f90c05c5..4477bd128 100644 --- a/src/os/mach.zig +++ b/src/os/mach.zig @@ -1,4 +1,7 @@ const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const mem = std.mem; +const Allocator = std.mem.Allocator; /// macOS virtual memory tags for use with mach_vm_map/mach_vm_allocate. /// These identify memory regions in tools like vmmap and Instruments. @@ -30,6 +33,118 @@ pub const VMTag = enum(u8) { } }; +/// Creates a page allocator that tags all allocated memory with the given +/// VMTag. +pub fn taggedPageAllocator(tag: VMTag) Allocator { + return .{ + // We smuggle the tag in as the context pointer. + .ptr = @ptrFromInt(@as(usize, @intFromEnum(tag))), + .vtable = &TaggedPageAllocator.vtable, + }; +} + +/// This is based heavily on the Zig 0.15.2 PageAllocator implementation, +/// with only the posix implementation. Zig 0.15.2 is MIT licensed. +const TaggedPageAllocator = struct { + pub const vtable: Allocator.VTable = .{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }; + + fn alloc(context: *anyopaque, n: usize, alignment: mem.Alignment, ra: usize) ?[*]u8 { + _ = ra; + assert(n > 0); + const tag: VMTag = @enumFromInt(@as(u8, @truncate(@intFromPtr(context)))); + return map(n, alignment, tag); + } + + fn resize(context: *anyopaque, memory: []u8, alignment: mem.Alignment, new_len: usize, return_address: usize) bool { + _ = context; + _ = alignment; + _ = return_address; + return realloc(memory, new_len, false) != null; + } + + fn remap(context: *anyopaque, memory: []u8, alignment: mem.Alignment, new_len: usize, return_address: usize) ?[*]u8 { + _ = context; + _ = alignment; + _ = return_address; + return realloc(memory, new_len, true); + } + + fn free(context: *anyopaque, memory: []u8, alignment: mem.Alignment, return_address: usize) void { + _ = context; + _ = alignment; + _ = return_address; + return unmap(@alignCast(memory)); + } + + pub fn map(n: usize, alignment: mem.Alignment, tag: VMTag) ?[*]u8 { + const page_size = std.heap.pageSize(); + if (n >= std.math.maxInt(usize) - page_size) return null; + const alignment_bytes = alignment.toByteUnits(); + + const aligned_len = mem.alignForward(usize, n, page_size); + const max_drop_len = alignment_bytes - @min(alignment_bytes, page_size); + const overalloc_len = if (max_drop_len <= aligned_len - n) + aligned_len + else + mem.alignForward(usize, aligned_len + max_drop_len, page_size); + const hint = @atomicLoad(@TypeOf(std.heap.next_mmap_addr_hint), &std.heap.next_mmap_addr_hint, .unordered); + const slice = std.posix.mmap( + hint, + overalloc_len, + std.posix.PROT.READ | std.posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + tag.make(), + 0, + ) catch return null; + const result_ptr = mem.alignPointer(slice.ptr, alignment_bytes) orelse return null; + // Unmap the extra bytes that were only requested in order to guarantee + // that the range of memory we were provided had a proper alignment in it + // somewhere. The extra bytes could be at the beginning, or end, or both. + const drop_len = result_ptr - slice.ptr; + if (drop_len != 0) std.posix.munmap(slice[0..drop_len]); + const remaining_len = overalloc_len - drop_len; + if (remaining_len > aligned_len) std.posix.munmap(@alignCast(result_ptr[aligned_len..remaining_len])); + const new_hint: [*]align(std.heap.page_size_min) u8 = @alignCast(result_ptr + aligned_len); + _ = @cmpxchgStrong(@TypeOf(std.heap.next_mmap_addr_hint), &std.heap.next_mmap_addr_hint, hint, new_hint, .monotonic, .monotonic); + return result_ptr; + } + + pub fn unmap(memory: []align(std.heap.page_size_min) u8) void { + const page_aligned_len = mem.alignForward(usize, memory.len, std.heap.pageSize()); + std.posix.munmap(memory.ptr[0..page_aligned_len]); + } + + pub fn realloc(uncasted_memory: []u8, new_len: usize, may_move: bool) ?[*]u8 { + const memory: []align(std.heap.page_size_min) u8 = @alignCast(uncasted_memory); + const page_size = std.heap.pageSize(); + const new_size_aligned = mem.alignForward(usize, new_len, page_size); + + const page_aligned_len = mem.alignForward(usize, memory.len, page_size); + if (new_size_aligned == page_aligned_len) + return memory.ptr; + + if (std.posix.MREMAP != void) { + // TODO: if the next_mmap_addr_hint is within the remapped range, update it + const new_memory = std.posix.mremap(memory.ptr, memory.len, new_len, .{ .MAYMOVE = may_move }, null) catch return null; + return new_memory.ptr; + } + + if (new_size_aligned < page_aligned_len) { + const ptr = memory.ptr + new_size_aligned; + // TODO: if the next_mmap_addr_hint is within the unmapped range, update it + std.posix.munmap(@alignCast(ptr[0 .. page_aligned_len - new_size_aligned])); + return memory.ptr; + } + + return null; + } +}; + test "VMTag.make" { try std.testing.expectEqual(@as(i32, @bitCast(@as(u32, 240) << 24)), VMTag.application_specific_1.make()); } From 1d63045c2ffe006847716bb39720259e670ea96d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 20:17:33 -0800 Subject: [PATCH 429/605] terminal: use tagged memory for PageList ops --- src/terminal/PageList.zig | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index bebe6b700..71f5ff24a 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4,6 +4,7 @@ const PageList = @This(); const std = @import("std"); +const builtin = @import("builtin"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; @@ -255,6 +256,18 @@ fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { return PagePool.item_size * pages; } +/// This is the page allocator we'll use for all our underlying +/// VM page allocations. +inline fn pageAllocator() Allocator { + // On non-macOS we use our standard Zig page allocator. + if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator; + + // On macOS we want to tag our memory so we can assign it to our + // core terminal usage. + const mach = @import("../os/mach.zig"); + return mach.taggedPageAllocator(.application_specific_1); +} + /// Initialize the page. The top of the first page in the list is always the /// top of the active area of the screen (important knowledge for quickly /// setting up cursors in Screen). @@ -280,7 +293,11 @@ pub fn init( // The screen starts with a single page that is the entire viewport, // and we'll split it thereafter if it gets too large and add more as // necessary. - var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); + var pool = try MemoryPool.init( + alloc, + pageAllocator(), + page_preheat, + ); errdefer pool.deinit(); var page_serial: u64 = 0; const page_list, const page_size = try initPages( @@ -669,7 +686,7 @@ pub fn clone( // Setup our pools break :alloc try .init( alloc, - std.heap.page_allocator, + pageAllocator(), page_count, ); }, From 9ee78d82c02f3b3c3e79c20c02406f2de09196b1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 9 Jan 2026 20:17:33 -0800 Subject: [PATCH 430/605] terminal: fix memory leak when grow attempts to reuse non-standard page --- src/terminal/PageList.zig | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 71f5ff24a..cd4064445 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2509,13 +2509,26 @@ pub fn grow(self: *PageList) !?*List.Node { // satisfied then we do not prune. if (self.growRequiredForActive()) break :prune; - const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); - - // Get our first page and reset it to prepare for reuse. const first = self.pages.popFirst().?; assert(first != last); + + // If our first node has non-standard memory size, we can't reuse + // it. This is because our initBuf below would change the underlying + // memory length which would break our memory free outside the pool. + // It is easiest in this case to prune the node. + if (first.data.memory.len > std_size) { + // Node is already removed so we can just destroy it. + self.destroyNode(first); + break :prune; + } + + // Get the layout first so our failable work is done early. + const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); + + // Reset our memory const buf = first.data.memory; @memset(buf, 0); + assert(buf.len <= std_size); // Decrease our total row count from the pruned page and then // add one for our new row. From 235eebfa9295ef2877321656b35f160d8a3d0b5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Jan 2026 06:55:21 -0800 Subject: [PATCH 431/605] terminal: during test, use the testing allocator for pages --- src/terminal/PageList.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index cd4064445..2acdfc9c3 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -259,6 +259,9 @@ fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { /// This is the page allocator we'll use for all our underlying /// VM page allocations. inline fn pageAllocator() Allocator { + // In tests we use our testing allocator so we can detect leaks. + if (builtin.is_test) return std.testing.allocator; + // On non-macOS we use our standard Zig page allocator. if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator; From 509f0733667d7631266f787ea800ba05d3672cec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Jan 2026 06:58:19 -0800 Subject: [PATCH 432/605] terminal: fix up our total row and pin accounting during reuse --- src/terminal/PageList.zig | 111 ++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 23 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 2acdfc9c3..1cf4b376c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2496,6 +2496,10 @@ pub fn grow(self: *PageList) !?*List.Node { // Slower path: we have no space, we need to allocate a new page. + // Get the layout first so our failable work is done early. + // We'll need this for both paths. + const cap = try std_capacity.adjust(.{ .cols = self.cols }); + // If allocation would exceed our max size, we prune the first page. // We don't need to reallocate because we can simply reuse that first // page. @@ -2515,28 +2519,8 @@ pub fn grow(self: *PageList) !?*List.Node { const first = self.pages.popFirst().?; assert(first != last); - // If our first node has non-standard memory size, we can't reuse - // it. This is because our initBuf below would change the underlying - // memory length which would break our memory free outside the pool. - // It is easiest in this case to prune the node. - if (first.data.memory.len > std_size) { - // Node is already removed so we can just destroy it. - self.destroyNode(first); - break :prune; - } - - // Get the layout first so our failable work is done early. - const layout = Page.layout(try std_capacity.adjust(.{ .cols = self.cols })); - - // Reset our memory - const buf = first.data.memory; - @memset(buf, 0); - assert(buf.len <= std_size); - - // Decrease our total row count from the pruned page and then - // add one for our new row. + // Decrease our total row count from the pruned page self.total_rows -= first.data.size.rows; - self.total_rows += 1; // If we have a pin viewport cache then we need to update it. if (self.viewport == .pin) viewport: { @@ -2554,10 +2538,26 @@ pub fn grow(self: *PageList) !?*List.Node { } } + // If our first node has non-standard memory size, we can't reuse + // it. This is because our initBuf below would change the underlying + // memory length which would break our memory free outside the pool. + // It is easiest in this case to prune the node. + if (first.data.memory.len > std_size) { + // Node is already removed so we can just destroy it. + self.destroyNode(first); + break :prune; + } + + // Reset our memory + const buf = first.data.memory; + @memset(buf, 0); + assert(buf.len <= std_size); + // Initialize our new page and reinsert it as the last - first.data = .initBuf(.init(buf), layout); + first.data = .initBuf(.init(buf), Page.layout(cap)); first.data.size.rows = 1; self.pages.insertAfter(last, first); + self.total_rows += 1; // We also need to reset the serial number. Since this is the only // place we ever reuse a serial number, we also can safely set @@ -2587,7 +2587,7 @@ pub fn grow(self: *PageList) !?*List.Node { } // We need to allocate a new memory buffer. - const next_node = try self.createPage(try std_capacity.adjust(.{ .cols = self.cols })); + const next_node = try self.createPage(cap); // we don't errdefer this because we've added it to the linked // list and its fine to have dangling unused pages. self.pages.append(next_node); @@ -11018,3 +11018,68 @@ test "PageList resize grow cols with unwrap fixes viewport pin" { const br_after = s.getBottomRight(.viewport); try testing.expect(br_after != null); } + +test "PageList grow reuses non-standard page without leak" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create a PageList with 3 * std_size max so we can fit multiple pages + // but will still trigger reuse. + var s = try init(alloc, 80, 24, 3 * std_size); + defer s.deinit(); + + // Save the first page node before adjustment + const first_before = s.pages.first.?; + + // Adjust the first page to have non-standard capacity. We use a small + // increase that makes it just slightly larger than std_size. + _ = try s.adjustCapacity(first_before, .{ .grapheme_bytes = std_size + 1 }); + + // The first page should now have non-standard memory size. + try testing.expect(s.pages.first.?.data.memory.len > std_size); + + // First, fill up the first page's capacity + const first_page = s.pages.first.?; + while (first_page.data.size.rows < first_page.data.capacity.rows) { + _ = try s.grow(); + } + + // Now grow to create a second page + _ = try s.grow(); + try testing.expect(s.pages.first != s.pages.last); + + // Continue growing until we exceed max_size AND the last page is full + while (s.page_size + PagePool.item_size <= s.maxSize() or + s.pages.last.?.data.size.rows < s.pages.last.?.data.capacity.rows) + { + _ = try s.grow(); + } + + // The first page should still be non-standard + try testing.expect(s.pages.first.?.data.memory.len > std_size); + + // Verify we have enough rows for active area (so prune path isn't skipped) + try testing.expect(!s.growRequiredForActive()); + + // Verify last page is full (so grow will need to allocate/reuse) + try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows); + + // Remember the first page memory pointer before the reuse attempt + const first_page_ptr = s.pages.first.?; + const first_page_mem_ptr = s.pages.first.?.data.memory.ptr; + + // Now grow one more time to trigger the reuse path. Since the first page + // is non-standard, it should be destroyed (not reused). The testing + // allocator will detect a leak if destroyNode doesn't properly free + // the non-standard memory. + _ = try s.grow(); + + // After grow, check if the first page is a different one + // (meaning the non-standard page was pruned, not reused at the end) + // The original first page should no longer be the first page + try testing.expect(s.pages.first.? != first_page_ptr); + + // If the non-standard page was properly destroyed and not reused, + // the last page should not have the same memory pointer + try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr); +} From 50516ed581a80f78492424b1e6d75d97146c2654 Mon Sep 17 00:00:00 2001 From: rhodes-b <59537185+rhodes-b@users.noreply.github.com> Date: Sat, 10 Jan 2026 19:42:30 -0600 Subject: [PATCH 433/605] add search selection for GTK --- src/Surface.zig | 1 + src/apprt/gtk/class/application.zig | 8 ++++---- src/apprt/gtk/class/search_overlay.zig | 7 +++++++ src/apprt/gtk/class/surface.zig | 6 +++++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 9998922b9..4103b91fb 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5221,6 +5221,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .search_selection => { const selection = try self.selectionString(self.alloc) orelse return false; + defer self.alloc.free(selection); return try self.rt_app.performAction( .{ .surface = self }, .start_search, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 848aa22db..bc83c09a4 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -734,7 +734,7 @@ pub const Application = extern struct { .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), - .start_search => Action.startSearch(target), + .start_search => Action.startSearch(target, value), .end_search => Action.endSearch(target), .search_total => Action.searchTotal(target, value), .search_selected => Action.searchSelected(target, value), @@ -2439,17 +2439,17 @@ const Action = struct { } } - pub fn startSearch(target: apprt.Target) void { + pub fn startSearch(target: apprt.Target, value: apprt.action.StartSearch) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.surface.setSearchActive(true), + .surface => |v| v.rt_surface.surface.setSearchActive(true, value.needle), } } pub fn endSearch(target: apprt.Target) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.surface.setSearchActive(false), + .surface => |v| v.rt_surface.surface.setSearchActive(false, ""), } } diff --git a/src/apprt/gtk/class/search_overlay.zig b/src/apprt/gtk/class/search_overlay.zig index 4936cd967..a81115462 100644 --- a/src/apprt/gtk/class/search_overlay.zig +++ b/src/apprt/gtk/class/search_overlay.zig @@ -250,6 +250,13 @@ pub const SearchOverlay = extern struct { priv.active = active; } + // Set contents of search + pub fn setSearchContents(self: *Self, content: [:0]const u8) void { + const priv = self.private(); + priv.search_entry.as(gtk.Editable).setText(content); + signals.@"search-changed".impl.emit(self, null, .{content}, null); + } + /// Set the total number of search matches. pub fn setSearchTotal(self: *Self, total: ?usize) void { const priv = self.private(); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 0d6c64433..d1eb144e9 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -2091,7 +2091,7 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec); } - pub fn setSearchActive(self: *Self, active: bool) void { + pub fn setSearchActive(self: *Self, active: bool, needle: [:0]const u8) void { const priv = self.private(); var value = gobject.ext.Value.newFrom(active); defer value.unset(); @@ -2101,6 +2101,10 @@ pub const Surface = extern struct { &value, ); + if (!std.mem.eql(u8, needle, "")) { + priv.search_overlay.setSearchContents(needle); + } + if (active) { priv.search_overlay.grabFocus(); } From 6037e2194ae301e82ac88e74a3fa8ddefe73727b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 10 Jan 2026 20:28:49 -0800 Subject: [PATCH 434/605] terminal: remove the ability to reuse a pool from PageList This complicates logic and is unused. --- src/terminal/PageList.zig | 90 +++++++++++---------------------------- src/terminal/Screen.zig | 19 +-------- 2 files changed, 27 insertions(+), 82 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 1cf4b376c..1d2eda6b9 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -109,7 +109,6 @@ pub const MemoryPool = struct { /// The memory pool we get page nodes, pages from. pool: MemoryPool, -pool_owned: bool, /// The list of pages in the screen. pages: List, @@ -324,7 +323,6 @@ pub fn init( .cols = cols, .rows = rows, .pool = pool, - .pool_owned = true, .pages = page_list, .page_serial = page_serial, .page_serial_min = 0, @@ -519,11 +517,7 @@ pub fn deinit(self: *PageList) void { // Deallocate all the pages. We don't need to deallocate the list or // nodes because they all reside in the pool. - if (self.pool_owned) { - self.pool.deinit(); - } else { - self.pool.reset(.{ .retain_capacity = {} }); - } + self.pool.deinit(); } /// Reset the PageList back to an empty state. This is similar to @@ -641,14 +635,6 @@ pub const Clone = struct { top: point.Point, bot: ?point.Point = null, - /// The allocator source for the clone operation. If this is alloc - /// then the cloned pagelist will own and dealloc the memory on deinit. - /// If this is pool then the caller owns the memory. - memory: union(enum) { - alloc: Allocator, - pool: *MemoryPool, - }, - // If this is non-null then cloning will attempt to remap the tracked // pins into the new cloned area and will keep track of the old to // new mapping in this map. If this is null, the cloned pagelist will @@ -670,37 +656,26 @@ pub const Clone = struct { /// rows will be added to the bottom of the region to make up the difference. pub fn clone( self: *const PageList, + alloc: Allocator, opts: Clone, ) !PageList { var it = self.pageIterator(.right_down, opts.top, opts.bot); - // Setup our own memory pool if we have to. - var owned_pool: ?MemoryPool = switch (opts.memory) { - .pool => null, - .alloc => |alloc| alloc: { - // First, count our pages so our preheat is exactly what we need. - var it_copy = it; - const page_count: usize = page_count: { - var count: usize = 0; - while (it_copy.next()) |_| count += 1; - break :page_count count; - }; - - // Setup our pools - break :alloc try .init( - alloc, - pageAllocator(), - page_count, - ); - }, + // First, count our pages so our preheat is exactly what we need. + var it_copy = it; + const page_count: usize = page_count: { + var count: usize = 0; + while (it_copy.next()) |_| count += 1; + break :page_count count; }; - errdefer if (owned_pool) |*pool| pool.deinit(); - // Create our memory pool we use - const pool: *MemoryPool = switch (opts.memory) { - .pool => |v| v, - .alloc => &owned_pool.?, - }; + // Setup our pool + var pool: MemoryPool = try .init( + alloc, + pageAllocator(), + page_count, + ); + errdefer pool.deinit(); // Our viewport pin is always undefined since our viewport in a clones // goes back to the top @@ -729,7 +704,7 @@ pub fn clone( // Clone the page. We have to use createPageExt here because // we don't know if the source page has a standard size. const node = try createPageExt( - pool, + &pool, chunk.node.data.capacity, &page_serial, &page_size, @@ -770,11 +745,7 @@ pub fn clone( } var result: PageList = .{ - .pool = pool.*, - .pool_owned = switch (opts.memory) { - .pool => false, - .alloc => true, - }, + .pool = pool, .pages = page_list, .page_serial = page_serial, .page_serial_min = 0, @@ -7141,9 +7112,8 @@ test "PageList clone" { defer s.deinit(); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{} }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); @@ -7158,10 +7128,9 @@ test "PageList clone partial trimmed right" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); try s.growRows(30); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{} }, .bot = .{ .screen = .{ .y = 39 } }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, 40), s2.totalRows()); @@ -7176,9 +7145,8 @@ test "PageList clone partial trimmed left" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); try s.growRows(30); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{ .y = 10 } }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, 40), s2.totalRows()); @@ -7220,9 +7188,8 @@ test "PageList clone partial trimmed left reclaims styles" { try testing.expectEqual(1, page.styles.count()); } - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{ .y = 10 } }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, 40), s2.totalRows()); @@ -7243,10 +7210,9 @@ test "PageList clone partial trimmed both" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); try s.growRows(30); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{ .y = 10 } }, .bot = .{ .screen = .{ .y = 35 } }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, 26), s2.totalRows()); @@ -7260,9 +7226,8 @@ test "PageList clone less than active" { defer s.deinit(); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .active = .{ .y = 5 } }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); @@ -7282,9 +7247,8 @@ test "PageList clone remap tracked pin" { var pin_remap = Clone.TrackedPinsRemap.init(alloc); defer pin_remap.deinit(); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .active = .{ .y = 5 } }, - .memory = .{ .alloc = alloc }, .tracked_pins = &pin_remap, }); defer s2.deinit(); @@ -7311,9 +7275,8 @@ test "PageList clone remap tracked pin not in cloned area" { var pin_remap = Clone.TrackedPinsRemap.init(alloc); defer pin_remap.deinit(); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .active = .{ .y = 5 } }, - .memory = .{ .alloc = alloc }, .tracked_pins = &pin_remap, }); defer s2.deinit(); @@ -7335,9 +7298,8 @@ test "PageList clone full dirty" { s.markDirty(.{ .active = .{ .x = 0, .y = 12 } }); s.markDirty(.{ .active = .{ .x = 0, .y = 23 } }); - var s2 = try s.clone(.{ + var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{} }, - .memory = .{ .alloc = alloc }, }); defer s2.deinit(); try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index ba2af2473..2861e02e5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -396,18 +396,6 @@ pub fn clone( alloc: Allocator, top: point.Point, bot: ?point.Point, -) !Screen { - return try self.clonePool(alloc, null, top, bot); -} - -/// Same as clone but you can specify a custom memory pool to use for -/// the screen. -pub fn clonePool( - self: *const Screen, - alloc: Allocator, - pool: ?*PageList.MemoryPool, - top: point.Point, - bot: ?point.Point, ) !Screen { // Create a tracked pin remapper for our selection and cursor. Note // that we may want to expose this generally in the future but at the @@ -415,14 +403,9 @@ pub fn clonePool( var pin_remap = PageList.Clone.TrackedPinsRemap.init(alloc); defer pin_remap.deinit(); - var pages = try self.pages.clone(.{ + var pages = try self.pages.clone(alloc, .{ .top = top, .bot = bot, - .memory = if (pool) |p| .{ - .pool = p, - } else .{ - .alloc = alloc, - }, .tracked_pins = &pin_remap, }); errdefer pages.deinit(); From 7e3c9f4d5a9638b95e6d3b1d2237658989f2c723 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 11 Jan 2026 10:57:10 -0500 Subject: [PATCH 435/605] shell-integration: initial nushell shell integration Nushell is a modern interactive shell that provides many shell features out-of-the-box, like `title` support. Our shell integration therefore focuses on Ghostty-specific features like `sudo`. We use Nushell's module system to provide a `ghostty` module containing our shell integration features. This module is automatically loaded from $XDG_DATA_DIRS/nushell/vendor/autoload/ when `nushell` shell integration is enabled. Exported module functions need to be explicitly "used" before they're available to the interactive shell environment. We do that automatically by adding `--execute "use ghostty *"` to the `nu` command line. This imports all available functions, and individual shell features are runtime-guarded by the script code (using $GHOSTTY_SHELL_FEATURES). We can consider further refining this later. When automatic shell integration is disabled, users can still manually source and enable the shell integration module: source $GHOSTTY_RESOURCES_DIR/shell-integration/nushell/vendor/autoload/ghostty.nu use ghostty * This initial work implements our TERMINFO-aware `sudo` wrapper (via the `sudo` shell feature). Support for additional features, like `ssh-env` and `ssh-terminfo`, will follow. --- src/config/Config.zig | 3 +- src/shell-integration/README.md | 18 ++ .../nushell/vendor/autoload/ghostty.nu | 35 ++++ src/termio/Exec.zig | 1 + src/termio/shell_integration.zig | 171 ++++++++++++++++-- 5 files changed, 212 insertions(+), 16 deletions(-) create mode 100644 src/shell-integration/nushell/vendor/autoload/ghostty.nu diff --git a/src/config/Config.zig b/src/config/Config.zig index f13eba358..7689899de 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2667,7 +2667,7 @@ keybind: Keybinds = .{}, /// /// * `detect` - Detect the shell based on the filename. /// -/// * `bash`, `elvish`, `fish`, `zsh` - Use this specific shell injection scheme. +/// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme. /// /// The default value is `detect`. @"shell-integration": ShellIntegration = .detect, @@ -8027,6 +8027,7 @@ pub const ShellIntegration = enum { bash, elvish, fish, + nushell, zsh, }; diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 9c422ef26..8809134d2 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -76,6 +76,24 @@ allowing us to automatically integrate with the shell. For details on the Fish startup process, see the [Fish documentation](https://fishshell.com/docs/current/language.html). +### Nushell + +For [Nushell](https://www.nushell.sh/), Ghostty prepends to the +`XDG_DATA_DIRS` directory, making the `ghostty` module available through +Nushell's vendor autoload mechanism. Ghostty then automatically imports +the module using the `-e "use ghostty *"` flag when starting Nushell. + +Nushell provides many shell features itself, such as `title` and `cursor`, +so our integration focuses on Ghostty-specific features like `sudo`. + +The shell integration is automatically enabled when running Nushell in Ghostty, +but you can also load it manually is shell integration is disabled: + +```nushell +source $GHOSTTY_RESOURCES_DIR/shell-integration/nushell/vendor/autoload/ghostty.nu +use ghostty * +``` + ### Zsh Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu new file mode 100644 index 000000000..467e3f529 --- /dev/null +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -0,0 +1,35 @@ +# Ghostty shell integration +export module ghostty { + def has_feature [feature: string] { + $feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',') + } + + # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable + export def --wrapped sudo [ + ...args # Arguments to pass to `sudo` + ] { + mut sudo_args = $args + + if (has_feature "sudo") { + # Extract just the sudo options (before the command) + let sudo_options = ($args | take until {|arg| + not (($arg | str starts-with "-") or ($arg | str contains "=")) + }) + + # Prepend TERMINFO preservation flag if not using sudoedit + if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) { + $sudo_args = ($args | prepend "--preserve-env=TERMINFO") + } + } + + ^sudo ...$sudo_args + } +} + +# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR +if 'GHOSTTY_SHELL_INTEGRATION_XDG_DIR' in $env { + if 'XDG_DATA_DIRS' in $env { + $env.XDG_DATA_DIRS = ($env.XDG_DATA_DIRS | str replace $"($env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR):" "") + } + hide-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 93ad835c5..0e7cdc172 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -770,6 +770,7 @@ const Subprocess = struct { .bash => .bash, .elvish => .elvish, .fish => .fish, + .nushell => .nushell, .zsh => .zsh, }; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index b9477e090..94000110a 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -14,6 +14,7 @@ pub const Shell = enum { bash, elvish, fish, + nushell, zsh, }; @@ -57,7 +58,7 @@ pub fn setup( env, ), - .elvish, .fish => try setupXdgDataDirs( + .nushell => try setupNushell( alloc_arena, command, resource_dir, @@ -70,6 +71,11 @@ pub fn setup( resource_dir, env, ), + + .elvish, .fish => xdg: { + if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; + break :xdg try command.clone(alloc_arena); + }, } orelse return null; return .{ @@ -153,6 +159,7 @@ fn detectShell(alloc: Allocator, command: config.Command) !?Shell { if (std.mem.eql(u8, "elvish", exe)) return .elvish; if (std.mem.eql(u8, "fish", exe)) return .fish; + if (std.mem.eql(u8, "nu", exe)) return .nushell; if (std.mem.eql(u8, "zsh", exe)) return .zsh; return null; @@ -166,6 +173,7 @@ test detectShell { try testing.expectEqual(.bash, try detectShell(alloc, .{ .shell = "bash" })); try testing.expectEqual(.elvish, try detectShell(alloc, .{ .shell = "elvish" })); try testing.expectEqual(.fish, try detectShell(alloc, .{ .shell = "fish" })); + try testing.expectEqual(.nushell, try detectShell(alloc, .{ .shell = "nu" })); try testing.expectEqual(.zsh, try detectShell(alloc, .{ .shell = "zsh" })); if (comptime builtin.target.os.tag.isDarwin()) { @@ -373,11 +381,8 @@ fn setupBash( } } - // Get the command string from the builder, then copy it to the arena - // allocator. The stackFallback allocator's memory becomes invalid after - // this function returns, so we must copy to the arena. - const cmd_str = try cmd.toOwnedSlice(); - return .{ .shell = try alloc.dupeZ(u8, cmd_str) }; + // Return a copy of our modified command line to use as the shell command. + return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; } test "bash" { @@ -595,10 +600,9 @@ test "bash: missing resources" { /// from `XDG_DATA_DIRS` when integration is complete. fn setupXdgDataDirs( alloc: Allocator, - command: config.Command, resource_dir: []const u8, env: *EnvMap, -) !?config.Command { +) !bool { var path_buf: [std.fs.max_path_bytes]u8 = undefined; // Get our path to the shell integration directory. @@ -609,7 +613,7 @@ fn setupXdgDataDirs( ); var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { log.warn("unable to open {s}: {}", .{ integ_path, err }); - return null; + return false; }; integ_dir.close(); @@ -640,7 +644,7 @@ fn setupXdgDataDirs( ), ); - return try command.clone(alloc); + return true; } test "xdg: empty XDG_DATA_DIRS" { @@ -656,8 +660,7 @@ test "xdg: empty XDG_DATA_DIRS" { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env); - try testing.expectEqualStrings("xdg", command.?.shell); + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); var path_buf: [std.fs.max_path_bytes]u8 = undefined; try testing.expectEqualStrings( @@ -685,8 +688,7 @@ test "xdg: existing XDG_DATA_DIRS" { try env.put("XDG_DATA_DIRS", "/opt/share"); - const command = try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, res.path, &env); - try testing.expectEqualStrings("xdg", command.?.shell); + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); var path_buf: [std.fs.max_path_bytes]u8 = undefined; try testing.expectEqualStrings( @@ -714,7 +716,146 @@ test "xdg: missing resources" { var env = EnvMap.init(alloc); defer env.deinit(); - try testing.expect(try setupXdgDataDirs(alloc, .{ .shell = "xdg" }, resources_dir, &env) == null); + try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env)); + try testing.expectEqual(0, env.count()); +} + +/// Set up automatic Nushell shell integration. This works by adding our +/// shell resource directory to the `XDG_DATA_DIRS` environment variable, +/// which Nushell will use to load `nushell/vendor/autoload/ghostty.nu`. +/// +/// We then add `--execute 'use ghostty ...'` to the nu command line to +/// automatically enable our shelll features. +fn setupNushell( + alloc: Allocator, + command: config.Command, + resource_dir: []const u8, + env: *EnvMap, +) !?config.Command { + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); + + // Iterator that yields each argument in the original command line. + // This will allocate once proportionate to the command line length. + var iter = try command.argIterator(alloc); + defer iter.deinit(); + + // Start accumulating arguments with the executable and initial flags. + if (iter.next()) |exe| { + try cmd.appendArg(exe); + } else return null; + + // Tell nu to immediately "use" all of the exported functions in our + // 'ghostty' module. + // + // We can consider making this more specific based on the set of + // enabled shell features (e.g. `use ghostty sudo`). At the moment, + // shell features are all runtime-guarded in the nushell script. + try cmd.appendArg("--execute 'use ghostty *'"); + + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration module. + // + // Unsupported options: + // -c / --command -c is always non-interactive + // --lsp --lsp starts the language server + while (iter.next()) |arg| { + if (std.mem.eql(u8, arg, "--command") or std.mem.eql(u8, arg, "--lsp")) { + return null; + } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') { + if (std.mem.indexOfScalar(u8, arg, 'c') != null) { + return null; + } + try cmd.appendArg(arg); + } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + try cmd.appendArg(arg); + while (iter.next()) |remaining_arg| { + try cmd.appendArg(remaining_arg); + } + break; + } else { + try cmd.appendArg(arg); + } + } + + if (!try setupXdgDataDirs(alloc, resource_dir, env)) return null; + + // Return a copy of our modified command line to use as the shell command. + return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; +} + +test "nushell" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .nushell); + defer res.deinit(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + const command = try setupNushell(alloc, .{ .shell = "nu" }, res.path, &env); + try testing.expectEqualStrings("nu --execute 'use ghostty *'", command.?.shell); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectStringStartsWith( + env.get("XDG_DATA_DIRS").?, + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + ); +} + +test "nushell: unsupported options" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var res: TmpResourcesDir = try .init(alloc, .nushell); + defer res.deinit(); + + const cmdlines = [_][:0]const u8{ + "nu --command exit", + "nu --lsp", + "nu -c script.sh", + "nu -ic script.sh", + }; + + for (cmdlines) |cmdline| { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupNushell(alloc, .{ .shell = cmdline }, res.path, &env) == null); + try testing.expectEqual(0, env.count()); + } +} + +test "nushell: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupNushell(alloc, .{ .shell = "nu" }, resources_dir, &env) == null); try testing.expectEqual(0, env.count()); } From 9434203725f68283b8346705bd1a339b18eabf85 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 11 Jan 2026 17:05:08 -0500 Subject: [PATCH 436/605] shell-integration: always set up XDG_DATA_DIRS for nushell This makes our 'ghostty' module available even if the rest of our automatic integration steps fail, which is convenient for manual "use"-age. This is safe because autoload-ing our module doesn't have any side effects other than cleaning up the XDG_DATA_DIRS environment variable. --- src/termio/shell_integration.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 94000110a..ab6dcd6ff 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -732,6 +732,11 @@ fn setupNushell( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { + // Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This + // makes our 'ghostty' module automatically available, even if any + // of the later checks abort the rest of our automatic integration. + if (!try setupXdgDataDirs(alloc, resource_dir, env)) return null; + var stack_fallback = std.heap.stackFallback(4096, alloc); var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); defer cmd.deinit(); @@ -783,8 +788,6 @@ fn setupNushell( } } - if (!try setupXdgDataDirs(alloc, resource_dir, env)) return null; - // Return a copy of our modified command line to use as the shell command. return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; } @@ -836,7 +839,8 @@ test "nushell: unsupported options" { defer env.deinit(); try testing.expect(try setupNushell(alloc, .{ .shell = cmdline }, res.path, &env) == null); - try testing.expectEqual(0, env.count()); + try testing.expect(env.get("XDG_DATA_DIRS") != null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR") != null); } } From b995595953bdd7d6537a553827308c803539f49d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 11 Jan 2026 14:24:24 -0800 Subject: [PATCH 437/605] terminal: tracked pins need to be invalidated for the non-std page Fixes a regression from #10251 Thanks to @grishy again for finding this. Updated tests to catch it, too. --- src/terminal/PageList.zig | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 1d2eda6b9..a96aa1975 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2509,6 +2509,18 @@ pub fn grow(self: *PageList) !?*List.Node { } } + // Update any tracked pins that point to this page to point to the + // new first page to the top-left, and mark them as garbage. + const pin_keys = self.tracked_pins.keys(); + for (pin_keys) |p| { + if (p.node != first) continue; + p.node = self.pages.first.?; + p.y = 0; + p.x = 0; + p.garbage = true; + } + self.viewport_pin.garbage = false; + // If our first node has non-standard memory size, we can't reuse // it. This is because our initBuf below would change the underlying // memory length which would break our memory free outside the pool. @@ -2538,18 +2550,6 @@ pub fn grow(self: *PageList) !?*List.Node { first.serial = self.page_serial; self.page_serial += 1; - // Update any tracked pins that point to this page to point to the - // new first page to the top-left. - const pin_keys = self.tracked_pins.keys(); - for (pin_keys) |p| { - if (p.node != first) continue; - p.node = self.pages.first.?; - p.y = 0; - p.x = 0; - p.garbage = true; - } - self.viewport_pin.garbage = false; - // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. @@ -11030,6 +11030,10 @@ test "PageList grow reuses non-standard page without leak" { const first_page_ptr = s.pages.first.?; const first_page_mem_ptr = s.pages.first.?.data.memory.ptr; + // Create a tracked pin pointing to the non-standard first page + const tracked_pin = try s.trackPin(.{ .node = first_page_ptr, .x = 0, .y = 0 }); + defer s.untrackPin(tracked_pin); + // Now grow one more time to trigger the reuse path. Since the first page // is non-standard, it should be destroyed (not reused). The testing // allocator will detect a leak if destroyNode doesn't properly free @@ -11044,4 +11048,10 @@ test "PageList grow reuses non-standard page without leak" { // If the non-standard page was properly destroyed and not reused, // the last page should not have the same memory pointer try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr); + + // The tracked pin should have been moved to the new first page and marked as garbage + try testing.expectEqual(s.pages.first.?, tracked_pin.node); + try testing.expectEqual(0, tracked_pin.x); + try testing.expectEqual(0, tracked_pin.y); + try testing.expect(tracked_pin.garbage); } From 2bdbda20fd105d193f106116f7358c6c38ca030a Mon Sep 17 00:00:00 2001 From: Peter Cardenas <16930781+PeterCardenas@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:57:27 -0800 Subject: [PATCH 438/605] fix(completions.fish): add +help and +version to completions --- src/extra/fish.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/extra/fish.zig b/src/extra/fish.zig index 12343c62f..73fa9a706 100644 --- a/src/extra/fish.zig +++ b/src/extra/fish.zig @@ -28,8 +28,6 @@ fn writeCompletions(writer: *std.Io.Writer) !void { try writer.writeAll("set -l commands \""); var count: usize = 0; for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; if (count > 0) try writer.writeAll(" "); try writer.writeAll("+"); try writer.writeAll(field.name); @@ -98,8 +96,6 @@ fn writeCompletions(writer: *std.Io.Writer) !void { try writer.writeAll("complete -c ghostty -n \"string match -q -- '+*' (commandline -pt)\" -f -a \""); var count: usize = 0; for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; if (count > 0) try writer.writeAll(" "); try writer.writeAll("+"); try writer.writeAll(field.name); From e47272878d5da048125b49b882518e483a27da9d Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 11 Jan 2026 19:26:27 -0500 Subject: [PATCH 439/605] extra: enable +help and +version in bash and zsh --- src/extra/bash.zig | 9 --------- src/extra/zsh.zig | 6 ------ 2 files changed, 15 deletions(-) diff --git a/src/extra/bash.zig b/src/extra/bash.zig index ee9a7895c..14bf3a225 100644 --- a/src/extra/bash.zig +++ b/src/extra/bash.zig @@ -158,9 +158,6 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void { ); for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; - const options = @field(Action, field.name).options(); // assumes options will never be created with only <_name> members if (@typeInfo(options).@"struct".fields.len == 0) continue; @@ -194,9 +191,6 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void { ); for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; - const options = @field(Action, field.name).options(); if (@typeInfo(options).@"struct".fields.len == 0) continue; @@ -272,9 +266,6 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void { ); for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; - try writer.writeAll(pad1 ++ "topLevel+=\" +" ++ field.name ++ "\"\n"); } diff --git a/src/extra/zsh.zig b/src/extra/zsh.zig index 2fad4234a..376db807f 100644 --- a/src/extra/zsh.zig +++ b/src/extra/zsh.zig @@ -139,9 +139,6 @@ fn writeZshCompletions(writer: *std.Io.Writer) !void { var count: usize = 0; const padding = " "; for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; - try writer.writeAll(padding ++ "'+"); try writer.writeAll(field.name); try writer.writeAll("'\n"); @@ -168,9 +165,6 @@ fn writeZshCompletions(writer: *std.Io.Writer) !void { { const padding = " "; for (@typeInfo(Action).@"enum".fields) |field| { - if (std.mem.eql(u8, "help", field.name)) continue; - if (std.mem.eql(u8, "version", field.name)) continue; - const options = @field(Action, field.name).options(); // assumes options will never be created with only <_name> members if (@typeInfo(options).@"struct".fields.len == 0) continue; From 5c8c7c627c3f186b1acae2d84ceae9b5e2e6dc0b Mon Sep 17 00:00:00 2001 From: MithicSpirit Date: Sun, 11 Jan 2026 17:37:57 -0500 Subject: [PATCH 440/605] nix: update nixpkgs, remove zig.hook, and remove x11-gnome As of NixOS/nixpkgs#473413[1], `zig.hook` no longer supports `zig_default_flags`, and now they can and must be provided in `zigBuildFlags` instead. Updating also requires removing gnome-xorg since it has been removed from nixpkgs. [1] https://github.com/NixOS/nixpkgs/pull/473413 --- flake.lock | 15 +++++++-------- flake.nix | 3 +-- nix/package.nix | 16 +++++----------- nix/tests.nix | 2 +- nix/vm/common-gnome.nix | 2 +- nix/vm/x11-gnome.nix | 9 --------- 6 files changed, 15 insertions(+), 32 deletions(-) delete mode 100644 nix/vm/x11-gnome.nix diff --git a/flake.lock b/flake.lock index a80c2f8ae..853fb27b6 100644 --- a/flake.lock +++ b/flake.lock @@ -41,27 +41,26 @@ ] }, "locked": { - "lastModified": 1755776884, - "narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=", + "lastModified": 1768068402, + "narHash": "sha256-bAXnnJZKJiF7Xr6eNW6+PhBf1lg2P1aFUO9+xgWkXfA=", "owner": "nix-community", "repo": "home-manager", - "rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86", + "rev": "8bc5473b6bc2b6e1529a9c4040411e1199c43b4c", "type": "github" }, "original": { "owner": "nix-community", - "ref": "release-25.05", "repo": "home-manager", "type": "github" } }, "nixpkgs": { "locked": { - "lastModified": 1763191728, - "narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=", - "rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c", + "lastModified": 1768032153, + "narHash": "sha256-zvxtwlM8ZlulmZKyYCQAPpkm5dngSEnnHjmjV7Teloc=", + "rev": "3146c6aa9995e7351a398e17470e15305e6e18ff", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre925418.3146c6aa9995/nixexprs.tar.xz" }, "original": { "type": "tarball", diff --git a/flake.nix b/flake.nix index d70f23513..58e250aec 100644 --- a/flake.nix +++ b/flake.nix @@ -35,7 +35,7 @@ }; home-manager = { - url = "github:nix-community/home-manager?ref=release-25.05"; + url = "github:nix-community/home-manager"; inputs = { nixpkgs.follows = "nixpkgs"; }; @@ -117,7 +117,6 @@ wayland-gnome = runVM ./nix/vm/wayland-gnome.nix; wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix; x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix; - x11-gnome = runVM ./nix/vm/x11-gnome.nix; x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix; x11-xfce = runVM ./nix/vm/x11-xfce.nix; }; diff --git a/nix/package.nix b/nix/package.nix index 3d00648ec..b2decc7bc 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -20,16 +20,6 @@ wayland-scanner, pkgs, }: let - # The Zig hook has no way to select the release type without actual - # overriding of the default flags. - # - # TODO: Once - # https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is - # ultimately acted on and has made its way to a nixpkgs implementation, this - # can probably be removed in favor of that. - zig_hook = zig_0_15.hook.overrideAttrs { - zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize} --color off"; - }; gi_typelib_path = import ./build-support/gi-typelib-path.nix { inherit pkgs lib stdenv; }; @@ -73,7 +63,7 @@ in ncurses pandoc pkg-config - zig_hook + zig_0_15 gobject-introspection wrapGAppsHook4 blueprint-compiler @@ -92,12 +82,16 @@ in GI_TYPELIB_PATH = gi_typelib_path; + dontSetZigDefaultFlags = true; + zigBuildFlags = [ "--system" "${finalAttrs.deps}" "-Dversion-string=${finalAttrs.version}-${revision}-nix" "-Dgtk-x11=${lib.boolToString enableX11}" "-Dgtk-wayland=${lib.boolToString enableWayland}" + "-Dcpu=baseline" + "-Doptimize=${optimize}" "-Dstrip=${lib.boolToString strip}" ]; diff --git a/nix/tests.nix b/nix/tests.nix index a9970e80c..3949877cf 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -274,7 +274,7 @@ in { client.succeed("${su "${ghostty} +new-window"}") client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") - with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.") + with subtest("SSH from client to server and verify that the Ghostty terminfo is copied."): client.sleep(2) client.send_chars("ssh ghostty@server\n") server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index ab4aab9e9..d8d484071 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -8,7 +8,7 @@ ./common.nix ]; - services.xserver = { + services = { displayManager = { gdm = { enable = true; diff --git a/nix/vm/x11-gnome.nix b/nix/vm/x11-gnome.nix deleted file mode 100644 index 1994aea82..000000000 --- a/nix/vm/x11-gnome.nix +++ /dev/null @@ -1,9 +0,0 @@ -{...}: { - imports = [ - ./common-gnome.nix - ]; - - services.displayManager = { - defaultSession = "gnome-xorg"; - }; -} From 87b11e08929a7e8d5b80dcb8b14f18a25e969883 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 11 Jan 2026 22:54:03 -0800 Subject: [PATCH 441/605] Add failing tests for #10265 --- src/terminal/Terminal.zig | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8bb167cd1..903e427d4 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5419,6 +5419,52 @@ test "Terminal: insertLines top/bottom scroll region" { } } +test "Terminal: insertLines across page boundary marks all shifted rows dirty" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 1024 }); + defer t.deinit(alloc); + + const first_page = t.screens.active.pages.pages.first.?; + const first_page_nrows = first_page.data.capacity.rows; + + // Fill up the first page minus 3 rows + for (0..first_page_nrows - 3) |_| try t.linefeed(); + + // Add content that will cross a page boundary + try t.printString("1AAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("2BBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("3CCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("4DDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("5EEEE"); + + // Verify we now have a second page + try testing.expect(first_page.next != null); + + t.setCursorPos(1, 1); + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n1AAAA\n2BBBB\n3CCCC\n4DDDD", str); + } +} + test "Terminal: insertLines (legacy test)" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 2, .rows = 5 }); @@ -8042,6 +8088,52 @@ test "Terminal: deleteLines colors with bg color" { } } +test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 1024 }); + defer t.deinit(alloc); + + const first_page = t.screens.active.pages.pages.first.?; + const first_page_nrows = first_page.data.capacity.rows; + + // Fill up the first page minus 3 rows + for (0..first_page_nrows - 3) |_| try t.linefeed(); + + // Add content that will cross a page boundary + try t.printString("1AAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("2BBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("3CCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("4DDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("5EEEE"); + + // Verify we now have a second page + try testing.expect(first_page.next != null); + + t.setCursorPos(1, 1); + t.clearDirty(); + t.deleteLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2BBBB\n3CCCC\n4DDDD\n5EEEE", str); + } +} + test "Terminal: deleteLines (legacy)" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 80, .rows = 80 }); From 095c82910b2df5302c75934cb5f7b13bdd759efc Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 11 Jan 2026 22:56:03 -0800 Subject: [PATCH 442/605] Terminal: keep cross-boundary rows dirty in {insert,delete}Lines --- src/terminal/Terminal.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 903e427d4..7b384f34e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1690,6 +1690,10 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Continue the loop to try handling this row again. continue; }; + + // The clone operation may overwrite the dirty flag, so make + // sure the row is still marked dirty. + dst_row.dirty = true; } else { if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the @@ -1888,6 +1892,10 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Continue the loop to try handling this row again. continue; }; + + // The clone operation may overwrite the dirty flag, so make + // sure the row is still marked dirty. + dst_row.dirty = true; } else { if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the From f37b0c56ecfc3b6ce3b7156c26cd1416652bcbdf Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 12 Jan 2026 09:56:31 -0500 Subject: [PATCH 443/605] Keep track of run's max cluster seen. --- src/font/shaper/coretext.zig | 409 +++++++++++++++++++++++++++++------ 1 file changed, 337 insertions(+), 72 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index b61c8a62d..d36151698 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -6,6 +6,7 @@ const macos = @import("macos"); const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); +const unicode = @import("../../unicode/main.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -103,7 +104,7 @@ pub const Shaper = struct { } }; - const CellOffset = struct { + const Offset = struct { cluster: u32 = 0, x: f64 = 0, }; @@ -382,11 +383,12 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current x offset (sum of advance.width) - var run_offset_x: f64 = 0.0; + // This keeps track of the current x offset (sum of advance.width) and + // the furthest cluster we've seen so far (max). + var run_offset: Offset = .{}; // This keeps track of the cell starting x and cluster. - var cell_offset: CellOffset = .{}; + var cell_offset: Offset = .{}; // For debugging positions, turn this on: //var run_offset_y: f64 = 0.0; @@ -436,12 +438,12 @@ pub const Shaper = struct { const cluster = state.codepoints.items[index].cluster; if (cell_offset.cluster != cluster) { // We previously asserted that the new cluster is greater - // than cell_offset.cluster, but for rtl text this is not - // true. We then used to break out of this block if the new - // cluster was less than cell_offset.cluster, but now this - // would fail to reset cell_offset.x and - // cell_offset.cluster and lead to incorrect shape.Cell `x` - // and `x_offset`. We don't have a test case for RTL, yet. + // than cell_offset.cluster, but this isn't always true. + // See e.g. the "shape Chakma vowel sign with ligature + // (vowel sign renders first)" test. + + const is_after_glyph_from_current_or_next_clusters = + cluster <= run_offset.cluster; const is_first_codepoint_in_cluster = blk: { var i = index; @@ -457,22 +459,38 @@ pub const Shaper = struct { // We need to reset the `cell_offset` at the start of a new // cluster, but we do that conditionally if the codepoint - // `is_first_codepoint_in_cluster`, which is a heuristic to - // detect ligatures and avoid positioning glyphs that mark - // ligatures incorrectly. The idea is that if the first - // codepoint in a cluster doesn't appear in the stream, - // it's very likely that it combined with codepoints from a - // previous cluster into a ligature. Then, the subsequent - // codepoints are very likely marking glyphs that are - // placed relative to that ligature, so if we were to reset - // the `cell_offset` to align it with the grid, the - // positions would be off. (TBD if there are exceptions to - // this heuristic, but using the logging below seems to - // show it works well.) - if (is_first_codepoint_in_cluster) { + // `is_first_codepoint_in_cluster` and the cluster is not + // `is_after_glyph_from_current_or_next_clusters`, which is + // a heuristic to detect ligatures and avoid positioning + // glyphs that mark ligatures incorrectly. The idea is that + // if the first codepoint in a cluster doesn't appear in + // the stream, it's very likely that it combined with + // codepoints from a previous cluster into a ligature. + // Then, the subsequent codepoints are very likely marking + // glyphs that are placed relative to that ligature, so if + // we were to reset the `cell_offset` to align it with the + // grid, the positions would be off. The + // `!is_after_glyph_from_current_or_next_clusters` check is + // needed in case these marking glyphs come from a later + // cluster but are rendered first (see the Chakma and + // Bengali tests). In that case when we get to the + // codepoint that `is_first_codepoint_in_cluster`, but in a + // cluster that + // `is_after_glyph_from_current_or_next_clusters`, we don't + // want to reset to the grid and cause the positions to be + // off. (Note that we could go back and align the cells to + // the grid starting from the one from the cluster that + // rendered out of order, but that is more complicated so + // we don't do that for now. Also, it's TBD if there are + // exceptions to this heuristic for detecting ligatures, + // but using the logging below seems to show it works + // well.) + if (is_first_codepoint_in_cluster and + !is_after_glyph_from_current_or_next_clusters) + { cell_offset = .{ .cluster = cluster, - .x = run_offset_x, + .x = run_offset.x, }; // For debugging positions, turn this on: @@ -481,7 +499,7 @@ pub const Shaper = struct { } // For debugging positions, turn this on: - //try self.debugPositions(alloc, run_offset_x, run_offset_y, cell_offset, cell_offset_y, position, index); + //try self.debugPositions(alloc, run_offset, run_offset_y, cell_offset, cell_offset_y, position, index); const x_offset = position.x - cell_offset.x; @@ -494,7 +512,8 @@ pub const Shaper = struct { // Add our advances to keep track of our run offsets. // Advances apply to the NEXT cell. - run_offset_x += advance.width; + run_offset.x += advance.width; + run_offset.cluster = @max(run_offset.cluster, cluster); // For debugging positions, turn this on: //run_offset_y += advance.height; @@ -670,16 +689,16 @@ pub const Shaper = struct { fn debugPositions( self: *Shaper, alloc: Allocator, - run_offset_x: f64, + run_offset: Offset, run_offset_y: f64, - cell_offset: CellOffset, + cell_offset: Offset, cell_offset_y: f64, position: macos.graphics.Point, index: usize, ) !void { const state = &self.run_state; const x_offset = position.x - cell_offset.x; - const advance_x_offset = run_offset_x - cell_offset.x; + const advance_x_offset = run_offset.x - cell_offset.x; const advance_y_offset = run_offset_y - cell_offset_y; const x_offset_diff = x_offset - advance_x_offset; const y_offset_diff = position.y - advance_y_offset; @@ -689,7 +708,30 @@ pub const Shaper = struct { const cluster = state.codepoints.items[index].cluster; const cluster_differs = cluster != cell_offset.cluster; - if (positions_differ or position_y_differs or cluster_differs) { + // To debug every loop, flip this to true: + const extra_debugging = false; + + const is_previous_codepoint_prepend = if (cluster_differs or + extra_debugging) + blk: { + var i = index; + while (i > 0) { + i -= 1; + const codepoint = state.codepoints.items[i]; + + // Skip surrogate pair padding + if (codepoint.codepoint == 0) continue; + + break :blk unicode.table.get(@intCast(codepoint.codepoint)).grapheme_boundary_class == .prepend; + } + break :blk false; + } else false; + + const formatted_cps = if (positions_differ or + position_y_differs or + cluster_differs or + extra_debugging) + blk: { var allocating = std.Io.Writer.Allocating.init(alloc); const writer = &allocating.writer; const codepoints = state.codepoints.items; @@ -725,49 +767,69 @@ pub const Shaper = struct { try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); } } - const formatted_cps = try allocating.toOwnedSlice(); + break :blk try allocating.toOwnedSlice(); + } else ""; - if (positions_differ) { - log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) cps = {s}", .{ - cluster, - x_offset, - position.y, - advance_x_offset, - advance_y_offset, - x_offset_diff, - y_offset_diff, - formatted_cps, - }); - } + if (extra_debugging) { + log.warn("extra debugging of positions index={d} cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) is_prev_prepend={} cps = {s}", .{ + index, + cell_offset.cluster, + cluster, + run_offset.cluster, + cluster - cell_offset.cluster, + x_offset, + position.y, + run_offset.x, + run_offset_y, + cell_offset.x, + cell_offset_y, + is_previous_codepoint_prepend, + formatted_cps, + }); + } - if (position_y_differs) { - log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} cps = {s}", .{ - cluster, - x_offset, - position.y, - run_offset_x, - run_offset_y, - cell_offset.x, - cell_offset_y, - old_offset_y, - formatted_cps, - }); - } + if (positions_differ) { + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) cps = {s}", .{ + cluster, + x_offset, + position.y, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + formatted_cps, + }); + } - if (cluster_differs) { - log.warn("cell_offset.cluster differs from cluster (potential ligature detected) cell_offset.cluster={d} cluster={d} diff={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) cps = {s}", .{ - cell_offset.cluster, - cluster, - cluster - cell_offset.cluster, - x_offset, - position.y, - run_offset_x, - run_offset_y, - cell_offset.x, - cell_offset_y, - formatted_cps, - }); - } + if (position_y_differs) { + log.warn("position.y differs from old offset.y: cluster={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) old offset.y={d:.2} cps = {s}", .{ + cluster, + x_offset, + position.y, + run_offset.x, + run_offset_y, + cell_offset.x, + cell_offset_y, + old_offset_y, + formatted_cps, + }); + } + + if (cluster_differs) { + log.warn("cell_offset.cluster differs from cluster (potential ligature detected) cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d:.2},{d:.2}) run_offset=({d:.2},{d:.2}) cell_offset=({d:.2},{d:.2}) is_prev_prepend={} cps = {s}", .{ + cell_offset.cluster, + cluster, + run_offset.cluster, + cluster - cell_offset.cluster, + x_offset, + position.y, + run_offset.x, + run_offset_y, + cell_offset.x, + cell_offset_y, + is_previous_codepoint_prepend, + formatted_cps, + }); } } }; @@ -1636,7 +1698,7 @@ test "shape Tai Tham letters (position.y differs from advance)" { try testing.expectEqual(@as(usize, 3), cells.len); try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 1), cells[2].x); // U from second grapheme + try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme // The U glyph renders at a y below zero try testing.expectEqual(@as(i16, -3), cells[2].y_offset); @@ -1644,6 +1706,209 @@ test "shape Tai Tham letters (position.y differs from advance)" { try testing.expectEqual(@as(usize, 1), count); } +test "shape Javanese ligatures" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Javanese for this to work, if we can't find + // Noto Sans Javanese Regular, which is a system font on macOS, we just + // skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Javanese", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA + buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON + // Second grapheme cluster, combining with the first in a ligature: + buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA + buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + + // The vowel sign SUKU renders with correct x_offset + try testing.expect(cells[2].x_offset > 3 * cell_width); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Chakma vowel sign with ligature (vowel sign renders first)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Chakma for this to work, if we can't find + // Noto Sans Chakma Regular, which is a system font on macOS, we just skip + // the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Chakma", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1111d, buf[buf_idx..]); // BAA + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x11116, buf[buf_idx..]); // TAA + buf_idx += try std.unicode.utf8Encode(0x11133, buf[buf_idx..]); // Virama + // Third grapheme cluster, combining with the second in a ligature: + buf_idx += try std.unicode.utf8Encode(0x11120, buf[buf_idx..]); // YYAA + buf_idx += try std.unicode.utf8Encode(0x1112c, buf[buf_idx..]); // Vowel Sign U + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 4), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // don't take on that complexity. + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 0), cells[3].x); + + // The vowel sign U renders before the TAA: + try testing.expect(cells[1].x_offset < cells[2].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Bengali ligatures with out of order vowels" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Bengali for this to work, if we can't find + // Arial Unicode MS, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Arial Unicode MS", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA + buf_idx += try std.unicode.utf8Encode(0x09be, buf[buf_idx..]); // Vowel sign AA + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x09b7, buf[buf_idx..]); // SSA + buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama + // Third grapheme cluster, combining with the second in a ligature: + buf_idx += try std.unicode.utf8Encode(0x099f, buf[buf_idx..]); // TTA + buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama + // Fourth grapheme cluster, combining with the previous two in a ligature: + buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA + buf_idx += try std.unicode.utf8Encode(0x09c7, buf[buf_idx..]); // Vowel sign E + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 8), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // don't take on that complexity. + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 0), cells[3].x); + try testing.expectEqual(@as(u16, 0), cells[4].x); + try testing.expectEqual(@as(u16, 0), cells[5].x); + try testing.expectEqual(@as(u16, 0), cells[6].x); + try testing.expectEqual(@as(u16, 0), cells[7].x); + + // The vowel sign E renders before the SSA: + try testing.expect(cells[2].x_offset < cells[3].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; @@ -2381,7 +2646,7 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS .monospace = false, }); defer disco_it.deinit(); - var face: font.DeferredFace = (try disco_it.next()).?; + var face: font.DeferredFace = (try disco_it.next()) orelse return error.FontNotFound; errdefer face.deinit(); _ = try c.add( alloc, From 2a0a57506524999a21192410eb06e630f2a9052b Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 12 Jan 2026 10:22:37 -0500 Subject: [PATCH 444/605] comment fixup --- src/font/shaper/coretext.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index d36151698..223b2382b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1823,7 +1823,7 @@ test "shape Chakma vowel sign with ligature (vowel sign renders first)" { // See the giant "We need to reset the `cell_offset`" comment, but here // we should technically have the rest of these be `x` of 1, but that // would require going back in the stream to adjust past cells, and - // don't take on that complexity. + // we don't take on that complexity. try testing.expectEqual(@as(u16, 0), cells[1].x); try testing.expectEqual(@as(u16, 0), cells[2].x); try testing.expectEqual(@as(u16, 0), cells[3].x); @@ -1895,7 +1895,7 @@ test "shape Bengali ligatures with out of order vowels" { // See the giant "We need to reset the `cell_offset`" comment, but here // we should technically have the rest of these be `x` of 1, but that // would require going back in the stream to adjust past cells, and - // don't take on that complexity. + // we don't take on that complexity. try testing.expectEqual(@as(u16, 0), cells[2].x); try testing.expectEqual(@as(u16, 0), cells[3].x); try testing.expectEqual(@as(u16, 0), cells[4].x); From 7ed19689b9791ab2f64fdaa4c088da31507a1d71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 12 Jan 2026 08:35:15 -0800 Subject: [PATCH 445/605] terminal: add Capacity.maxCols --- src/terminal/PageList.zig | 8 +++ src/terminal/page.zig | 113 +++++++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index a96aa1975..546f6c2e2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -49,7 +49,15 @@ const Node = struct { /// The memory pool we get page nodes from. const NodePool = std.heap.MemoryPool(List.Node); +/// The standard page capacity that we use as a starting point for +/// all pages. This is chosen as a sane default that fits most terminal +/// usage to support using our pool. const std_capacity = pagepkg.std_capacity; + +/// The maximum columns we can support with the standard capacity. +const std_max_cols = std_capacity.maxCols().?; + +/// The byte size required for a standard page. const std_size = Page.layout(std_capacity).total_size; /// The memory pool we use for page memory buffers. We use a separate pool diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 124ff2545..6e6416e4e 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1662,43 +1662,42 @@ pub const Capacity = struct { cols: ?size.CellCountInt = null, }; + /// Returns the maximum number of columns that can be used with this + /// capacity while still fitting at least one row. Returns null if even + /// a single column cannot fit (which would indicate an unusable capacity). + /// + /// Note that this is the maximum number of columns that never increases + /// the amount of memory the original capacity will take. If you modify + /// the original capacity to add rows, then you can fit more columns. + pub fn maxCols(self: Capacity) ?size.CellCountInt { + const available_bits = self.availableBitsForGrid(); + + // If we can't even fit the row metadata, return null + if (available_bits <= @bitSizeOf(Row)) return null; + + // We do the math of how many columns we can fit in the remaining + // bits ignoring the metadat of a row. + const remaining_bits = available_bits - @bitSizeOf(Row); + const max_cols = remaining_bits / @bitSizeOf(Cell); + + // Clamp to CellCountInt max + return @min(std.math.maxInt(size.CellCountInt), max_cols); + } + /// Adjust the capacity parameters while retaining the same total size. + /// /// Adjustments always happen by limiting the rows in the page. Everything /// else can grow. If it is impossible to achieve the desired adjustment, /// OutOfMemory is returned. pub fn adjust(self: Capacity, req: Adjustment) Allocator.Error!Capacity { var adjusted = self; if (req.cols) |cols| { - // The math below only works if there is no alignment gap between - // the end of the rows array and the start of the cells array. - // - // To guarantee this, we assert that Row's size is a multiple of - // Cell's alignment, so that any length array of Rows will end on - // a valid alignment for the start of the Cell array. - assert(@sizeOf(Row) % @alignOf(Cell) == 0); - - const layout = Page.layout(self); - - // In order to determine the amount of space in the page available - // for rows & cells (which will allow us to calculate the number of - // rows we can fit at a certain column width) we need to layout the - // "meta" members of the page (i.e. everything else) from the end. - const hyperlink_map_start = alignBackward(usize, layout.total_size - layout.hyperlink_map_layout.total_size, hyperlink.Map.base_align.toByteUnits()); - const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - layout.hyperlink_set_layout.total_size, hyperlink.Set.base_align.toByteUnits()); - const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits()); - const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits()); - const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits()); - const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, StyleSet.base_align.toByteUnits()); + const available_bits = self.availableBitsForGrid(); // The size per row is: // - The row metadata itself // - The cells per row (n=cols) - const bits_per_row: usize = size: { - var bits: usize = @bitSizeOf(Row); // Row metadata - bits += @bitSizeOf(Cell) * @as(usize, @intCast(cols)); // Cells (n=cols) - break :size bits; - }; - const available_bits: usize = styles_start * 8; + const bits_per_row: usize = @bitSizeOf(Row) + @bitSizeOf(Cell) * @as(usize, @intCast(cols)); const new_rows: usize = @divFloor(available_bits, bits_per_row); // If our rows go to zero then we can't fit any row metadata @@ -1711,6 +1710,34 @@ pub const Capacity = struct { return adjusted; } + + /// Computes the number of bits available for rows and cells in the page. + /// + /// This is done by laying out the "meta" members (styles, graphemes, + /// hyperlinks, strings) from the end of the page and finding where they + /// start, which gives us the space available for rows and cells. + fn availableBitsForGrid(self: Capacity) usize { + // The math below only works if there is no alignment gap between + // the end of the rows array and the start of the cells array. + // + // To guarantee this, we assert that Row's size is a multiple of + // Cell's alignment, so that any length array of Rows will end on + // a valid alignment for the start of the Cell array. + assert(@sizeOf(Row) % @alignOf(Cell) == 0); + + const l = Page.layout(self); + + // Layout meta members from the end to find styles_start + const hyperlink_map_start = alignBackward(usize, l.total_size - l.hyperlink_map_layout.total_size, hyperlink.Map.base_align.toByteUnits()); + const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - l.hyperlink_set_layout.total_size, hyperlink.Set.base_align.toByteUnits()); + const string_alloc_start = alignBackward(usize, hyperlink_set_start - l.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits()); + const grapheme_map_start = alignBackward(usize, string_alloc_start - l.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits()); + const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - l.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits()); + const styles_start = alignBackward(usize, grapheme_alloc_start - l.styles_layout.total_size, StyleSet.base_align.toByteUnits()); + + // Multiply by 8 to convert bytes to bits + return styles_start * 8; + } }; pub const Row = packed struct(u64) { @@ -2070,6 +2097,40 @@ test "Page capacity adjust cols too high" { ); } +test "Capacity maxCols basic" { + const cap = std_capacity; + const max = cap.maxCols().?; + + // maxCols should be >= current cols (since current capacity is valid) + try testing.expect(max >= cap.cols); + + // Adjusting to maxCols should succeed with at least 1 row + const adjusted = try cap.adjust(.{ .cols = max }); + try testing.expect(adjusted.rows >= 1); + + // Adjusting to maxCols + 1 should fail + try testing.expectError( + error.OutOfMemory, + cap.adjust(.{ .cols = max + 1 }), + ); +} + +test "Capacity maxCols preserves total size" { + const cap = std_capacity; + const original_size = Page.layout(cap).total_size; + const max = cap.maxCols().?; + const adjusted = try cap.adjust(.{ .cols = max }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); +} + +test "Capacity maxCols with 1 row exactly" { + const cap = std_capacity; + const max = cap.maxCols().?; + const adjusted = try cap.adjust(.{ .cols = max }); + try testing.expectEqual(@as(size.CellCountInt, 1), adjusted.rows); +} + test "Page init" { var page = try Page.init(.{ .cols = 120, From 257aafb7b44db09252e1ae5a8d63a9e8920f788d Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 12 Jan 2026 09:32:33 -0800 Subject: [PATCH 446/605] Consolidate dirty marking in insertLines/deleteLines --- src/terminal/Terminal.zig | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 7b384f34e..d717a9724 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1602,9 +1602,6 @@ pub fn insertLines(self: *Terminal, count: usize) void { const cur_rac = cur_p.rowAndCell(); const cur_row: *Row = cur_rac.row; - // Mark the row as dirty - cur_p.markDirty(); - // If this is one of the lines we need to shift, do so if (y > adjusted_count) { const off_p = cur_p.up(adjusted_count).?; @@ -1690,10 +1687,6 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Continue the loop to try handling this row again. continue; }; - - // The clone operation may overwrite the dirty flag, so make - // sure the row is still marked dirty. - dst_row.dirty = true; } else { if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the @@ -1703,9 +1696,6 @@ pub fn insertLines(self: *Terminal, count: usize) void { dst_row.* = src_row.*; src_row.* = dst; - // Make sure the row is marked as dirty though. - dst_row.dirty = true; - // Ensure what we did didn't corrupt the page cur_p.node.data.assertIntegrity(); } else { @@ -1732,6 +1722,9 @@ pub fn insertLines(self: *Terminal, count: usize) void { ); } + // Mark the row as dirty + cur_p.markDirty(); + // We have successfully processed a line. y -= 1; // Move our pin up to the next row. @@ -1809,9 +1802,6 @@ pub fn deleteLines(self: *Terminal, count: usize) void { const cur_rac = cur_p.rowAndCell(); const cur_row: *Row = cur_rac.row; - // Mark the row as dirty - cur_p.markDirty(); - // If this is one of the lines we need to shift, do so if (y < rem - adjusted_count) { const off_p = cur_p.down(adjusted_count).?; @@ -1892,10 +1882,6 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Continue the loop to try handling this row again. continue; }; - - // The clone operation may overwrite the dirty flag, so make - // sure the row is still marked dirty. - dst_row.dirty = true; } else { if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the @@ -1905,9 +1891,6 @@ pub fn deleteLines(self: *Terminal, count: usize) void { dst_row.* = src_row.*; src_row.* = dst; - // Make sure the row is marked as dirty though. - dst_row.dirty = true; - // Ensure what we did didn't corrupt the page cur_p.node.data.assertIntegrity(); } else { @@ -1934,6 +1917,9 @@ pub fn deleteLines(self: *Terminal, count: usize) void { ); } + // Mark the row as dirty + cur_p.markDirty(); + // We have successfully processed a line. y += 1; // Move our pin down to the next row. From d512de7e7839d580d02cb2560fc6733c81b7d058 Mon Sep 17 00:00:00 2001 From: MithicSpirit Date: Mon, 12 Jan 2026 00:22:18 -0500 Subject: [PATCH 447/605] nix: update zon2nix After the last commit, zon2nix required recompiling zig, which caused slow build times and CI failures. --- flake.lock | 8 ++++---- flake.nix | 2 +- nix/devShell.nix | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/flake.lock b/flake.lock index 853fb27b6..be298785c 100644 --- a/flake.lock +++ b/flake.lock @@ -125,17 +125,17 @@ ] }, "locked": { - "lastModified": 1758405547, - "narHash": "sha256-WgaDgvIZMPvlZcZrpPMjkaalTBnGF2lTG+62znXctWM=", + "lastModified": 1768231828, + "narHash": "sha256-wL/8Iij4T2OLkhHcc4NieOjf7YeJffaUYbCiCqKv/+0=", "owner": "jcollie", "repo": "zon2nix", - "rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245", + "rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071", "type": "github" }, "original": { "owner": "jcollie", "repo": "zon2nix", - "rev": "bf983aa90ff169372b9fa8c02e57ea75e0b42245", + "rev": "c28e93f3ba133d4c1b1d65224e2eebede61fd071", "type": "github" } } diff --git a/flake.nix b/flake.nix index 58e250aec..a854f6ea3 100644 --- a/flake.nix +++ b/flake.nix @@ -28,7 +28,7 @@ }; zon2nix = { - url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245"; + url = "github:jcollie/zon2nix?rev=c28e93f3ba133d4c1b1d65224e2eebede61fd071"; inputs = { nixpkgs.follows = "nixpkgs"; }; diff --git a/nix/devShell.nix b/nix/devShell.nix index d37107133..90059a730 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -26,6 +26,7 @@ wasmtime, wraptest, zig, + zig_0_15, zip, llvmPackages_latest, bzip2, From 5817e1dc5f4a12d2d68b9865053a64bdbd43ebd9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 12 Jan 2026 09:13:52 -0800 Subject: [PATCH 448/605] terminal: PageList can initialize with memory requirements > std --- src/terminal/PageList.zig | 136 ++++++++++++++++++++++++++++++++------ src/terminal/page.zig | 7 +- 2 files changed, 118 insertions(+), 25 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 546f6c2e2..fc680b971 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -54,9 +54,6 @@ const NodePool = std.heap.MemoryPool(List.Node); /// usage to support using our pool. const std_capacity = pagepkg.std_capacity; -/// The maximum columns we can support with the standard capacity. -const std_max_cols = std_capacity.maxCols().?; - /// The byte size required for a standard page. const std_size = Page.layout(std_capacity).total_size; @@ -231,19 +228,30 @@ pub const Viewport = union(enum) { /// But this gives us a nice fast heuristic for determining min/max size. /// Therefore, if the page size is violated you should always also verify /// that we have enough space for the active area. -fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { +fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) usize { + // Invariant required to ensure our divCeil below cannot overflow. + comptime { + const max_rows = std.math.maxInt(size.CellCountInt); + _ = std.math.divCeil(usize, max_rows, 1) catch unreachable; + } + // Get our capacity to fit our rows. If the cols are too big, it may // force less rows than we want meaning we need more than one page to // represent a viewport. - const cap = try std_capacity.adjust(.{ .cols = cols }); + const cap = initialCapacity(cols); // Calculate the number of standard sized pages we need to represent // an active area. - const pages_exact = if (cap.rows >= rows) 1 else try std.math.divCeil( + const pages_exact = if (cap.rows >= rows) 1 else std.math.divCeil( usize, rows, cap.rows, - ); + ) catch { + // Not possible: + // - initialCapacity guarantees at least 1 row + // - numerator/denominator can't overflow because of comptime check above + unreachable; + }; // We always need at least one page extra so that we // can fit partial pages to spread our active area across two pages. @@ -263,6 +271,49 @@ fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) !usize { return PagePool.item_size * pages; } +/// Calculates the initial capacity for a new page for a given column +/// count. This will attempt to fit within std_size at all times so we +/// can use our memory pool, but if cols is too big, this will return a +/// larger capacity. +/// +/// The returned capacity is always guaranteed to layout properly (not +/// overflow). We are able to support capacities up to the maximum int +/// value of cols, so this will never overflow. +fn initialCapacity(cols: size.CellCountInt) Capacity { + // This is an important invariant that ensures that this function + // can never return an error. We verify here that our standard capacity + // when increased to maximum possible columns can always support at + // least one row in memory. + // + // IF THIS EVER FAILS: We probably need to modify our logic below + // to reduce other elements of the capacity (styles, graphemes, etc.). + // But, instead, I recommend taking a step back and re-evaluating + // life choices. + comptime { + var cap = std_capacity; + cap.cols = std.math.maxInt(size.CellCountInt); + _ = Page.layout(cap); + } + + if (std_capacity.adjust( + .{ .cols = cols }, + )) |cap| { + // If we can adjust our standard capacity, we fit within the + // standard size and we're good! + return cap; + } else |err| { + // Ensure our error set doesn't change. + comptime assert(@TypeOf(err) == error{OutOfMemory}); + } + + // This code path means that our standard capacity can't even + // accommodate our column count! The only solution is to increase + // our capacity and go non-standard. + var cap: Capacity = std_capacity; + cap.cols = cols; + return cap; +} + /// This is the page allocator we'll use for all our underlying /// VM page allocations. inline fn pageAllocator() Allocator { @@ -318,7 +369,7 @@ pub fn init( ); // Get our minimum max size, see doc comments for more details. - const min_max_size = try minMaxSize(cols, rows); + const min_max_size = minMaxSize(cols, rows); // We always track our viewport pin to ensure this is never an allocation const viewport_pin = try pool.pins.create(); @@ -352,17 +403,31 @@ fn initPages( serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, -) !struct { List, usize } { +) Allocator.Error!struct { List, usize } { var page_list: List = .{}; var page_size: usize = 0; // Add pages as needed to create our initial viewport. - const cap = try std_capacity.adjust(.{ .cols = cols }); + const cap = initialCapacity(cols); + const layout = Page.layout(cap); + const pooled = layout.total_size <= std_size; + const page_alloc = pool.pages.arena.child_allocator; + var rem = rows; while (rem > 0) { const node = try pool.nodes.create(); - const page_buf = try pool.pages.create(); - // no errdefer because the pool deinit will clean these up + const page_buf = if (pooled) + try pool.pages.create() + else + try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + layout.total_size, + ); + errdefer if (pooled) + pool.pages.destroy(page_buf) + else + page_alloc.free(page_buf); // In runtime safety modes we have to memset because the Zig allocator // interface will always memset to 0xAA for undefined. In non-safe modes @@ -372,10 +437,7 @@ 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 = .initBuf( - .init(page_buf), - Page.layout(cap), - ), + .data = .initBuf(.init(page_buf), layout), .serial = serial.*, }; node.data.size.rows = @min(rem, node.data.capacity.rows); @@ -541,10 +603,8 @@ pub fn reset(self: *PageList) void { // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already // that fits our size but I'm not confident to make that assertion. - const cap = std_capacity.adjust( - .{ .cols = self.cols }, - ) catch @panic("reset: std_capacity.adjust failed"); - assert(cap.rows > 0); // adjust should never return 0 rows + const cap = initialCapacity(self.cols); + assert(cap.rows > 0); // The number of pages we need is the number of rows in the active // area divided by the row capacity of a page. @@ -836,7 +896,7 @@ pub fn resize(self: *PageList, opts: Resize) !void { // when increasing beyond our initial minimum max size or explicit max // size to fit the active area. const old_min_max_size = self.min_max_size; - self.min_max_size = try minMaxSize( + self.min_max_size = minMaxSize( opts.cols orelse self.cols, opts.rows orelse self.rows, ); @@ -1600,7 +1660,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // We only set the new min_max_size if we're not reflowing. If we are // reflowing, then resize handles this for us. const old_min_max_size = self.min_max_size; - self.min_max_size = if (!opts.reflow) try minMaxSize( + self.min_max_size = if (!opts.reflow) minMaxSize( opts.cols orelse self.cols, opts.rows orelse self.rows, ) else old_min_max_size; @@ -4559,6 +4619,38 @@ test "PageList init rows across two pages" { }, s.scrollbar()); } +test "PageList init more than max cols" { + const testing = std.testing; + const alloc = testing.allocator; + + // Initialize with more columns than we can fit in our standard + // capacity. This is going to force us to go to a non-standard page + // immediately. + var s = try init( + alloc, + std_capacity.maxCols().? + 1, + 80, + null, + ); + defer s.deinit(); + try testing.expect(s.viewport == .active); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // We expect a single, non-standard page + try testing.expect(s.pages.first != null); + try testing.expect(s.pages.first.?.data.memory.len > std_size); + + // Initial total rows should be our row count + try testing.expectEqual(s.rows, s.total_rows); + + // Scrollbar should be where we expect it + try testing.expectEqual(Scrollbar{ + .total = s.rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); +} + test "PageList pointFromPin active no history" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 6e6416e4e..848123405 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -196,7 +196,8 @@ pub const Page = struct { // We need to go through and initialize all the rows so that // they point to a valid offset into the cells, since the rows // zero-initialized aren't valid. - const cells_ptr = cells.ptr(buf)[0 .. cap.cols * cap.rows]; + const cells_len = @as(usize, cap.cols) * @as(usize, cap.rows); + const cells_ptr = cells.ptr(buf)[0..cells_len]; for (rows.ptr(buf)[0..cap.rows], 0..) |*row, y| { const start = y * cap.cols; row.* = .{ @@ -1556,7 +1557,7 @@ pub const Page = struct { const rows_start = 0; const rows_end: usize = rows_start + (rows_count * @sizeOf(Row)); - const cells_count: usize = @intCast(cap.cols * cap.rows); + const cells_count: usize = @as(usize, cap.cols) * @as(usize, cap.rows); const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); const cells_end = cells_start + (cells_count * @sizeOf(Cell)); @@ -1676,7 +1677,7 @@ pub const Capacity = struct { if (available_bits <= @bitSizeOf(Row)) return null; // We do the math of how many columns we can fit in the remaining - // bits ignoring the metadat of a row. + // bits ignoring the metadata of a row. const remaining_bits = available_bits - @bitSizeOf(Row); const max_cols = remaining_bits / @bitSizeOf(Cell); From 2af6e255e4dd4ea685610f7445d7b69ba4410e49 Mon Sep 17 00:00:00 2001 From: MithicSpirit Date: Mon, 12 Jan 2026 13:13:17 -0500 Subject: [PATCH 449/605] chore: fix typo curor->cursor (2x) Detected by CI (typos) after nixpkgs update. --- src/extra/bash.zig | 2 +- src/terminal/stream.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extra/bash.zig b/src/extra/bash.zig index ee9a7895c..ee672a964 100644 --- a/src/extra/bash.zig +++ b/src/extra/bash.zig @@ -296,7 +296,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void { \\ else prev="${COMP_WORDS[COMP_CWORD-1]}" \\ fi \\ - \\ # current completion is double quoted add a space so the curor progresses + \\ # current completion is double quoted add a space so the cursor progresses \\ if [[ "$2" == \"*\" ]]; then \\ COMPREPLY=( "$cur " ); \\ return; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 665b54284..eef249327 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1641,7 +1641,7 @@ pub fn Stream(comptime Handler: type) type { }, }, else => { - log.warn("invalid set curor style command: {f}", .{input}); + log.warn("invalid set cursor style command: {f}", .{input}); return; }, }; From 8f4bfeece56c1d31b920116e9baf1b7ee2edfedd Mon Sep 17 00:00:00 2001 From: MithicSpirit Date: Mon, 12 Jan 2026 13:14:10 -0500 Subject: [PATCH 450/605] ci: fix ryand56/r2-upload-action version comment Does not entail any actual changes in the version, merely in the comment indicating the used version. Detected by CI (GitHub Action Pins) after nixpkgs update. --- .github/workflows/publish-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-tag.yml b/.github/workflows/publish-tag.yml index c433e7484..acb1ab1f1 100644 --- a/.github/workflows/publish-tag.yml +++ b/.github/workflows/publish-tag.yml @@ -64,7 +64,7 @@ jobs: mkdir blob mv appcast.xml blob/appcast.xml - name: Upload Appcast to R2 - uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 with: r2-account-id: ${{ secrets.CF_R2_RELEASE_ACCOUNT_ID }} r2-access-key-id: ${{ secrets.CF_R2_RELEASE_AWS_KEY }} From 1537590a5f25bca16f2516efda33a5571f100008 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 12 Jan 2026 11:40:22 -0500 Subject: [PATCH 451/605] macos: cycle through our icons in the About view Clicking on the icon immediately advances to the next one. Hovering on the icon pauses the automatic cycling, and the "help" tooltip displays the icon's configuration name (for `macos-icon`). --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/App/macOS/AppDelegate.swift | 34 +++------- macos/Sources/Features/About/AboutView.swift | 5 +- .../Features/About/CyclingIconView.swift | 62 +++++++++++++++++++ macos/Sources/Ghostty/Package.swift | 16 +++++ 5 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 macos/Sources/Features/About/CyclingIconView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 9bd36eaad..0b3432362 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ Features/About/About.xib, Features/About/AboutController.swift, Features/About/AboutView.swift, + Features/About/CyclingIconView.swift, "Features/App Intents/CloseTerminalIntent.swift", "Features/App Intents/CommandPaletteIntent.swift", "Features/App Intents/Entities/CommandEntity.swift", diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2e62d033b..e8c56e9d9 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -946,33 +946,8 @@ class AppDelegate: NSObject, var appIconName: String? = config.macosIcon.rawValue switch (config.macosIcon) { - case .official: - // Discard saved icon name - appIconName = nil - break - case .blueprint: - appIcon = NSImage(named: "BlueprintImage")! - - case .chalkboard: - appIcon = NSImage(named: "ChalkboardImage")! - - case .glass: - appIcon = NSImage(named: "GlassImage")! - - case .holographic: - appIcon = NSImage(named: "HolographicImage")! - - case .microchip: - appIcon = NSImage(named: "MicrochipImage")! - - case .paper: - appIcon = NSImage(named: "PaperImage")! - - case .retro: - appIcon = NSImage(named: "RetroImage")! - - case .xray: - appIcon = NSImage(named: "XrayImage")! + case let icon where icon.assetName != nil: + appIcon = NSImage(named: icon.assetName!)! case .custom: if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { @@ -982,6 +957,7 @@ class AppDelegate: NSObject, appIcon = nil // Revert back to official icon if invalid location appIconName = nil // Discard saved icon name } + case .customStyle: // Discard saved icon name // if no valid colours were found @@ -997,6 +973,10 @@ class AppDelegate: NSObject, let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) appIconName = (colorStrings + [config.macosIconFrame.rawValue]) .joined(separator: "_") + + default: + // Discard saved icon name + appIconName = nil } // Only change the icon if it has actually changed from the current one, diff --git a/macos/Sources/Features/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index 6ed3285ed..967eb16b0 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -44,10 +44,7 @@ struct AboutView: View { var body: some View { VStack(alignment: .center) { - ghosttyIconImage() - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: 128) + CyclingIconView() VStack(alignment: .center, spacing: 32) { VStack(alignment: .center, spacing: 8) { diff --git a/macos/Sources/Features/About/CyclingIconView.swift b/macos/Sources/Features/About/CyclingIconView.swift new file mode 100644 index 000000000..4274278e0 --- /dev/null +++ b/macos/Sources/Features/About/CyclingIconView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import GhosttyKit + +/// A view that cycles through Ghostty's official icon variants. +struct CyclingIconView: View { + @State private var currentIcon: Ghostty.MacOSIcon = .official + @State private var isHovering: Bool = false + + private let icons: [Ghostty.MacOSIcon] = [ + .official, + .blueprint, + .chalkboard, + .microchip, + .glass, + .holographic, + .paper, + .retro, + .xray, + ] + private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common) + + var body: some View { + ZStack { + iconView(for: currentIcon) + .id(currentIcon) + } + .animation(.easeInOut(duration: 0.5), value: currentIcon) + .frame(height: 128) + .onReceive(timerPublisher.autoconnect()) { _ in + if !isHovering { + advanceToNextIcon() + } + } + .onHover { hovering in + isHovering = hovering + } + .onTapGesture { + advanceToNextIcon() + } + .help("macos-icon = \(currentIcon.rawValue)") + .accessibilityLabel("Ghostty Application Icon") + .accessibilityHint("Click to cycle through icon variants") + } + + @ViewBuilder + private func iconView(for icon: Ghostty.MacOSIcon) -> some View { + let iconImage: Image = switch icon.assetName { + case let assetName?: Image(assetName) + case nil: ghosttyIconImage() + } + + iconImage + .resizable() + .aspectRatio(contentMode: .fit) + } + + private func advanceToNextIcon() { + let currentIndex = icons.firstIndex(of: currentIcon) ?? 0 + let nextIndex = icons.indexWrapping(after: currentIndex) + currentIcon = icons[nextIndex] + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index aa62c16f7..15cb3a51e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -330,6 +330,22 @@ extension Ghostty { case xray case custom case customStyle = "custom-style" + + /// Bundled asset name for built-in icons + var assetName: String? { + switch self { + case .official: return nil + case .blueprint: return "BlueprintImage" + case .chalkboard: return "ChalkboardImage" + case .microchip: return "MicrochipImage" + case .glass: return "GlassImage" + case .holographic: return "HolographicImage" + case .paper: return "PaperImage" + case .retro: return "RetroImage" + case .xray: return "XrayImage" + case .custom, .customStyle: return nil + } + } } /// macos-icon-frame From 55583d9f27ff9165286c45df8db4f2c999583468 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 13 Jan 2026 10:36:20 -0500 Subject: [PATCH 452/605] fix Devanagari test --- src/font/shaper/coretext.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 223b2382b..cc05022c4 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -776,7 +776,7 @@ pub const Shaper = struct { cell_offset.cluster, cluster, run_offset.cluster, - cluster - cell_offset.cluster, + @as(isize, @intCast(cluster)) - @as(isize, @intCast(cell_offset.cluster)), x_offset, position.y, run_offset.x, @@ -820,7 +820,7 @@ pub const Shaper = struct { cell_offset.cluster, cluster, run_offset.cluster, - cluster - cell_offset.cluster, + @as(isize, @intCast(cluster)) - @as(isize, @intCast(cell_offset.cluster)), x_offset, position.y, run_offset.x, @@ -1577,11 +1577,13 @@ test "shape Devanagari string" { try testing.expect(run != null); const cells = try shaper.shape(run.?); + // To understand the `x`/`cluster` assertions here, run with the "For + // debugging positions" code turned on and `extra_debugging` set to true. try testing.expectEqual(@as(usize, 8), cells.len); try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 1), cells[1].x); try testing.expectEqual(@as(u16, 2), cells[2].x); - try testing.expectEqual(@as(u16, 3), cells[3].x); + try testing.expectEqual(@as(u16, 4), cells[3].x); try testing.expectEqual(@as(u16, 4), cells[4].x); try testing.expectEqual(@as(u16, 5), cells[5].x); try testing.expectEqual(@as(u16, 5), cells[6].x); From 38117f54452072dbc689d0c74dad91270472788b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:15:44 +0000 Subject: [PATCH 453/605] build(deps): bump namespacelabs/nscloud-setup-buildx-action Bumps [namespacelabs/nscloud-setup-buildx-action](https://github.com/namespacelabs/nscloud-setup-buildx-action) from 0.0.20 to 0.0.21. - [Release notes](https://github.com/namespacelabs/nscloud-setup-buildx-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-setup-buildx-action/compare/91c2e6537780e3b092cb8476406be99a8f91bd5e...a7e525416136ee2842da3c800e7067b72a27200e) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-setup-buildx-action dependency-version: 0.0.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45127e032..a9adcfbc2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1083,7 +1083,7 @@ jobs: uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 + uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21 - name: Download Source Tarball Artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 From 2587a2efb44038e2b02aa1c97cfc5d98e65dafe4 Mon Sep 17 00:00:00 2001 From: Mike Kasberg Date: Tue, 21 Oct 2025 17:25:54 -0600 Subject: [PATCH 454/605] feat: Select/Copy Links On Right Click If Present This is a solution for #2107. When a user right-clicks, and there's no existing selection, the existing behavior is to try to select the word under the cursor: https://github.com/ghostty-org/ghostty/blob/3548acfac63e7674b5e25896f6b393474fe8ea65/src/Surface.zig#L3740-L3742 This PR tweaks that behavior _slightly_: If there's a link under our cursor, as determined by `linkAtPos`, select the link (to copy with the right-click context menu). Otherwise, select the word as before. As noted in #2107, this matches the behavior of iTerm and Gnome Terminal. It's worth noting that `linkAtPos` already does the right thing in terms of checking the links from config and their highlight/hover states (modified by Ctrl or Super depending on platform). https://github.com/ghostty-org/ghostty/blob/3548acfac63e7674b5e25896f6b393474fe8ea65/src/Surface.zig#L3896-L3901 It also therefore respects `link-url` from config. https://github.com/ghostty-org/ghostty/blob/3548acfac63e7674b5e25896f6b393474fe8ea65/src/config/Config.zig#L3411-L3416 By using `linkAtPos`, we get all that behavior for free. In practical terms, that means: - If I'm holding Ctrl so a link is underlined and I right click on it, it selects the underlined link. - If I'm not holding Ctrl and I right click on a link that is not underlined, it selects the word as before. - This behavior respects per-platform key bindings and user config settings. `linkAtPos` requires that the render state mutex is held. I believe it's safe to call because we're inside a block holding the mutex: https://github.com/ghostty-org/ghostty/blob/3548acfac63e7674b5e25896f6b393474fe8ea65/src/Surface.zig#L3702-L3704 **AI Disclosure:** I used Gemini CLI to help me with this PR because while I have many years of programming experience, this is my first time writing Zig. I prototyped a couple different approaches with AI before landing on this one, so AI generated various prototypes and I chose the final imlementation. I've verified that my code compiles and works as intended. --- src/Surface.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4103b91fb..1f50cb681 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4225,8 +4225,8 @@ pub fn mouseButtonCallback( // Get our viewport pin const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; + const pos = try self.rt_surface.getCursorPos(); const pin = pin: { - const pos = try self.rt_surface.getCursorPos(); const pt_viewport = self.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ .viewport = .{ @@ -4257,8 +4257,14 @@ pub fn mouseButtonCallback( // word selection where we clicked. } - const sel = screen.selectWord(pin) orelse break :sel; - try self.setSelection(sel); + // If there is a link at this position, we want to + // select the link. Otherwise, select the word. + if (try self.linkAtPos(pos)) |link| { + try self.setSelection(link.selection); + } else { + const sel = screen.selectWord(pin) orelse break :sel; + try self.setSelection(sel); + } try self.queueRender(); // Don't consume so that we show the context menu in apprt. From 916b99df7c65b79ed8970d12f7bad06c67bc77c4 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 14 Jan 2026 22:51:53 +0800 Subject: [PATCH 455/605] terminal: parse kitty text sizing protocol (OSC 66), redux #9845 redone to use the new OSC parser Implements the VT side of #5563 --- src/inspector/termio.zig | 17 +- src/terminal/osc.zig | 24 +- src/terminal/osc/encoding.zig | 38 +++ src/terminal/osc/parsers.zig | 2 + .../osc/parsers/kitty_text_sizing.zig | 250 ++++++++++++++++++ src/terminal/stream.zig | 1 + 6 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 src/terminal/osc/encoding.zig create mode 100644 src/terminal/osc/parsers/kitty_text_sizing.zig diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 9f55e6019..934bb6e2d 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -286,18 +286,19 @@ pub const VTEvent = struct { ), else => switch (Value) { - u8, u16 => try md.put( - key, - try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0), - ), - []const u8, [:0]const u8, => try md.put(key, try alloc.dupeZ(u8, value)), - else => |T| { - @compileLog(T); - @compileError("unsupported type, see log"); + else => |T| switch (@typeInfo(T)) { + .int => try md.put( + key, + try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0), + ), + else => { + @compileLog(T); + @compileError("unsupported type, see log"); + }, }, }, } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 1f4489961..14d501eaa 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -14,6 +14,8 @@ const Allocator = mem.Allocator; const LibEnum = @import("../lib/enum.zig").Enum; const kitty_color = @import("kitty/color.zig"); const parsers = @import("osc/parsers.zig"); +const encoding = @import("osc/encoding.zig"); + pub const color = parsers.color; const log = std.log.scoped(.osc); @@ -191,6 +193,9 @@ pub const Command = union(Key) { /// ConEmu GUI macro (OSC 9;6) conemu_guimacro: [:0]const u8, + /// Kitty text sizing protocol (OSC 66) + kitty_text_sizing: parsers.kitty_text_sizing.OSC, + pub const Key = LibEnum( if (build_options.c_abi) .c else .zig, // NOTE: Order matters, see LibEnum documentation. @@ -216,6 +221,7 @@ pub const Command = union(Key) { "conemu_progress_report", "conemu_wait_input", "conemu_guimacro", + "kitty_text_sizing", }, ); @@ -342,6 +348,7 @@ pub const Parser = struct { @"2", @"4", @"5", + @"6", @"7", @"8", @"9", @@ -358,6 +365,7 @@ pub const Parser = struct { @"21", @"22", @"52", + @"66", @"77", @"104", @"110", @@ -431,6 +439,7 @@ pub const Parser = struct { .prompt_start, .report_pwd, .show_desktop_notification, + .kitty_text_sizing, => {}, } @@ -510,6 +519,7 @@ pub const Parser = struct { '2' => self.state = .@"2", '4' => self.state = .@"4", '5' => self.state = .@"5", + '6' => self.state = .@"6", '7' => self.state = .@"7", '8' => self.state = .@"8", '9' => self.state = .@"9", @@ -600,7 +610,14 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"52" => switch (c) { + .@"6" => switch (c) { + '6' => self.state = .@"66", + else => self.state = .invalid, + }, + + .@"52", + .@"66", + => switch (c) { ';' => self.writeToAllocating(), else => self.state = .invalid, }, @@ -685,6 +702,10 @@ pub const Parser = struct { .@"52" => parsers.clipboard_operation.parse(self, terminator_ch), + .@"6" => null, + + .@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), + .@"77" => null, .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), @@ -696,4 +717,5 @@ pub const Parser = struct { test { _ = parsers; + _ = encoding; } diff --git a/src/terminal/osc/encoding.zig b/src/terminal/osc/encoding.zig new file mode 100644 index 000000000..7491d10c2 --- /dev/null +++ b/src/terminal/osc/encoding.zig @@ -0,0 +1,38 @@ +//! Specialized encodings used in some OSC protocols. +const std = @import("std"); + +/// Kitty defines "Escape code safe UTF-8" as valid UTF-8 with the +/// additional requirement of not containing any C0 escape codes +/// (0x00-0x1f), DEL (0x7f) and C1 escape codes (0x80-0x9f). +/// +/// Used by OSC 66 (text sizing) and OSC 99 (Kitty notifications). +/// +/// See: https://sw.kovidgoyal.net/kitty/desktop-notifications/#safe-utf8 +pub fn isSafeUtf8(s: []const u8) bool { + const utf8 = std.unicode.Utf8View.init(s) catch { + @branchHint(.cold); + return false; + }; + + var it = utf8.iterator(); + while (it.nextCodepoint()) |cp| switch (cp) { + 0x00...0x1f, 0x7f, 0x80...0x9f => { + @branchHint(.cold); + return false; + }, + else => {}, + }; + + return true; +} + +test isSafeUtf8 { + const testing = std.testing; + + try testing.expect(isSafeUtf8("Hello world!")); + try testing.expect(isSafeUtf8("安全的ユニコード☀️")); + try testing.expect(!isSafeUtf8("No linebreaks\nallowed")); + try testing.expect(!isSafeUtf8("\x07no bells")); + try testing.expect(!isSafeUtf8("\x1b]9;no OSCs\x1b\\\x1b[m")); + try testing.expect(!isSafeUtf8("\x9f8-bit escapes are clever, but no")); +} diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index 152276af2..9c1c39b2c 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -6,6 +6,7 @@ pub const clipboard_operation = @import("parsers/clipboard_operation.zig"); pub const color = @import("parsers/color.zig"); pub const hyperlink = @import("parsers/hyperlink.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); +pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); pub const mouse_shape = @import("parsers/mouse_shape.zig"); pub const osc9 = @import("parsers/osc9.zig"); pub const report_pwd = @import("parsers/report_pwd.zig"); @@ -19,6 +20,7 @@ test { _ = color; _ = hyperlink; _ = kitty_color; + _ = kitty_text_sizing; _ = mouse_shape; _ = osc9; _ = report_pwd; diff --git a/src/terminal/osc/parsers/kitty_text_sizing.zig b/src/terminal/osc/parsers/kitty_text_sizing.zig new file mode 100644 index 000000000..2c2d1b8fd --- /dev/null +++ b/src/terminal/osc/parsers/kitty_text_sizing.zig @@ -0,0 +1,250 @@ +//! Kitty's text sizing protocol (OSC 66) +//! Specification: https://sw.kovidgoyal.net/kitty/text-sizing-protocol/ + +const std = @import("std"); +const build_options = @import("terminal_options"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; +const encoding = @import("../encoding.zig"); +const lib = @import("../../../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + +const log = std.log.scoped(.kitty_text_sizing); + +pub const max_payload_length = 4096; + +pub const VAlign = lib.Enum(lib_target, &.{ + "top", + "bottom", + "center", +}); + +pub const HAlign = lib.Enum(lib_target, &.{ + "left", + "right", + "center", +}); + +pub const OSC = struct { + scale: u3 = 1, // 1 - 7 + width: u3 = 0, // 0 - 7 (0 means default) + numerator: u4 = 0, + denominator: u4 = 0, + valign: VAlign = .top, + halign: HAlign = .left, + text: [:0]const u8, + + /// We don't currently support encoding this to C in any way. + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } + + fn update(self: *OSC, key: u8, value: []const u8) !void { + // All values are numeric, so we can do a small hack here + const v = try std.fmt.parseInt(u4, value, 10); + + switch (key) { + 's' => { + if (v == 0) return error.InvalidValue; + self.scale = std.math.cast(u3, v) orelse return error.Overflow; + }, + 'w' => self.width = std.math.cast(u3, v) orelse return error.Overflow, + 'n' => self.numerator = v, + 'd' => self.denominator = v, + 'v' => self.valign = std.enums.fromInt(VAlign, v) orelse return error.InvalidValue, + 'h' => self.halign = std.enums.fromInt(HAlign, v) orelse return error.InvalidValue, + else => return error.UnknownKey, + } + } +}; + +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + assert(parser.state == .@"66"); + + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + + // Write a NUL byte to ensure that `text` is NUL-terminated + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + + const payload_start = std.mem.indexOfScalar(u8, data, ';') orelse { + log.warn("missing semicolon before payload", .{}); + parser.state = .invalid; + return null; + }; + const payload = data[payload_start + 1 .. data.len - 1 :0]; + + // Payload has to be a URL-safe UTF-8 string, + // and be under the size limit. + if (payload.len > max_payload_length) { + log.warn("payload is too long", .{}); + parser.state = .invalid; + return null; + } + if (!encoding.isSafeUtf8(payload)) { + log.warn("payload is not escape code safe UTF-8", .{}); + parser.state = .invalid; + return null; + } + + parser.command = .{ + .kitty_text_sizing = .{ .text = payload }, + }; + const cmd = &parser.command.kitty_text_sizing; + + // Parse any arguments if given + if (payload_start > 0) { + var kv_it = std.mem.splitScalar( + u8, + data[0..payload_start], + ':', + ); + + while (kv_it.next()) |kv| { + var it = std.mem.splitScalar(u8, kv, '='); + const k = it.next() orelse { + log.warn("missing key", .{}); + continue; + }; + if (k.len != 1) { + log.warn("key must be a single character", .{}); + continue; + } + + const value = it.next() orelse { + log.warn("missing value", .{}); + continue; + }; + + cmd.update(k[0], value) catch |err| { + switch (err) { + error.UnknownKey => log.warn("unknown key: '{c}'", .{k[0]}), + else => log.warn("invalid value for key '{c}': {}", .{ k[0], err }), + } + continue; + }; + } + } + + return &parser.command; +} + +test "OSC 66: empty parameters" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;;bobr"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_text_sizing); + try testing.expectEqual(1, cmd.kitty_text_sizing.scale); + try testing.expectEqualStrings("bobr", cmd.kitty_text_sizing.text); +} + +test "OSC 66: single parameter" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;s=2;kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_text_sizing); + try testing.expectEqual(2, cmd.kitty_text_sizing.scale); + try testing.expectEqualStrings("kurwa", cmd.kitty_text_sizing.text); +} + +test "OSC 66: multiple parameters" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;s=2:w=7:n=13:d=15:v=1:h=2;long"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_text_sizing); + try testing.expectEqual(2, cmd.kitty_text_sizing.scale); + try testing.expectEqual(7, cmd.kitty_text_sizing.width); + try testing.expectEqual(13, cmd.kitty_text_sizing.numerator); + try testing.expectEqual(15, cmd.kitty_text_sizing.denominator); + try testing.expectEqual(.bottom, cmd.kitty_text_sizing.valign); + try testing.expectEqual(.center, cmd.kitty_text_sizing.halign); + try testing.expectEqualStrings("long", cmd.kitty_text_sizing.text); +} + +test "OSC 66: scale is zero" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;s=0;nope"; + for (input) |ch| p.next(ch); + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .kitty_text_sizing); + try testing.expectEqual(1, cmd.kitty_text_sizing.scale); +} + +test "OSC 66: invalid parameters" { + const testing = std.testing; + + var p: Parser = .init(null); + + for ("66;w=8:v=3:n=16;") |ch| p.next(ch); + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .kitty_text_sizing); + try testing.expectEqual(0, cmd.kitty_text_sizing.width); + try testing.expect(cmd.kitty_text_sizing.valign == .top); + try testing.expectEqual(0, cmd.kitty_text_sizing.numerator); +} + +test "OSC 66: UTF-8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;;👻魑魅魍魉ゴースッティ"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_text_sizing); + try testing.expectEqualStrings("👻魑魅魍魉ゴースッティ", cmd.kitty_text_sizing.text); +} + +test "OSC 66: unsafe UTF-8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;;\n"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC 66: overlong UTF-8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "66;;" ++ "bobr" ** 1025; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index eef249327..74a01e8a6 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -2107,6 +2107,7 @@ pub fn Stream(comptime Handler: type) type { .conemu_change_tab_title, .conemu_wait_input, .conemu_guimacro, + .kitty_text_sizing, => { log.debug("unimplemented OSC callback: {}", .{cmd}); }, From 78a503491e619ea9c69ee307167bc398f9f4cc28 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Thu, 15 Jan 2026 10:53:00 -0300 Subject: [PATCH 456/605] initial commit --- src/apprt/gtk/class/surface.zig | 33 ++++++++++++++++++++++++++------ src/apprt/gtk/gsettings.zig | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/apprt/gtk/gsettings.zig diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index d1eb144e9..8f219677d 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -19,6 +19,7 @@ const terminal = @import("../../../terminal/main.zig"); const CoreSurface = @import("../../../Surface.zig"); const gresource = @import("../build/gresource.zig"); const ext = @import("../ext.zig"); +const gsettings = @import("../gsettings.zig"); const gtk_key = @import("../key.zig"); const ApprtSurface = @import("../Surface.zig"); const Common = @import("../class.zig").Common; @@ -674,6 +675,11 @@ pub const Surface = extern struct { /// The context for this surface (window, tab, or split) context: apprt.surface.NewSurfaceContext = .window, + /// Whether primary paste (middle-click paste) is enabled via GNOME settings. + /// If null, the setting could not be read (non-GNOME system or schema missing). + /// If true, middle-click paste is enabled. If false, it's disabled. + gtk_enable_primary_paste: ?bool = null, + pub var offset: c_int = 0; }; @@ -1511,12 +1517,10 @@ pub const Surface = extern struct { const xft_dpi_scale = xft_scale: { // gtk-xft-dpi is font DPI multiplied by 1024. See // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const settings = gtk.Settings.getDefault() orelse break :xft_scale 1.0; - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(c_int)); - settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value); - const gtk_xft_dpi = value.getInt(); + const gtk_xft_dpi = gsettings.readSetting(c_int, "gtk-xft-dpi") orelse { + log.warn("gtk-xft-dpi was not set, using default value", .{}); + break :xft_scale 1.0; + }; // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 // See: @@ -1767,6 +1771,10 @@ pub const Surface = extern struct { priv.im_composing = false; priv.im_len = 0; + // Read GNOME desktop interface settings for primary paste (middle-click) + // This is only relevant on Linux systems with GNOME settings available + priv.gtk_enable_primary_paste = gsettings.readSetting(bool, "gtk-enable-primary-paste"); + // Set up to handle items being dropped on our surface. Files can be dropped // from Nautilus and strings can be dropped from many programs. The order // of these types matter. @@ -2685,6 +2693,13 @@ pub const Surface = extern struct { // Report the event const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + + // Check if middle button paste should be disabled based on GNOME settings + // If gtk_enable_primary_paste is explicitly false, skip processing middle button + if (button == .middle and priv.gtk_enable_primary_paste == false) { + return; + } + const consumed = consumed: { const gtk_mods = event.getModifierState(); const mods = gtk_key.translateMods(gtk_mods); @@ -2736,6 +2751,12 @@ pub const Surface = extern struct { const gtk_mods = event.getModifierState(); const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + // Check if middle button paste should be disabled based on GNOME settings + // If gtk_enable_primary_paste is explicitly false, skip processing middle button + if (button == .middle and priv.gtk_enable_primary_paste == false) { + return; + } + const mods = gtk_key.translateMods(gtk_mods); const consumed = surface.mouseButtonCallback( .release, diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig new file mode 100644 index 000000000..1e9f14a1f --- /dev/null +++ b/src/apprt/gtk/gsettings.zig @@ -0,0 +1,34 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const gtk = @import("gtk"); +const gobject = @import("gobject"); + +/// Reads a GTK setting using the GTK Settings API. +/// This automatically uses XDG Desktop Portal in Flatpak environments. +/// Returns null if not on a GTK-supported platform or if the setting cannot be read. +/// +/// Supported platforms: Linux, FreeBSD +/// Supported types: bool, c_int +/// +/// Example usage: +/// const enabled = readSetting(bool, "gtk-enable-primary-paste"); +/// const dpi = readSetting(c_int, "gtk-xft-dpi"); +pub fn readSetting(comptime T: type, key: [*:0]const u8) ?T { + // Only available on systems that use GTK (Linux, FreeBSD) + if (comptime builtin.os.tag != .linux and builtin.os.tag != .freebsd) return null; + + const settings = gtk.Settings.getDefault() orelse return null; + + // For bool and c_int, we use c_int as the underlying GObject type + // because GTK boolean properties are stored as integers + var value = gobject.ext.Value.new(c_int); + defer value.unset(); + + settings.as(gobject.Object).getProperty(key, &value); + + return switch (T) { + bool => value.getInt() != 0, + c_int => value.getInt(), + else => @compileError("Unsupported type '" ++ @typeName(T) ++ "' for GTK setting. Supported types: bool, c_int"), + }; +} From e7e83d63144bd07fc0ffc5a65cb1cf0f41358d4d Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 14 Jan 2026 09:53:48 -0500 Subject: [PATCH 457/605] Update harfbuzz logic, debugging, and tests to match CoreText changes --- src/font/shaper/harfbuzz.zig | 604 ++++++++++++++++++++++++++++++++--- 1 file changed, 563 insertions(+), 41 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 02d28347e..0acf06bbf 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); +const unicode = @import("../../unicode/main.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -19,7 +20,7 @@ const log = std.log.scoped(.font_shaper); /// Shaper that uses Harfbuzz. pub const Shaper = struct { - /// The allocated used for the feature list and cell buf. + /// The allocated used for the feature list, cell buf, and codepoints. alloc: Allocator, /// The buffer used for text shaping. We reuse it across multiple shaping @@ -32,16 +33,29 @@ pub const Shaper = struct { /// The features to use for shaping. hb_feats: []harfbuzz.Feature, - // For debugging positions, turn this on: - debug_codepoints: std.ArrayListUnmanaged(DebugCodepoint) = .{}, + /// The codepoints added to the buffer before shaping. We need to keep + /// these separately because after shaping, HarfBuzz replaces codepoints + /// with glyph indices in the buffer. + codepoints: std.ArrayListUnmanaged(Codepoint) = .{}, - const DebugCodepoint = struct { + const Codepoint = struct { cluster: u32, codepoint: u32, }; const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); + const RunOffset = struct { + cluster: u32 = 0, + x: i32 = 0, + y: i32 = 0, + }; + + const CellOffset = struct { + cluster: u32 = 0, + x: i32 = 0, + }; + /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { @@ -82,9 +96,7 @@ pub const Shaper = struct { self.hb_buf.destroy(); self.cell_buf.deinit(self.alloc); self.alloc.free(self.hb_feats); - - // For debugging positions, turn this on: - self.debug_codepoints.deinit(self.alloc); + self.codepoints.deinit(self.alloc); } pub fn endFrame(self: *const Shaper) void { @@ -146,42 +158,97 @@ pub const Shaper = struct { // If it isn't true, I'd like to catch it and learn more. assert(info.len == pos.len); - // This keeps track of the current offsets within a single cell. - var cell_offset: struct { - cluster: u32 = 0, - x: i32 = 0, - y: i32 = 0, - } = .{}; + // This keeps track of the current x and y offsets (sum of advances) + // and the furthest cluster we've seen so far (max). + var run_offset: RunOffset = .{}; + + // This keeps track of the cell starting x and cluster. + var cell_offset: CellOffset = .{}; // Convert all our info/pos to cells and set it. self.cell_buf.clearRetainingCapacity(); for (info, pos) |info_v, pos_v| { - // If our cluster changed then we've moved to a new cell. - if (info_v.cluster != cell_offset.cluster) cell_offset = .{ - .cluster = info_v.cluster, - }; + // info_v.cluster is the index into our codepoints array. We use it + // to get the original cluster. + const index = info_v.cluster; + // Our cluster is also our cell X position. If the cluster changes + // then we need to reset our current cell offsets. + const cluster = self.codepoints.items[index].cluster; + if (cell_offset.cluster != cluster) { + const is_after_glyph_from_current_or_next_clusters = + cluster <= run_offset.cluster; + + const is_first_codepoint_in_cluster = blk: { + var i = index; + while (i > 0) { + i -= 1; + const codepoint = self.codepoints.items[i]; + break :blk codepoint.cluster != cluster; + } else break :blk true; + }; + + // We need to reset the `cell_offset` at the start of a new + // cluster, but we do that conditionally if the codepoint + // `is_first_codepoint_in_cluster` and the cluster is not + // `is_after_glyph_from_current_or_next_clusters`, which is + // a heuristic to detect ligatures and avoid positioning + // glyphs that mark ligatures incorrectly. The idea is that + // if the first codepoint in a cluster doesn't appear in + // the stream, it's very likely that it combined with + // codepoints from a previous cluster into a ligature. + // Then, the subsequent codepoints are very likely marking + // glyphs that are placed relative to that ligature, so if + // we were to reset the `cell_offset` to align it with the + // grid, the positions would be off. The + // `!is_after_glyph_from_current_or_next_clusters` check is + // needed in case these marking glyphs come from a later + // cluster but are rendered first (see the Chakma and + // Bengali tests). In that case when we get to the + // codepoint that `is_first_codepoint_in_cluster`, but in a + // cluster that + // `is_after_glyph_from_current_or_next_clusters`, we don't + // want to reset to the grid and cause the positions to be + // off. (Note that we could go back and align the cells to + // the grid starting from the one from the cluster that + // rendered out of order, but that is more complicated so + // we don't do that for now. Also, it's TBD if there are + // exceptions to this heuristic for detecting ligatures, + // but using the logging below seems to show it works + // well.) + if (is_first_codepoint_in_cluster and + !is_after_glyph_from_current_or_next_clusters) + { + cell_offset = .{ + .cluster = cluster, + .x = run_offset.x, + }; + } + } // Under both FreeType and CoreText the harfbuzz scale is // in 26.6 fixed point units, so we round to the nearest // whole value here. - const x_offset = cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); - const y_offset = cell_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); + const x_offset = run_offset.x - cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); + const y_offset = run_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); // For debugging positions, turn this on: - try self.debugPositions(cell_offset, pos_v); + //try self.debugPositions(run_offset, cell_offset, pos_v, index); try self.cell_buf.append(self.alloc, .{ - .x = @intCast(info_v.cluster), + .x = @intCast(cell_offset.cluster), .x_offset = @intCast(x_offset), .y_offset = @intCast(y_offset), .glyph_index = info_v.codepoint, }); + // Add our advances to keep track of our run offsets. + // Advances apply to the NEXT cell. // Under both FreeType and CoreText the harfbuzz scale is // in 26.6 fixed point units, so we round to the nearest // whole value here. - cell_offset.x += (pos_v.x_advance + 0b100_000) >> 6; - cell_offset.y += (pos_v.y_advance + 0b100_000) >> 6; + run_offset.x += (pos_v.x_advance + 0b100_000) >> 6; + run_offset.y += (pos_v.y_advance + 0b100_000) >> 6; + run_offset.cluster = @max(run_offset.cluster, cluster); // const i = self.cell_buf.items.len - 1; // log.warn("i={} info={} pos={} cell={}", .{ i, info_v, pos_v, self.cell_buf.items[i] }); @@ -199,6 +266,7 @@ pub const Shaper = struct { // Reset the buffer for our current run self.shaper.hb_buf.reset(); self.shaper.hb_buf.setContentType(.unicode); + self.shaper.codepoints.clearRetainingCapacity(); // We don't support RTL text because RTL in terminals is messy. // Its something we want to improve. For now, we force LTR because @@ -208,10 +276,12 @@ pub const Shaper = struct { pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void { // log.warn("cluster={} cp={x}", .{ cluster, cp }); - self.shaper.hb_buf.add(cp, cluster); - - // For debugging positions, turn this on: - try self.shaper.debug_codepoints.append(self.shaper.alloc, .{ + // We pass the index into codepoints as the cluster value to HarfBuzz. + // After shaping, we use info.cluster to get back the index, which + // lets us look up the original cluster value from codepoints. + const index: u32 = @intCast(self.shaper.codepoints.items.len); + self.shaper.hb_buf.add(cp, index); + try self.shaper.codepoints.append(self.shaper.alloc, .{ .cluster = cluster, .codepoint = cp, }); @@ -224,35 +294,98 @@ pub const Shaper = struct { fn debugPositions( self: *Shaper, - cell_offset: anytype, + run_offset: RunOffset, + cell_offset: CellOffset, pos_v: harfbuzz.GlyphPosition, + index: u32, ) !void { const x_offset = cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); - const y_offset = cell_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); - const advance_x_offset = cell_offset.x; - const advance_y_offset = cell_offset.y; - const x_offset_diff = x_offset - advance_x_offset; + const y_offset = run_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y; + const x_offset_diff = x_offset - cell_offset.x - advance_x_offset; const y_offset_diff = y_offset - advance_y_offset; + const positions_differ = @abs(x_offset_diff) > 0 or @abs(y_offset_diff) > 0; + const y_offset_differs = run_offset.y != 0; + const cluster = self.codepoints.items[index].cluster; + const cluster_differs = cluster != cell_offset.cluster; - if (@abs(x_offset_diff) > 0 or @abs(y_offset_diff) > 0) { + // To debug every loop, flip this to true: + const extra_debugging = false; + + const is_previous_codepoint_prepend = if (cluster_differs or + extra_debugging) + blk: { + var i = index; + while (i > 0) { + i -= 1; + const codepoint = self.codepoints.items[i]; + break :blk unicode.table.get(@intCast(codepoint.codepoint)).grapheme_boundary_class == .prepend; + } + break :blk false; + } else false; + + const formatted_cps = if (positions_differ or + y_offset_differs or + cluster_differs or + extra_debugging) + blk: { var allocating = std.Io.Writer.Allocating.init(self.alloc); - defer allocating.deinit(); const writer = &allocating.writer; - const codepoints = self.debug_codepoints.items; - for (codepoints) |cp| { - if (cp.cluster == cell_offset.cluster) { - try writer.print("\\u{{{x}}}", .{cp.codepoint}); + const codepoints = self.codepoints.items; + var last_cluster: ?u32 = null; + for (codepoints, 0..) |cp, i| { + if (@as(i32, @intCast(cp.cluster)) >= @as(i32, @intCast(cell_offset.cluster)) - 1 and + cp.cluster <= cluster + 1) + { + if (last_cluster) |last| { + if (cp.cluster != last) { + try writer.writeAll(" "); + } + } + if (i == index) { + try writer.writeAll("▸"); + } + // Using Python syntax for easier debugging + if (cp.codepoint > 0xFFFF) { + try writer.print("\\U{x:0>8}", .{cp.codepoint}); + } else { + try writer.print("\\u{x:0>4}", .{cp.codepoint}); + } + last_cluster = cp.cluster; } } try writer.writeAll(" → "); for (codepoints) |cp| { - if (cp.cluster == cell_offset.cluster) { + if (@as(i32, @intCast(cp.cluster)) >= @as(i32, @intCast(cell_offset.cluster)) - 1 and + cp.cluster <= cluster + 1) + { try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); } } - const formatted_cps = try allocating.toOwnedSlice(); - log.warn("position differs from advance: cluster={d} pos=({d},{d}) adv=({d},{d}) diff=({d},{d}) cps={s}", .{ + break :blk try allocating.toOwnedSlice(); + } else ""; + + if (extra_debugging) { + log.warn("extra debugging of positions index={d} cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} is_prev_prepend={} cps = {s}", .{ + index, cell_offset.cluster, + cluster, + run_offset.cluster, + @as(isize, @intCast(cluster)) - @as(isize, @intCast(cell_offset.cluster)), + x_offset, + y_offset, + run_offset.x, + run_offset.y, + cell_offset.x, + is_previous_codepoint_prepend, + formatted_cps, + }); + } + + if (positions_differ) { + log.warn("position differs from advance: cluster={d} pos=({d},{d}) adv=({d},{d}) diff=({d},{d}) cps = {s}", .{ + cluster, x_offset, y_offset, advance_x_offset, @@ -262,6 +395,34 @@ pub const Shaper = struct { formatted_cps, }); } + + if (y_offset_differs) { + log.warn("y_offset differs from zero: cluster={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} cps = {s}", .{ + cluster, + x_offset, + y_offset, + run_offset.x, + run_offset.y, + cell_offset.x, + formatted_cps, + }); + } + + if (cluster_differs) { + log.warn("cell_offset.cluster differs from cluster (potential ligature detected) cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} is_prev_prepend={} cps = {s}", .{ + cell_offset.cluster, + cluster, + run_offset.cluster, + @as(isize, @intCast(cluster)) - @as(isize, @intCast(cell_offset.cluster)), + x_offset, + y_offset, + run_offset.x, + run_offset.y, + cell_offset.x, + is_previous_codepoint_prepend, + formatted_cps, + }); + } } }; @@ -1500,3 +1661,364 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { .lib = lib, }; } + +fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestShaper { + var lib = try Library.init(alloc); + errdefer lib.deinit(); + + var c = Collection.init(); + c.load_options = .{ .library = lib }; + + // Discover and add our font to the collection. + { + var disco = font.Discover.init(); + defer disco.deinit(); + var disco_it = try disco.discover(alloc, .{ + .family = font_req, + .size = 12, + .monospace = false, + }); + defer disco_it.deinit(); + var face: font.DeferredFace = (try disco_it.next()) orelse return error.FontNotFound; + errdefer face.deinit(); + _ = try c.add( + alloc, + try face.load(lib, .{ .size = .{ .points = 12 } }), + .{ + .style = .regular, + .fallback = false, + .size_adjustment = .none, + }, + ); + } + + const grid_ptr = try alloc.create(SharedGrid); + errdefer alloc.destroy(grid_ptr); + grid_ptr.* = try .init(alloc, .{ .collection = c }); + errdefer grid_ptr.*.deinit(alloc); + + var shaper = try Shaper.init(alloc, .{}); + errdefer shaper.deinit(); + + return TestShaper{ + .alloc = alloc, + .shaper = shaper, + .grid = grid_ptr, + .lib = lib, + }; +} + +test "shape Tai Tham vowels (y_offset differs from zero)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, we just skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + + // The first glyph renders in the next cell + try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); + try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Tai Tham letters (y_offset differs from zero)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, we just skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1a48, buf[buf_idx..]); // MA + buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT + buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // LOW LA + buf_idx += try std.unicode.utf8Encode(0x1a75, buf[buf_idx..]); // Tone-1 + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1a41, buf[buf_idx..]); // HIGH PA + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme + + // The U glyph renders at a y below zero + try testing.expectEqual(@as(i16, -3), cells[2].y_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Javanese ligatures" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Javanese for this to work, if we can't find + // Noto Sans Javanese Regular, we just skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Javanese", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA + buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON + // Second grapheme cluster, combining with the first in a ligature: + buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA + buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + + // The vowel sign SUKU renders with correct x_offset + try testing.expect(cells[2].x_offset > 3 * cell_width); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Chakma vowel sign with ligature (vowel sign renders first)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Chakma for this to work, if we can't find + // Noto Sans Chakma Regular, we just skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Chakma", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1111d, buf[buf_idx..]); // BAA + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x11116, buf[buf_idx..]); // TAA + buf_idx += try std.unicode.utf8Encode(0x11133, buf[buf_idx..]); // Virama + // Third grapheme cluster, combining with the second in a ligature: + buf_idx += try std.unicode.utf8Encode(0x11120, buf[buf_idx..]); // YYAA + buf_idx += try std.unicode.utf8Encode(0x1112c, buf[buf_idx..]); // Vowel Sign U + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 4), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // we don't take on that complexity. + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 0), cells[3].x); + + // The vowel sign U renders before the TAA: + try testing.expect(cells[1].x_offset < cells[2].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Bengali ligatures with out of order vowels" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Bengali for this to work, if we can't find + // Arial Unicode MS, we just skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Arial Unicode MS", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA + buf_idx += try std.unicode.utf8Encode(0x09be, buf[buf_idx..]); // Vowel sign AA + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x09b7, buf[buf_idx..]); // SSA + buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama + // Third grapheme cluster, combining with the second in a ligature: + buf_idx += try std.unicode.utf8Encode(0x099f, buf[buf_idx..]); // TTA + buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama + // Fourth grapheme cluster, combining with the previous two in a ligature: + buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA + buf_idx += try std.unicode.utf8Encode(0x09c7, buf[buf_idx..]); // Vowel sign E + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 8), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // we don't take on that complexity. + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 0), cells[3].x); + try testing.expectEqual(@as(u16, 0), cells[4].x); + try testing.expectEqual(@as(u16, 0), cells[5].x); + try testing.expectEqual(@as(u16, 0), cells[6].x); + try testing.expectEqual(@as(u16, 0), cells[7].x); + + // The vowel sign E renders before the SSA: + try testing.expect(cells[2].x_offset < cells[3].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} From 7a306e52c2b4ca4bef038e1c9f576ffaea5b52d9 Mon Sep 17 00:00:00 2001 From: Everton Correia <1169768+evertonstz@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:49:23 -0300 Subject: [PATCH 458/605] Update src/apprt/gtk/class/surface.zig Co-authored-by: Leah Amelia Chen --- src/apprt/gtk/class/surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 8f219677d..cb7ac7d5f 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -2753,7 +2753,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and priv.gtk_enable_primary_paste == false) { + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } From 29adcf4b6481d849896d9d0fe9bbcb61dc786e38 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Thu, 15 Jan 2026 17:16:11 -0300 Subject: [PATCH 459/605] Enhance GTK settings handling with well-defined types and utility functions --- src/apprt/gtk/gsettings.zig | 147 +++++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 18 deletions(-) diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig index 1e9f14a1f..2964f3b62 100644 --- a/src/apprt/gtk/gsettings.zig +++ b/src/apprt/gtk/gsettings.zig @@ -3,32 +3,143 @@ const builtin = @import("builtin"); const gtk = @import("gtk"); const gobject = @import("gobject"); -/// Reads a GTK setting using the GTK Settings API. +/// GTK Settings keys with well-defined types. +pub const Key = enum { + gtk_enable_primary_paste, + gtk_xft_dpi, + gtk_font_name, + + fn Type(comptime self: Key) type { + return switch (self) { + .gtk_enable_primary_paste => bool, + .gtk_xft_dpi => c_int, + .gtk_font_name => []const u8, + }; + } + + fn GValueType(comptime self: Key) type { + return switch (self) { + // Booleans are stored as integers in GTK's internal representation + .gtk_enable_primary_paste, + => c_int, + + // Integer types + .gtk_xft_dpi, + => c_int, + + // String types (returned as null-terminated C strings from GTK) + .gtk_font_name, + => ?[*:0]const u8, + }; + } + + fn propertyName(comptime self: Key) [*:0]const u8 { + return switch (self) { + .gtk_enable_primary_paste => "gtk-enable-primary-paste", + .gtk_xft_dpi => "gtk-xft-dpi", + .gtk_font_name => "gtk-font-name", + }; + } + + /// Returns true if this setting type requires memory allocation. + /// this is defensive: types that do not need allocation need to be + /// explicitly marked here + fn requiresAllocation(comptime self: Key) bool { + const T = self.Type(); + return switch (T) { + bool, c_int => false, + else => true, + }; + } +}; + +/// Reads a GTK setting using the GTK Settings API for non-allocating types. /// This automatically uses XDG Desktop Portal in Flatpak environments. -/// Returns null if not on a GTK-supported platform or if the setting cannot be read. /// -/// Supported platforms: Linux, FreeBSD -/// Supported types: bool, c_int +/// No allocator is required or used. Returns null if the setting is not available or cannot be read. /// /// Example usage: -/// const enabled = readSetting(bool, "gtk-enable-primary-paste"); -/// const dpi = readSetting(c_int, "gtk-xft-dpi"); -pub fn readSetting(comptime T: type, key: [*:0]const u8) ?T { - // Only available on systems that use GTK (Linux, FreeBSD) - if (comptime builtin.os.tag != .linux and builtin.os.tag != .freebsd) return null; - +/// const enabled = get(.gtk_enable_primary_paste); +/// const dpi = get(.gtk_xft_dpi); +pub fn get(comptime key: Key) ?key.Type() { + comptime { + if (key.requiresAllocation()) { + @compileError("Allocating types require an allocator; use getAlloc() instead"); + } + } const settings = gtk.Settings.getDefault() orelse return null; + return getImpl(settings, null, key) catch unreachable; +} - // For bool and c_int, we use c_int as the underlying GObject type - // because GTK boolean properties are stored as integers - var value = gobject.ext.Value.new(c_int); +/// Reads a GTK setting using the GTK Settings API, allocating if necessary. +/// This automatically uses XDG Desktop Portal in Flatpak environments. +/// +/// The caller must free any returned allocated memory with the provided allocator. +/// Returns null if the setting is not available or cannot be read. +/// May return an allocation error if memory allocation fails. +/// +/// Example usage: +/// const theme = try getAlloc(allocator, .gtk_theme_name); +/// defer if (theme) |t| allocator.free(t); +pub fn getAlloc(allocator: std.mem.Allocator, comptime key: Key) !?key.Type() { + const settings = gtk.Settings.getDefault() orelse return null; + return getImpl(settings, allocator, key); +} + +/// Shared implementation for reading GTK settings. +/// If allocator is null, only non-allocating types can be used. +/// Note: When adding a new type, research if it requires allocation (strings and boxed types do) +/// if allocation is NOT needed, list it inside the switch statement in the function requiresAllocation() +fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: Key) !?key.Type() { + const GValType = key.GValueType(); + var value = gobject.ext.Value.new(GValType); defer value.unset(); - settings.as(gobject.Object).getProperty(key, &value); + settings.as(gobject.Object).getProperty(key.propertyName(), &value); - return switch (T) { - bool => value.getInt() != 0, - c_int => value.getInt(), - else => @compileError("Unsupported type '" ++ @typeName(T) ++ "' for GTK setting. Supported types: bool, c_int"), + return switch (key) { + // Booleans are stored as integers in GTK, convert to bool + .gtk_enable_primary_paste, + => value.getInt() != 0, + + // Integer types are returned directly + .gtk_xft_dpi, + => value.getInt(), + + // Strings: GTK owns the GValue's pointer, so we must duplicate it + // before the GValue is destroyed by defer value.unset() + .gtk_font_name, + => blk: { + // This is defensive: we have already checked at compile-time that + // an allocator is provided for allocating types + const alloc = allocator.?; + const ptr = value.getString() orelse break :blk null; + const str = std.mem.span(ptr); + break :blk try alloc.dupe(u8, str); + }, }; } + +test "Key.Type returns correct types" { + try std.testing.expectEqual(bool, Key.gtk_enable_primary_paste.Type()); + try std.testing.expectEqual(c_int, Key.gtk_xft_dpi.Type()); + try std.testing.expectEqual([]const u8, Key.gtk_font_name.Type()); +} + +test "Key.requiresAllocation identifies allocating types" { + try std.testing.expectEqual(false, Key.gtk_enable_primary_paste.requiresAllocation()); + try std.testing.expectEqual(false, Key.gtk_xft_dpi.requiresAllocation()); + try std.testing.expectEqual(true, Key.gtk_font_name.requiresAllocation()); +} + +test "Key.GValueType returns correct GObject types" { + try std.testing.expectEqual(c_int, Key.gtk_enable_primary_paste.GValueType()); + try std.testing.expectEqual(c_int, Key.gtk_xft_dpi.GValueType()); + try std.testing.expectEqual(?[*:0]const u8, Key.gtk_font_name.GValueType()); +} + +test "Key.propertyName returns correct GTK property names" { + try std.testing.expectEqualSlices(u8, "gtk-enable-primary-paste", std.mem.span(Key.gtk_enable_primary_paste.propertyName())); + try std.testing.expectEqualSlices(u8, "gtk-xft-dpi", std.mem.span(Key.gtk_xft_dpi.propertyName())); + try std.testing.expectEqualSlices(u8, "gtk-font-name", std.mem.span(Key.gtk_font_name.propertyName())); +} From db7df92a81adb4c1ad9951afc0009d0dcb5f2cec Mon Sep 17 00:00:00 2001 From: evertonstz Date: Thu, 15 Jan 2026 18:15:31 -0300 Subject: [PATCH 460/605] Refactor gsettings usage for gtk-xft-dpi and gtk-enable-primary-paste with improved logging --- src/apprt/gtk/class/surface.zig | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cb7ac7d5f..7ba4b9c51 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1517,7 +1517,7 @@ pub const Surface = extern struct { const xft_dpi_scale = xft_scale: { // gtk-xft-dpi is font DPI multiplied by 1024. See // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const gtk_xft_dpi = gsettings.readSetting(c_int, "gtk-xft-dpi") orelse { + const gtk_xft_dpi = gsettings.get(.gtk_xft_dpi) orelse { log.warn("gtk-xft-dpi was not set, using default value", .{}); break :xft_scale 1.0; }; @@ -1527,7 +1527,7 @@ pub const Surface = extern struct { // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead if (gtk_xft_dpi <= 0) { - log.warn("gtk-xft-dpi was not set, using default value", .{}); + log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi}); break :xft_scale 1.0; } @@ -1773,7 +1773,10 @@ pub const Surface = extern struct { // Read GNOME desktop interface settings for primary paste (middle-click) // This is only relevant on Linux systems with GNOME settings available - priv.gtk_enable_primary_paste = gsettings.readSetting(bool, "gtk-enable-primary-paste"); + priv.gtk_enable_primary_paste = gsettings.get(.gtk_enable_primary_paste) orelse blk: { + log.warn("gtk-enable-primary-paste was not set, using default value", .{}); + break :blk false; + }; // Set up to handle items being dropped on our surface. Files can be dropped // from Nautilus and strings can be dropped from many programs. The order @@ -2696,7 +2699,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and priv.gtk_enable_primary_paste == false) { + if (button == .middle and !(priv.gtk_enable_primary_paste orelse true)) { return; } @@ -2753,7 +2756,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and !priv.gtk_enable_primary_paste) { + if (button == .middle and !(priv.gtk_enable_primary_paste orelse false)) { return; } From 04a7bcd138dcbc69a8b62294b67d813253b98b24 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Thu, 15 Jan 2026 18:20:44 -0300 Subject: [PATCH 461/605] Fix middle button paste condition to respect GNOME settings --- src/apprt/gtk/class/surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 7ba4b9c51..360ce3f88 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -2756,7 +2756,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and !(priv.gtk_enable_primary_paste orelse false)) { + if (button == .middle and !(priv.gtk_enable_primary_paste orelse true)) { return; } From c553296d7a8721e9445b7369771d81de09fa2bc8 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Thu, 15 Jan 2026 19:08:37 -0300 Subject: [PATCH 462/605] Remove unused import of 'builtin' in gsettings.zig --- src/apprt/gtk/gsettings.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig index 2964f3b62..97b671c7a 100644 --- a/src/apprt/gtk/gsettings.zig +++ b/src/apprt/gtk/gsettings.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const builtin = @import("builtin"); const gtk = @import("gtk"); const gobject = @import("gobject"); From 3ca8b97ca7d18efe763af47cd00b212b4f9d6e07 Mon Sep 17 00:00:00 2001 From: Julian Haag Date: Fri, 16 Jan 2026 10:53:50 +0100 Subject: [PATCH 463/605] fix(zsh): strip control characters from window title The zsh shell integration was using `${(V)1}` parameter expansion to set the window title, which converts control characters to their visible escape sequence representations. This caused commands ending with a newline to display as `command\n` in the title bar. Changed to use `${1//[[:cntrl:]]}` which strips control characters entirely, matching the behavior of the bash integration. --- src/shell-integration/zsh/ghostty-integration | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index febf3e59c..3648a80c0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -204,8 +204,10 @@ _ghostty_deferred_init() { # Enable terminal title changes. functions[_ghostty_precmd]+=" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(%):-%(4~|…/%3~|%~)}\"\$'\\a'" + # Strip control characters from the command to avoid displaying escape + # sequences like \n in the title. functions[_ghostty_preexec]+=" - builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(V)1}\"\$'\\a'" + builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${1//[[:cntrl:]]}\"\$'\\a'" fi if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then From ca924f4f4506e72ddaf40d323932750c2ffbfcff Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 16 Jan 2026 08:53:18 -0500 Subject: [PATCH 464/605] zsh: improve title-related comment --- src/shell-integration/zsh/ghostty-integration | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 3648a80c0..3fb3ec19b 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -201,11 +201,9 @@ _ghostty_deferred_init() { _ghostty_report_pwd if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - # Enable terminal title changes. + # Enable terminal title changes, formatted for user-friendly display. functions[_ghostty_precmd]+=" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(%):-%(4~|…/%3~|%~)}\"\$'\\a'" - # Strip control characters from the command to avoid displaying escape - # sequences like \n in the title. functions[_ghostty_preexec]+=" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${1//[[:cntrl:]]}\"\$'\\a'" fi From bf1ca59196d8dec6bd3953fa42eea0d544f02dcf Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 16 Jan 2026 09:28:19 -0500 Subject: [PATCH 465/605] shellcheck: move common directives to .shellcheckrc This simplifies our CI command line and makes it easier to document expected usage (in HACKING.md). There unfortunately isn't a way to set --checked-sourced or our default warning level in .shellcheckrc, and our `find` command is still a bit unwieldy, but this is still a net improvement. --- .github/workflows/test.yml | 2 -- .shellcheckrc | 8 ++++++++ HACKING.md | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .shellcheckrc diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a9adcfbc2..fbac22a47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -978,8 +978,6 @@ jobs: --check-sourced \ --color=always \ --severity=warning \ - --shell=bash \ - --external-sources \ $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) translations: diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 000000000..919cc175d --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,8 @@ +# ShellCheck +# https://github.com/koalaman/shellcheck/wiki/Directive#shellcheckrc-file + +# Allow opening any 'source'd file, even if not specified as input +external-sources=true + +# Assume bash by default +shell=bash diff --git a/HACKING.md b/HACKING.md index bde50ec99..0abb3a2d8 100644 --- a/HACKING.md +++ b/HACKING.md @@ -164,6 +164,28 @@ alejandra . Make sure your Alejandra version matches the version of Alejandra in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix). +### ShellCheck + +Bash scripts are checked with [ShellCheck](https://www.shellcheck.net/) in CI. + +Nix users can use the following command to run ShellCheck over all of our scripts: + +``` +nix develop -c shellcheck \ + --check-sourced \ + --severity=warning \ + $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) +``` + +Non-Nix users can [install ShellCheck](https://github.com/koalaman/shellcheck#user-content-installing) and then run: + +``` +shellcheck \ + --check-sourced \ + --severity=warning \ + $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) +``` + ### Updating the Zig Cache Fixed-Output Derivation Hash The Nix package depends on a [fixed-output From 80bf50be1d3d22547b84457834b261c2ee2e85b5 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Fri, 16 Jan 2026 09:25:50 -0500 Subject: [PATCH 466/605] set cluster level to match CoreText logic --- pkg/harfbuzz/buffer.zig | 40 ++ src/font/shaper/harfbuzz.zig | 770 ++++++++++++++++++++--------------- 2 files changed, 487 insertions(+), 323 deletions(-) diff --git a/pkg/harfbuzz/buffer.zig b/pkg/harfbuzz/buffer.zig index 035120f1a..b97c1bef4 100644 --- a/pkg/harfbuzz/buffer.zig +++ b/pkg/harfbuzz/buffer.zig @@ -238,6 +238,12 @@ pub const Buffer = struct { pub fn guessSegmentProperties(self: Buffer) void { c.hb_buffer_guess_segment_properties(self.handle); } + + /// Sets the cluster level of a buffer. The `ClusterLevel` dictates one + /// aspect of how HarfBuzz will treat non-base characters during shaping. + pub fn setClusterLevel(self: Buffer, level: ClusterLevel) void { + c.hb_buffer_set_cluster_level(self.handle, @intFromEnum(level)); + } }; /// The type of hb_buffer_t contents. @@ -252,6 +258,40 @@ pub const ContentType = enum(u2) { glyphs = c.HB_BUFFER_CONTENT_TYPE_GLYPHS, }; +/// Data type for holding HarfBuzz's clustering behavior options. The cluster +/// level dictates one aspect of how HarfBuzz will treat non-base characters +/// during shaping. +pub const ClusterLevel = enum(u2) { + /// In `monotone_graphemes`, non-base characters are merged into the + /// cluster of the base character that precedes them. There is also cluster + /// merging every time the clusters will otherwise become non-monotone. + /// This is the default cluster level. + monotone_graphemes = c.HB_BUFFER_CLUSTER_LEVEL_MONOTONE_GRAPHEMES, + + /// In `monotone_characters`, non-base characters are initially assigned + /// their own cluster values, which are not merged into preceding base + /// clusters. This allows HarfBuzz to perform additional operations like + /// reorder sequences of adjacent marks. The output is still monotone, but + /// the cluster values are more granular. + monotone_characters = c.HB_BUFFER_CLUSTER_LEVEL_MONOTONE_CHARACTERS, + + /// In `characters`, non-base characters are assigned their own cluster + /// values, which are not merged into preceding base clusters. Moreover, + /// the cluster values are not merged into monotone order. This is the most + /// granular cluster level, and it is useful for clients that need to know + /// the exact cluster values of each character, but is harder to use for + /// clients, since clusters might appear in any order. + characters = c.HB_BUFFER_CLUSTER_LEVEL_CHARACTERS, + + /// In `graphemes`, non-base characters are merged into the cluster of the + /// base character that precedes them. This is similar to the Unicode + /// Grapheme Cluster algorithm, but it is not exactly the same. The output + /// is not forced to be monotone. This is useful for clients that want to + /// use HarfBuzz as a cheap implementation of the Unicode Grapheme Cluster + /// algorithm. + graphemes = c.HB_BUFFER_CLUSTER_LEVEL_GRAPHEMES, +}; + /// The hb_glyph_info_t is the structure that holds information about the /// glyphs and their relation to input text. pub const GlyphInfo = extern struct { diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 0acf06bbf..744f98254 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -266,6 +266,12 @@ pub const Shaper = struct { // Reset the buffer for our current run self.shaper.hb_buf.reset(); self.shaper.hb_buf.setContentType(.unicode); + + // We set the cluster level to `characters` to give us the most + // granularity, matching the CoreText shaper, and allowing us + // to use our same ligature detection heuristics. + self.shaper.hb_buf.setClusterLevel(.characters); + self.shaper.codepoints.clearRetainingCapacity(); // We don't support RTL text because RTL in terminals is messy. @@ -325,12 +331,13 @@ pub const Shaper = struct { break :blk false; } else false; - const formatted_cps = if (positions_differ or + const formatted_cps: ?[]u8 = if (positions_differ or y_offset_differs or cluster_differs or extra_debugging) blk: { var allocating = std.Io.Writer.Allocating.init(self.alloc); + defer allocating.deinit(); const writer = &allocating.writer; const codepoints = self.codepoints.items; var last_cluster: ?u32 = null; @@ -364,7 +371,8 @@ pub const Shaper = struct { } } break :blk try allocating.toOwnedSlice(); - } else ""; + } else null; + defer if (formatted_cps) |cps| self.alloc.free(cps); if (extra_debugging) { log.warn("extra debugging of positions index={d} cell_offset.cluster={d} cluster={d} run_offset.cluster={d} diff={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} is_prev_prepend={} cps = {s}", .{ @@ -379,7 +387,7 @@ pub const Shaper = struct { run_offset.y, cell_offset.x, is_previous_codepoint_prepend, - formatted_cps, + formatted_cps.?, }); } @@ -392,19 +400,19 @@ pub const Shaper = struct { advance_y_offset, x_offset_diff, y_offset_diff, - formatted_cps, + formatted_cps.?, }); } if (y_offset_differs) { - log.warn("y_offset differs from zero: cluster={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} cps = {s}", .{ + log.warn("run_offset.y differs from zero: cluster={d} pos=({d},{d}) run_offset=({d},{d}) cell_offset.x={d} cps = {s}", .{ cluster, x_offset, y_offset, run_offset.x, run_offset.y, cell_offset.x, - formatted_cps, + formatted_cps.?, }); } @@ -420,7 +428,7 @@ pub const Shaper = struct { run_offset.y, cell_offset.x, is_previous_codepoint_prepend, - formatted_cps, + formatted_cps.?, }); } } @@ -966,7 +974,7 @@ test "shape with empty cells in between" { } } -test "shape Chinese characters" { +test "shape Combining characters" { const testing = std.testing; const alloc = testing.allocator; @@ -1015,6 +1023,437 @@ test "shape Chinese characters" { try testing.expectEqual(@as(usize, 1), count); } +// This test exists because the string it uses causes HarfBuzz to output a +// non-monotonic run with our cluster level set to `characters`, which we need +// to handle by tracking the max cluster for the run. +test "shape Devanagari string" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports devanagari for this to work, if we can't + // find Arial Unicode MS, which is a system font on macOS, we just skip + // the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Arial Unicode MS", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("अपार्टमेंट"); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + + const run = try it.next(alloc); + try testing.expect(run != null); + const cells = try shaper.shape(run.?); + + try testing.expectEqual(@as(usize, 8), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 1), cells[1].x); + try testing.expectEqual(@as(u16, 2), cells[2].x); + try testing.expectEqual(@as(u16, 3), cells[3].x); + try testing.expectEqual(@as(u16, 4), cells[4].x); + try testing.expectEqual(@as(u16, 5), cells[5].x); + try testing.expectEqual(@as(u16, 5), cells[6].x); + try testing.expectEqual(@as(u16, 6), cells[7].x); + + try testing.expect(try it.next(alloc) == null); +} + +test "shape Tai Tham vowels (position differs from advance)" { + // Note that while this test was necessary for CoreText, the old logic was + // working for HarfBuzz. Still we keep it to ensure it has the correct + // behavior. + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + + // The first glyph renders in the next cell + try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); + try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Tibetan characters" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that has multiple glyphs for this codepoint to reproduce + // the old broken behavior, and Noto Serif Tibetan is one of them. It's not + // a default Mac font, and if we can't find it we just skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Serif Tibetan", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x0f00, buf[buf_idx..]); // ༀ + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + + // The second glyph renders at the correct location + try testing.expect(cells[1].x_offset < 2); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Tai Tham letters (run_offset.y differs from zero)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1a48, buf[buf_idx..]); // MA + buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT + buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // LOW LA + buf_idx += try std.unicode.utf8Encode(0x1a75, buf[buf_idx..]); // Tone-1 + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1a41, buf[buf_idx..]); // HIGH PA + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme + + // The U glyph renders at a y below zero + try testing.expectEqual(@as(i16, -3), cells[2].y_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Javanese ligatures" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Javanese for this to work, if we can't find + // Noto Sans Javanese Regular, which is a system font on macOS, we just + // skip the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Javanese", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA + buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON + // Second grapheme cluster, combining with the first in a ligature: + buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA + buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 3), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + + // The vowel sign SUKU renders with correct x_offset + try testing.expect(cells[2].x_offset > 3 * cell_width); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Chakma vowel sign with ligature (vowel sign renders first)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Chakma for this to work, if we can't find + // Noto Sans Chakma Regular, which is a system font on macOS, we just skip + // the test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Chakma", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x1111d, buf[buf_idx..]); // BAA + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x11116, buf[buf_idx..]); // TAA + buf_idx += try std.unicode.utf8Encode(0x11133, buf[buf_idx..]); // Virama + // Third grapheme cluster, combining with the second in a ligature: + buf_idx += try std.unicode.utf8Encode(0x11120, buf[buf_idx..]); // YYAA + buf_idx += try std.unicode.utf8Encode(0x1112c, buf[buf_idx..]); // Vowel Sign U + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 4), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // we don't take on that complexity. + try testing.expectEqual(@as(u16, 0), cells[1].x); + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 0), cells[3].x); + + // The vowel sign U renders before the TAA: + try testing.expect(cells[1].x_offset < cells[2].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + +test "shape Bengali ligatures with out of order vowels" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Bengali for this to work, if we can't find + // Arial Unicode MS, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Arial Unicode MS", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + + // First grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA + buf_idx += try std.unicode.utf8Encode(0x09be, buf[buf_idx..]); // Vowel sign AA + // Second grapheme cluster: + buf_idx += try std.unicode.utf8Encode(0x09b7, buf[buf_idx..]); // SSA + buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama + // Third grapheme cluster, combining with the second in a ligature: + buf_idx += try std.unicode.utf8Encode(0x099f, buf[buf_idx..]); // TTA + buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama + // Fourth grapheme cluster, combining with the previous two in a ligature: + buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA + buf_idx += try std.unicode.utf8Encode(0x09c7, buf[buf_idx..]); // Vowel sign E + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice(buf[0..buf_idx]); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 8), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // we don't take on that complexity. + try testing.expectEqual(@as(u16, 0), cells[2].x); + try testing.expectEqual(@as(u16, 0), cells[3].x); + try testing.expectEqual(@as(u16, 0), cells[4].x); + try testing.expectEqual(@as(u16, 0), cells[5].x); + try testing.expectEqual(@as(u16, 0), cells[6].x); + try testing.expectEqual(@as(u16, 0), cells[7].x); + + // The vowel sign E renders before the SSA: + try testing.expect(cells[2].x_offset < cells[3].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; @@ -1707,318 +2146,3 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS .lib = lib, }; } - -test "shape Tai Tham vowels (y_offset differs from zero)" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, we just skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ - buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - const cell_width = run.grid.metrics.cell_width; - try testing.expectEqual(@as(usize, 2), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - - // The first glyph renders in the next cell - try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); - try testing.expectEqual(@as(i16, 0), cells[1].x_offset); - } - try testing.expectEqual(@as(usize, 1), count); -} - -test "shape Tai Tham letters (y_offset differs from zero)" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, we just skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a48, buf[buf_idx..]); // MA - buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT - buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // LOW LA - buf_idx += try std.unicode.utf8Encode(0x1a75, buf[buf_idx..]); // Tone-1 - // Second grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a41, buf[buf_idx..]); // HIGH PA - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme - - // The U glyph renders at a y below zero - try testing.expectEqual(@as(i16, -3), cells[2].y_offset); - } - try testing.expectEqual(@as(usize, 1), count); -} - -test "shape Javanese ligatures" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Javanese for this to work, if we can't find - // Noto Sans Javanese Regular, we just skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Javanese", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA - buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - const cell_width = run.grid.metrics.cell_width; - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); - - // The vowel sign SUKU renders with correct x_offset - try testing.expect(cells[2].x_offset > 3 * cell_width); - } - try testing.expectEqual(@as(usize, 1), count); -} - -test "shape Chakma vowel sign with ligature (vowel sign renders first)" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Chakma for this to work, if we can't find - // Noto Sans Chakma Regular, we just skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Chakma", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1111d, buf[buf_idx..]); // BAA - // Second grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x11116, buf[buf_idx..]); // TAA - buf_idx += try std.unicode.utf8Encode(0x11133, buf[buf_idx..]); // Virama - // Third grapheme cluster, combining with the second in a ligature: - buf_idx += try std.unicode.utf8Encode(0x11120, buf[buf_idx..]); // YYAA - buf_idx += try std.unicode.utf8Encode(0x1112c, buf[buf_idx..]); // Vowel Sign U - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 4), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - // See the giant "We need to reset the `cell_offset`" comment, but here - // we should technically have the rest of these be `x` of 1, but that - // would require going back in the stream to adjust past cells, and - // we don't take on that complexity. - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); - try testing.expectEqual(@as(u16, 0), cells[3].x); - - // The vowel sign U renders before the TAA: - try testing.expect(cells[1].x_offset < cells[2].x_offset); - } - try testing.expectEqual(@as(usize, 1), count); -} - -test "shape Bengali ligatures with out of order vowels" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Bengali for this to work, if we can't find - // Arial Unicode MS, we just skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Arial Unicode MS", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA - buf_idx += try std.unicode.utf8Encode(0x09be, buf[buf_idx..]); // Vowel sign AA - // Second grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x09b7, buf[buf_idx..]); // SSA - buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama - // Third grapheme cluster, combining with the second in a ligature: - buf_idx += try std.unicode.utf8Encode(0x099f, buf[buf_idx..]); // TTA - buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama - // Fourth grapheme cluster, combining with the previous two in a ligature: - buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA - buf_idx += try std.unicode.utf8Encode(0x09c7, buf[buf_idx..]); // Vowel sign E - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 8), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - // See the giant "We need to reset the `cell_offset`" comment, but here - // we should technically have the rest of these be `x` of 1, but that - // would require going back in the stream to adjust past cells, and - // we don't take on that complexity. - try testing.expectEqual(@as(u16, 0), cells[2].x); - try testing.expectEqual(@as(u16, 0), cells[3].x); - try testing.expectEqual(@as(u16, 0), cells[4].x); - try testing.expectEqual(@as(u16, 0), cells[5].x); - try testing.expectEqual(@as(u16, 0), cells[6].x); - try testing.expectEqual(@as(u16, 0), cells[7].x); - - // The vowel sign E renders before the SSA: - try testing.expect(cells[2].x_offset < cells[3].x_offset); - } - try testing.expectEqual(@as(usize, 1), count); -} From 60da5eb3a671bb6889adc6c76b090fdb148c003b Mon Sep 17 00:00:00 2001 From: Everton Correia <1169768+evertonstz@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:25:49 -0300 Subject: [PATCH 467/605] Apply suggestion from @pluiedev Co-authored-by: Leah Amelia Chen --- src/apprt/gtk/gsettings.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig index 97b671c7a..f96e9f01c 100644 --- a/src/apprt/gtk/gsettings.zig +++ b/src/apprt/gtk/gsettings.zig @@ -61,10 +61,8 @@ pub const Key = enum { /// const enabled = get(.gtk_enable_primary_paste); /// const dpi = get(.gtk_xft_dpi); pub fn get(comptime key: Key) ?key.Type() { - comptime { - if (key.requiresAllocation()) { - @compileError("Allocating types require an allocator; use getAlloc() instead"); - } + if (comptime key.requiresAllocation()) { + @compileError("Allocating types require an allocator; use getAlloc() instead"); } const settings = gtk.Settings.getDefault() orelse return null; return getImpl(settings, null, key) catch unreachable; From 1c2c2257d4aff4924afdb2c152106de8d7df89fa Mon Sep 17 00:00:00 2001 From: evertonstz Date: Fri, 16 Jan 2026 12:29:31 -0300 Subject: [PATCH 468/605] Set default value for gtk_enable_primary_paste to true and simplify condition checks --- src/apprt/gtk/class/surface.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 360ce3f88..ea5ca203f 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -676,9 +676,8 @@ pub const Surface = extern struct { context: apprt.surface.NewSurfaceContext = .window, /// Whether primary paste (middle-click paste) is enabled via GNOME settings. - /// If null, the setting could not be read (non-GNOME system or schema missing). /// If true, middle-click paste is enabled. If false, it's disabled. - gtk_enable_primary_paste: ?bool = null, + gtk_enable_primary_paste: ?bool = true, pub var offset: c_int = 0; }; @@ -2699,7 +2698,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and !(priv.gtk_enable_primary_paste orelse true)) { + if (button == .middle and !(priv.gtk_enable_primary_paste)) { return; } @@ -2756,7 +2755,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and !(priv.gtk_enable_primary_paste orelse true)) { + if (button == .middle and !(priv.gtk_enable_primary_paste)) { return; } From c587d7a3a0db439e5d3f2b3f9374146b94b34f97 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Fri, 16 Jan 2026 09:48:44 -0500 Subject: [PATCH 469/605] fix Bengali, Tai Tham letters, and Devanagari tests --- src/font/shaper/harfbuzz.zig | 42 +++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 744f98254..6e90f3e05 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -305,11 +305,11 @@ pub const Shaper = struct { pos_v: harfbuzz.GlyphPosition, index: u32, ) !void { - const x_offset = cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); + const x_offset = run_offset.x - cell_offset.x + ((pos_v.x_offset + 0b100_000) >> 6); const y_offset = run_offset.y + ((pos_v.y_offset + 0b100_000) >> 6); const advance_x_offset = run_offset.x - cell_offset.x; const advance_y_offset = run_offset.y; - const x_offset_diff = x_offset - cell_offset.x - advance_x_offset; + const x_offset_diff = x_offset - advance_x_offset; const y_offset_diff = y_offset - advance_y_offset; const positions_differ = @abs(x_offset_diff) > 0 or @abs(y_offset_diff) > 0; const y_offset_differs = run_offset.y != 0; @@ -1069,7 +1069,7 @@ test "shape Devanagari string" { try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 1), cells[1].x); try testing.expectEqual(@as(u16, 2), cells[2].x); - try testing.expectEqual(@as(u16, 3), cells[3].x); + try testing.expectEqual(@as(u16, 4), cells[3].x); try testing.expectEqual(@as(u16, 4), cells[4].x); try testing.expectEqual(@as(u16, 5), cells[5].x); try testing.expectEqual(@as(u16, 5), cells[6].x); @@ -1207,12 +1207,11 @@ test "shape Tai Tham letters (run_offset.y differs from zero)" { var buf_idx: usize = 0; // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a48, buf[buf_idx..]); // MA + buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT - buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // LOW LA - buf_idx += try std.unicode.utf8Encode(0x1a75, buf[buf_idx..]); // Tone-1 - // Second grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a41, buf[buf_idx..]); // HIGH PA + // Second grapheme cluster, combining with the first in a ligature: + buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA + buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U // Make a screen with some data var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); @@ -1380,6 +1379,10 @@ test "shape Chakma vowel sign with ligature (vowel sign renders first)" { } test "shape Bengali ligatures with out of order vowels" { + // Whereas this test in CoreText had everything shaping into one giant + // ligature, HarfBuzz splits it into a few clusters. It still looks okay + // (see #10332). + const testing = std.testing; const alloc = testing.allocator; @@ -1401,10 +1404,10 @@ test "shape Bengali ligatures with out of order vowels" { // Second grapheme cluster: buf_idx += try std.unicode.utf8Encode(0x09b7, buf[buf_idx..]); // SSA buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama - // Third grapheme cluster, combining with the second in a ligature: + // Third grapheme cluster: buf_idx += try std.unicode.utf8Encode(0x099f, buf[buf_idx..]); // TTA buf_idx += try std.unicode.utf8Encode(0x09cd, buf[buf_idx..]); // Virama - // Fourth grapheme cluster, combining with the previous two in a ligature: + // Fourth grapheme cluster: buf_idx += try std.unicode.utf8Encode(0x09b0, buf[buf_idx..]); // RA buf_idx += try std.unicode.utf8Encode(0x09c7, buf[buf_idx..]); // Vowel sign E @@ -1437,16 +1440,15 @@ test "shape Bengali ligatures with out of order vowels" { try testing.expectEqual(@as(usize, 8), cells.len); try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 0), cells[1].x); - // See the giant "We need to reset the `cell_offset`" comment, but here - // we should technically have the rest of these be `x` of 1, but that - // would require going back in the stream to adjust past cells, and - // we don't take on that complexity. - try testing.expectEqual(@as(u16, 0), cells[2].x); - try testing.expectEqual(@as(u16, 0), cells[3].x); - try testing.expectEqual(@as(u16, 0), cells[4].x); - try testing.expectEqual(@as(u16, 0), cells[5].x); - try testing.expectEqual(@as(u16, 0), cells[6].x); - try testing.expectEqual(@as(u16, 0), cells[7].x); + + // Whereas CoreText puts everything all into the first cell (see the + // coresponding test), HarfBuzz splits into three clusters. + try testing.expectEqual(@as(u16, 1), cells[2].x); + try testing.expectEqual(@as(u16, 1), cells[3].x); + try testing.expectEqual(@as(u16, 2), cells[4].x); + try testing.expectEqual(@as(u16, 2), cells[5].x); + try testing.expectEqual(@as(u16, 2), cells[6].x); + try testing.expectEqual(@as(u16, 2), cells[7].x); // The vowel sign E renders before the SSA: try testing.expect(cells[2].x_offset < cells[3].x_offset); From f1dbdc796564c0d73c97808c1bfd87ac9d29fc5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 12:46:34 -0800 Subject: [PATCH 470/605] terminal: fix stale cursor pin usage after cursorChangePin Fixes #10282 The function `cursorChangePin` is supposed to be called anytime the cursor page pin changes, but it itself may alter the page pin if setting up the underlying managed memory forces a page size adjustment. Multiple callers to this function were erroneously reusing the old page pin value. --- src/terminal/Screen.zig | 147 ++++++++++++++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 30 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2861e02e5..fed0a8c51 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -640,9 +640,8 @@ pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { defer self.assertIntegrity(); self.cursor.y -= n; // Must be set before cursorChangePin - const page_pin = self.cursor.page_pin.up(n).?; - self.cursorChangePin(page_pin); - const page_rac = page_pin.rowAndCell(); + self.cursorChangePin(self.cursor.page_pin.up(n).?); + const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; } @@ -667,9 +666,8 @@ pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { // We move the offset into our page list to the next row and then // get the pointers to the row/cell and set all the cursor state up. - const page_pin = self.cursor.page_pin.down(n).?; - self.cursorChangePin(page_pin); - const page_rac = page_pin.rowAndCell(); + self.cursorChangePin(self.cursor.page_pin.down(n).?); + const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; } @@ -800,31 +798,37 @@ pub fn cursorDownScroll(self: *Screen) !void { // allocate, prune scrollback, whatever. _ = try self.pages.grow(); - // If our pin page change it means that the page that the pin - // was on was pruned. In this case, grow() moves the pin to - // the top-left of the new page. This effectively moves it by - // one already, we just need to fix up the x value. - const page_pin = if (old_pin.node == self.cursor.page_pin.node) - self.cursor.page_pin.down(1).? - else reuse: { - var pin = self.cursor.page_pin.*; - pin.x = self.cursor.x; - break :reuse pin; - }; + self.cursorChangePin(new_pin: { + // We do this all in a block here because referencing this pin + // after cursorChangePin is unsafe, and we want to keep it out + // of scope. - // These assertions help catch some pagelist math errors. Our - // x/y should be unchanged after the grow. - if (build_options.slow_runtime_safety) { - const active = self.pages.pointFromPin( - .active, - page_pin, - ).?.active; - assert(active.x == self.cursor.x); - assert(active.y == self.cursor.y); - } + // If our pin page change it means that the page that the pin + // was on was pruned. In this case, grow() moves the pin to + // the top-left of the new page. This effectively moves it by + // one already, we just need to fix up the x value. + const page_pin = if (old_pin.node == self.cursor.page_pin.node) + self.cursor.page_pin.down(1).? + else reuse: { + var pin = self.cursor.page_pin.*; + pin.x = self.cursor.x; + break :reuse pin; + }; - self.cursorChangePin(page_pin); - const page_rac = page_pin.rowAndCell(); + // These assertions help catch some pagelist math errors. Our + // x/y should be unchanged after the grow. + if (build_options.slow_runtime_safety) { + const active = self.pages.pointFromPin( + .active, + page_pin, + ).?.active; + assert(active.x == self.cursor.x); + assert(active.y == self.cursor.y); + } + + break :new_pin page_pin; + }); + const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; @@ -834,7 +838,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // Clear the new row so it gets our bg color. We only do this // if we have a bg color at all. if (self.cursor.style.bg_color != .none) { - const page: *Page = &page_pin.node.data; + const page: *Page = &self.cursor.page_pin.node.data; self.clearCells( page, self.cursor.page_row, @@ -1081,6 +1085,11 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { /// page than the old AND we have a style or hyperlink set. In that case, /// we must release our old one and insert the new one, since styles are /// stored per-page. +/// +/// Note that this can change the cursor pin AGAIN if the process of +/// setting up our cursor forces a capacity adjustment of the underlying +/// cursor page, so any references to the page pin should be re-read +/// from `self.cursor.page_pin` after calling this. inline fn cursorChangePin(self: *Screen, new: Pin) void { // Moving the cursor affects text run splitting (ligatures) so // we must mark the old and new page dirty. We do this as long @@ -9016,3 +9025,81 @@ test "Screen: adjustCapacity cursor style exceeds style set capacity" { try testing.expect(s.cursor.style.default()); try testing.expectEqual(style.default_id, s.cursor.style_id); } + +test "Screen: cursorDown to page with insufficient capacity" { + // Regression test for https://github.com/ghostty-org/ghostty/issues/10282 + // + // This test exposes a use-after-realloc bug in cursorDown (and similar + // cursor movement functions). The bug pattern: + // + // 1. cursorDown creates a by-value copy of the pin via page_pin.down(n) + // 2. cursorChangePin is called, which may trigger adjustCapacity + // if the target page's style map is full + // 3. adjustCapacity frees the old page and creates a new one + // 4. The local pin copy still points to the freed page + // 5. rowAndCell() on the stale pin accesses freed memory + + const testing = std.testing; + const alloc = testing.allocator; + + // Small screen to make page boundary crossing easy to set up + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + defer s.deinit(); + + // Scroll down enough to create a second page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + start_page.pauseIntegrityChecks(true); + for (0..rem) |_| try s.cursorDownOrScroll(); + start_page.pauseIntegrityChecks(false); + + // Cursor should now be on a new page + const new_page = &s.cursor.page_pin.node.data; + try testing.expect(start_page != new_page); + + // Fill new_page's style map to capacity. When we move INTO this page + // with a style set, adjustCapacity will be triggered. + { + new_page.pauseIntegrityChecks(true); + defer new_page.pauseIntegrityChecks(false); + defer new_page.assertIntegrity(); + + var n: u24 = 1; + while (new_page.styles.add( + new_page.memory, + .{ .bg_color = .{ .rgb = @bitCast(n) } }, + )) |_| n += 1 else |_| {} + } + + // Move cursor to start of active area and set a style + s.cursorAbsolute(0, 0); + try s.setAttribute(.bold); + try testing.expect(s.cursor.style.flags.bold); + try testing.expect(s.cursor.style_id != style.default_id); + + // Find the row just before the page boundary + for (0..s.pages.rows - 1) |row| { + s.cursorAbsolute(0, @intCast(row)); + const cur_node = s.cursor.page_pin.node; + if (s.cursor.page_pin.down(1)) |next_pin| { + if (next_pin.node != cur_node) { + // Cursor is at 'row', moving down crosses to new_page + try testing.expect(&next_pin.node.data == new_page); + + // This cursorDown triggers the bug: the local page_pin copy + // becomes stale after adjustCapacity, causing rowAndCell() + // to access freed memory. + s.cursorDown(1); + + // If the fix is applied, verify correct state + try testing.expect(s.cursor.y == row + 1); + try testing.expect(s.cursor.style.flags.bold); + + break; + } + } + } else { + // Didn't find boundary + try testing.expect(false); + } +} From 95a23f756d0160f814f1043b7b2ebe96f0941482 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 13 Jan 2026 13:09:41 -0800 Subject: [PATCH 471/605] terminal: more strict sizing for page capacities, max cap can fit 64-bit --- src/terminal/PageList.zig | 24 ++++++++++-------------- src/terminal/hyperlink.zig | 5 +++-- src/terminal/page.zig | 34 +++++++++++++++++++++++++++++----- src/terminal/size.zig | 27 +++++++++++++++++++++++++-- src/terminal/style.zig | 2 +- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index fc680b971..4592b3db6 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1318,7 +1318,7 @@ const ReflowCursor = struct { // Grow our capacity until we can // definitely fit the extra bytes. const required = cps.len * @sizeOf(u21); - var new_grapheme_capacity: usize = cap.grapheme_bytes; + var new_grapheme_capacity: size.GraphemeBytesInt = cap.grapheme_bytes; while (new_grapheme_capacity - cap.grapheme_bytes < required) { new_grapheme_capacity *= 2; } @@ -1362,7 +1362,7 @@ const ReflowCursor = struct { } else |_| { // Grow our capacity until we can // definitely fit the extra bytes. - var new_string_capacity: usize = cap.string_bytes; + var new_string_capacity: size.StringBytesInt = cap.string_bytes; while (new_string_capacity - cap.string_bytes < additional_required_string_capacity) { new_string_capacity *= 2; } @@ -2647,16 +2647,16 @@ pub const AdjustCapacity = struct { /// Adjust the number of styles in the page. This may be /// rounded up if necessary to fit alignment requirements, /// but it will never be rounded down. - styles: ?usize = null, + styles: ?size.StyleCountInt = null, /// Adjust the number of available grapheme bytes in the page. - grapheme_bytes: ?usize = null, + grapheme_bytes: ?size.GraphemeBytesInt = null, /// Adjust the number of available hyperlink bytes in the page. - hyperlink_bytes: ?usize = null, + hyperlink_bytes: ?size.HyperlinkCountInt = null, /// Adjust the number of available string bytes in the page. - string_bytes: ?usize = null, + string_bytes: ?size.StringBytesInt = null, }; pub const AdjustCapacityError = Allocator.Error || Page.CloneFromError; @@ -2692,23 +2692,19 @@ pub fn adjustCapacity( // All ceilPowerOfTwo is unreachable because we're always same or less // bit width so maxInt is always possible. if (adjustment.styles) |v| { - comptime assert(@bitSizeOf(@TypeOf(v)) <= @bitSizeOf(usize)); - const aligned = std.math.ceilPowerOfTwo(usize, v) catch unreachable; + const aligned = std.math.ceilPowerOfTwo(size.StyleCountInt, v) catch unreachable; cap.styles = @max(cap.styles, aligned); } if (adjustment.grapheme_bytes) |v| { - comptime assert(@bitSizeOf(@TypeOf(v)) <= @bitSizeOf(usize)); - const aligned = std.math.ceilPowerOfTwo(usize, v) catch unreachable; + const aligned = std.math.ceilPowerOfTwo(size.GraphemeBytesInt, v) catch unreachable; cap.grapheme_bytes = @max(cap.grapheme_bytes, aligned); } if (adjustment.hyperlink_bytes) |v| { - comptime assert(@bitSizeOf(@TypeOf(v)) <= @bitSizeOf(usize)); - const aligned = std.math.ceilPowerOfTwo(usize, v) catch unreachable; + const aligned = std.math.ceilPowerOfTwo(size.HyperlinkCountInt, v) catch unreachable; cap.hyperlink_bytes = @max(cap.hyperlink_bytes, aligned); } if (adjustment.string_bytes) |v| { - comptime assert(@bitSizeOf(@TypeOf(v)) <= @bitSizeOf(usize)); - const aligned = std.math.ceilPowerOfTwo(usize, v) catch unreachable; + const aligned = std.math.ceilPowerOfTwo(size.StringBytesInt, v) catch unreachable; cap.string_bytes = @max(cap.string_bytes, aligned); } diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index 975e6f30e..94f86466c 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -13,8 +13,9 @@ const autoHash = std.hash.autoHash; const autoHashStrat = std.hash.autoHashStrat; /// The unique identifier for a hyperlink. This is at most the number of cells -/// that can fit in a single terminal page. -pub const Id = size.CellCountInt; +/// that can fit in a single terminal page, since each cell can only contain +/// at most one hyperlink. +pub const Id = size.HyperlinkCountInt; // The mapping of cell to hyperlink. We use an offset hash map to save space // since its very unlikely a cell is a hyperlink, so its a waste to store diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 848123405..559f536f1 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1569,7 +1569,10 @@ pub const Page = struct { const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align.toByteUnits()); const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size; - const grapheme_count = @divFloor(cap.grapheme_bytes, grapheme_chunk); + const grapheme_count = std.math.ceilPowerOfTwo( + usize, + @divFloor(cap.grapheme_bytes, grapheme_chunk), + ) catch unreachable; const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count)); const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits()); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; @@ -1639,25 +1642,33 @@ pub const Size = struct { }; /// Capacity of this page. +/// +/// This capacity can be maxed out (every field max) and still fit +/// within a 64-bit memory space. If you need more than this, you will +/// need to split data across separate pages. +/// +/// For 32-bit systems, it is possible to overflow the addressable +/// space and this is something we still need to address in the future +/// likely by limiting the maximum capacity on 32-bit systems further. pub const Capacity = struct { /// Number of columns and rows we can know about. cols: size.CellCountInt, rows: size.CellCountInt, /// Number of unique styles that can be used on this page. - styles: usize = 16, + styles: size.StyleCountInt = 16, /// Number of bytes to allocate for hyperlink data. Note that the /// amount of data used for hyperlinks in total is more than this because /// hyperlinks use string data as well as a small amount of lookup metadata. /// This number is a rough approximation. - hyperlink_bytes: usize = hyperlink_bytes_default, + hyperlink_bytes: size.HyperlinkCountInt = hyperlink_bytes_default, /// Number of bytes to allocate for grapheme data. - grapheme_bytes: usize = grapheme_bytes_default, + grapheme_bytes: size.GraphemeBytesInt = grapheme_bytes_default, /// Number of bytes to allocate for strings. - string_bytes: usize = string_bytes_default, + string_bytes: size.StringBytesInt = string_bytes_default, pub const Adjustment = struct { cols: ?size.CellCountInt = null, @@ -2025,6 +2036,19 @@ pub const Cell = packed struct(u64) { // //const pages = total_size / std.heap.page_size_min; // } +test "Page.layout can take a maxed capacity" { + // Our intention is for a maxed-out capacity to always fit + // within a page layout without trigering runtime safety on any + // overflow. This simplifies some of our handling downstream of the + // call (relevant to: https://github.com/ghostty-org/ghostty/issues/10258) + var cap: Capacity = undefined; + inline for (@typeInfo(Capacity).@"struct".fields) |field| { + @field(cap, field.name) = std.math.maxInt(field.type); + } + + _ = Page.layout(cap); +} + test "Cell is zero by default" { const cell = Cell.init(0); const cell_int: u64 = @bitCast(cell); diff --git a/src/terminal/size.zig b/src/terminal/size.zig index 0dedfcc14..7be09739e 100644 --- a/src/terminal/size.zig +++ b/src/terminal/size.zig @@ -11,9 +11,32 @@ pub const max_page_size = std.math.maxInt(u32); /// derived from the maximum terminal page size. pub const OffsetInt = std.math.IntFittingRange(0, max_page_size - 1); -/// The int type that can contain the maximum number of cells in a page. -pub const CellCountInt = u16; // TODO: derive +/// Int types for maximum values of things. A lot of these sizes are +/// based on "X is enough for any reasonable use case" principles. +// The goal is that a user can have the maxInt amount of all of these +// present at one time and be able to address them in a single Page.zig. + +// Total number of cells that are possible in each dimension (row/col). +// Based on 2^16 being enough for any reasonable terminal size and allowing +// IDs to remain 16-bit. +pub const CellCountInt = u16; + +// Total number of styles and hyperlinks that are possible in a page. +// We match CellCountInt here because each cell in a single row can have at +// most one style, making it simple to split a page by splitting rows. // +// Note due to the way RefCountedSet works, we are short one value, but +// this is a theoretical limit we accept. A page with a single row max +// columns wide would be one short of having every cell have a unique style. +pub const StyleCountInt = CellCountInt; +pub const HyperlinkCountInt = CellCountInt; + +// Total number of bytes that can be taken up by grapheme data and string +// data. Both of these technically unlimited with malicious input, but +// we choose a reasonable limit of 2^32 (4GB) per. +pub const GraphemeBytesInt = u32; +pub const StringBytesInt = u32; + /// The offset from the base address of the page to the start of some data. /// This is typed for ease of use. /// diff --git a/src/terminal/style.zig b/src/terminal/style.zig index e5c47b9fe..7908beefa 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -11,7 +11,7 @@ const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. -pub const Id = size.CellCountInt; +pub const Id = size.StyleCountInt; /// The Id to use for default styling. pub const default_id: Id = 0; From 8306f96d9460a440579d002bfcea254fac5889ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 13 Jan 2026 13:41:11 -0800 Subject: [PATCH 472/605] terminal: PageList.increaseCapacity --- src/terminal/PageList.zig | 399 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 4592b3db6..9c63fd578 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2736,6 +2736,110 @@ pub fn adjustCapacity( return new_node; } +/// Possible dimensions to increase capacity for. +pub const IncreaseCapacity = enum { + styles, + grapheme_bytes, + hyperlink_bytes, + string_bytes, +}; + +pub const IncreaseCapacityError = error{ + // An actual system OOM trying to allocate memory. + OutOfMemory, + + // The existing page is already at max capacity for the given + // adjustment. The caller must create a new page, remove data from + // the old page, etc. (up to the caller). + OutOfSpace, +}; + +/// Increase the capacity of the given page node in the given direction. +/// This will always allocate a new node and remove the old node, so the +/// existing node pointer will be invalid after this call. The newly created +/// node on success is returned. +/// +/// The increase amount is at the control of the PageList implementation, +/// but is guaranteed to always increase by at least one unit in the +/// given dimension. Practically, we'll always increase by much more +/// (we currently double every time) but callers shouldn't depend on that. +/// The only guarantee is some amount of growth. +/// +/// Adjustment can be null if you want to recreate, reclone the page +/// with the same capacity. This is a special case used for rehashing since +/// the logic is otherwise the same. In this case, OutOfMemory is the +/// only possible error. +pub fn increaseCapacity( + self: *PageList, + node: *List.Node, + adjustment: ?IncreaseCapacity, +) IncreaseCapacityError!*List.Node { + defer self.assertIntegrity(); + const page: *Page = &node.data; + + // Apply our adjustment + var cap = page.capacity; + if (adjustment) |v| switch (v) { + inline else => |tag| { + const field_name = @tagName(tag); + const Int = @FieldType(Capacity, field_name); + const old = @field(cap, field_name); + + // We use checked math to prevent overflow. If there is an + // overflow it means we're out of space in this dimension, + // since pages can take up to their maxInt capacity in any + // category. + const new = std.math.mul(Int, old, 2) catch |err| { + comptime assert(@TypeOf(err) == error{Overflow}); + return error.OutOfSpace; + }; + @field(cap, field_name) = new; + }, + }; + + log.info("adjusting page capacity={}", .{cap}); + + // Create our new page and clone the old page into it. + const new_node = try self.createPage(cap); + errdefer self.destroyNode(new_node); + const new_page: *Page = &new_node.data; + assert(new_page.capacity.rows >= page.capacity.rows); + assert(new_page.capacity.cols >= page.capacity.cols); + new_page.size.rows = page.size.rows; + new_page.size.cols = page.size.cols; + new_page.cloneFrom( + page, + 0, + page.size.rows, + ) catch |err| { + // cloneFrom only errors if there isn't capacity for the data + // from the source page but we're only increasing capacity so + // this should never be possible. If it happens, we should crash + // because we're in no man's land and can't safely recover. + log.err("increaseCapacity clone failed err={}", .{err}); + @panic("unexpected clone failure"); + }; + + // Must not fail after this because the operations we do after this + // can't be recovered. + errdefer comptime unreachable; + + // Fix up all our tracked pins to point to the new page. + const pin_keys = self.tracked_pins.keys(); + for (pin_keys) |p| { + if (p.node != node) continue; + p.node = new_node; + } + + // Insert this page and destroy the old page + self.pages.insertBefore(node, new_node); + self.pages.remove(node); + self.destroyNode(node); + + new_page.assertIntegrity(); + return new_node; +} + /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. inline fn createPage( @@ -6482,6 +6586,301 @@ test "PageList adjustCapacity after col shrink" { } } +test "PageList increaseCapacity to increase styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + + const original_styles_cap = s.pages.first.?.data.capacity.styles; + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our styles + _ = try s.increaseCapacity(s.pages.first.?, .styles); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Verify capacity doubled + try testing.expectEqual( + original_styles_cap * 2, + page.capacity.styles, + ); + + // Verify data preserved + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + +test "PageList increaseCapacity to increase graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + + const original_cap = s.pages.first.?.data.capacity.grapheme_bytes; + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + +test "PageList increaseCapacity to increase hyperlinks" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + + const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes; + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .hyperlink_bytes); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + +test "PageList increaseCapacity to increase string_bytes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + + const original_cap = s.pages.first.?.data.capacity.string_bytes; + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .string_bytes); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + try testing.expectEqual(original_cap * 2, page.capacity.string_bytes); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + +test "PageList increaseCapacity tracked pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + + // Create a tracked pin on the first page + const tracked = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 1 } }).?); + defer s.untrackPin(tracked); + + const old_node = s.pages.first.?; + try testing.expectEqual(old_node, tracked.node); + + // Increase capacity + const new_node = try s.increaseCapacity(s.pages.first.?, .styles); + + // Pin should now point to the new node + try testing.expectEqual(new_node, tracked.node); + try testing.expectEqual(@as(size.CellCountInt, 1), tracked.x); + try testing.expectEqual(@as(size.CellCountInt, 1), tracked.y); +} + +test "PageList increaseCapacity returns OutOfSpace at max capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + + // Set styles capacity to a value that will overflow when doubled + // We need to create a page with capacity at more than half of max + const max_styles = std.math.maxInt(size.StyleCountInt); + const half_max = max_styles / 2 + 1; + + // Adjust capacity to near-max first + _ = try s.adjustCapacity(s.pages.first.?, .{ .styles = half_max }); + + // Now increaseCapacity should fail with OutOfSpace + try testing.expectError( + error.OutOfSpace, + s.increaseCapacity(s.pages.first.?, .styles), + ); +} + +test "PageList increaseCapacity after col shrink" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 2, 0); + defer s.deinit(); + + // Shrink columns + try s.resize(.{ .cols = 5, .reflow = false }); + try testing.expectEqual(5, s.cols); + + { + const page = &s.pages.first.?.data; + try testing.expectEqual(5, page.size.cols); + try testing.expect(page.capacity.cols >= 10); + } + + // Increase capacity + _ = try s.increaseCapacity(s.pages.first.?, .styles); + + { + const page = &s.pages.first.?.data; + // size.cols should still be 5, not reverted to capacity.cols + try testing.expectEqual(5, page.size.cols); + try testing.expectEqual(5, s.cols); + } +} + +test "PageList increaseCapacity multi-page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow to create a second page + const page1_node = s.pages.last.?; + page1_node.data.pauseIntegrityChecks(true); + for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + page1_node.data.pauseIntegrityChecks(false); + try testing.expect(try s.grow() != null); + + // Now we have two pages + try testing.expect(s.pages.first != s.pages.last); + const page2_node = s.pages.last.?; + + const page1_styles_cap = s.pages.first.?.data.capacity.styles; + const page2_styles_cap = page2_node.data.capacity.styles; + + // Increase capacity on the first page only + _ = try s.increaseCapacity(s.pages.first.?, .styles); + + // First page capacity should be doubled + try testing.expectEqual( + page1_styles_cap * 2, + s.pages.first.?.data.capacity.styles, + ); + + // Second page should be unchanged + try testing.expectEqual( + page2_styles_cap, + s.pages.last.?.data.capacity.styles, + ); +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; From 1e5973386b5eb262526b4990102500e556f6f2ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 13 Jan 2026 13:41:11 -0800 Subject: [PATCH 473/605] terminal: Screen.increaseCapacity --- src/terminal/Screen.zig | 312 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 309 insertions(+), 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fed0a8c51..8edb72ba5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -588,6 +588,76 @@ pub fn adjustCapacity( return new_node; } +pub fn increaseCapacity( + self: *Screen, + node: *PageList.List.Node, + adjustment: ?PageList.IncreaseCapacity, +) PageList.IncreaseCapacityError!*PageList.List.Node { + // If the page being modified isn't our cursor page then + // this is a quick operation because we have no additional + // accounting. We have to do this check here BEFORE calling + // increaseCapacity because increaseCapacity will update all + // our tracked pins (including our cursor). + if (node != self.cursor.page_pin.node) return try self.pages.increaseCapacity( + node, + adjustment, + ); + + // We're modifying the cursor page. When we increase the + // capacity below it will be short the ref count on our + // current style and hyperlink, so we need to init those. + const new_node = try self.pages.increaseCapacity(node, adjustment); + const new_page: *Page = &new_node.data; + + // Re-add the style, if the page somehow doesn't have enough + // memory to add it, we emit a warning and gracefully degrade + // to the default style for the cursor. + if (self.cursor.style_id != style.default_id) { + self.cursor.style_id = new_page.styles.add( + new_page.memory, + self.cursor.style, + ) catch |err| id: { + // TODO: Should we increase the capacity further in this case? + log.warn( + "(Screen.increaseCapacity) Failed to add cursor style back to page, err={}", + .{err}, + ); + + // Reset the cursor style. + self.cursor.style = .{}; + break :id style.default_id; + }; + } + + // Re-add the hyperlink, if the page somehow doesn't have enough + // memory to add it, we emit a warning and gracefully degrade to + // no hyperlink. + if (self.cursor.hyperlink) |link| { + // So we don't attempt to free any memory in the replaced page. + self.cursor.hyperlink_id = 0; + self.cursor.hyperlink = null; + + // Re-add + self.startHyperlinkOnce(link.*) catch |err| { + // TODO: Should we increase the capacity further in this case? + log.warn( + "(Screen.increaseCapacity) Failed to add cursor hyperlink back to page, err={}", + .{err}, + ); + }; + + // Remove our old link + link.deinit(self.alloc); + self.alloc.destroy(link); + } + + // Reload the cursor information because the pin changed. + // So our page row/cell and so on are all off. + self.cursorReload(); + + return new_node; +} + pub inline fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { assert(self.cursor.x + n < self.pages.cols); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); @@ -3007,15 +3077,15 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .protected = self.cursor.protected, }; - // If we have a hyperlink, add it to the cell. - if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink(); - // If we have a ref-counted style, increase. if (self.cursor.style_id != style.default_id) { const page = self.cursor.page_pin.node.data; page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; } + + // If we have a hyperlink, add it to the cell. + if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink(); }, 2 => { @@ -9103,3 +9173,239 @@ test "Screen: cursorDown to page with insufficient capacity" { try testing.expect(false); } } + +test "Screen: increaseCapacity cursor style ref count preserved" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 5, + .rows = 5, + .max_scrollback = 0, + }); + defer s.deinit(); + try s.setAttribute(.bold); + try s.testWriteString("1ABCD"); + + // We should have one page and it should be our cursor page + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try testing.expect(s.pages.pages.first == s.cursor.page_pin.node); + + const old_style = s.cursor.style; + + { + const page = &s.pages.pages.last.?.data; + // 5 chars + cursor = 6 refs + try testing.expectEqual( + 6, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + } + + // This forces the page to change via increaseCapacity. + const new_node = try s.increaseCapacity( + s.cursor.page_pin.node, + .grapheme_bytes, + ); + + // Cursor's page_pin should now point to the new node + try testing.expect(s.cursor.page_pin.node == new_node); + + // Verify cursor's page_cell and page_row are correctly reloaded from the pin + const page_rac = s.cursor.page_pin.rowAndCell(); + try testing.expect(s.cursor.page_row == page_rac.row); + try testing.expect(s.cursor.page_cell == page_rac.cell); + + // Style should be preserved + try testing.expectEqual(old_style, s.cursor.style); + try testing.expect(s.cursor.style_id != style.default_id); + + // After increaseCapacity, the 5 chars are cloned (5 refs) and + // the cursor's style is re-added (1 ref) = 6 total. + { + const page = &s.pages.pages.last.?.data; + const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); + try testing.expectEqual(6, ref_count); + } +} + +test "Screen: increaseCapacity cursor hyperlink ref count preserved" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 5, + .rows = 5, + .max_scrollback = 0, + }); + defer s.deinit(); + try s.startHyperlink("https://example.com/", null); + try s.testWriteString("1ABCD"); + + // We should have one page and it should be our cursor page + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try testing.expect(s.pages.pages.first == s.cursor.page_pin.node); + + { + const page = &s.pages.pages.last.?.data; + // Cursor has the hyperlink active = 1 count in hyperlink_set + try testing.expectEqual(1, page.hyperlink_set.count()); + try testing.expect(s.cursor.hyperlink_id != 0); + try testing.expect(s.cursor.hyperlink != null); + } + + // This forces the page to change via increaseCapacity. + _ = try s.increaseCapacity( + s.cursor.page_pin.node, + .grapheme_bytes, + ); + + // Hyperlink should be preserved with correct URI + try testing.expect(s.cursor.hyperlink != null); + try testing.expect(s.cursor.hyperlink_id != 0); + try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri); + + // After increaseCapacity, the hyperlink is re-added to the new page. + { + const page = &s.pages.pages.last.?.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } +} + +test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 5, + .rows = 5, + .max_scrollback = 0, + }); + defer s.deinit(); + + // Set both a non-default style AND an active hyperlink. + // Write one character first with bold to mark the row as styled, + // then start the hyperlink and write more characters. + try s.setAttribute(.bold); + try s.startHyperlink("https://example.com/", null); + try s.testWriteString("1ABCD"); + + // We should have one page and it should be our cursor page + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try testing.expect(s.pages.pages.first == s.cursor.page_pin.node); + + const old_style = s.cursor.style; + + { + const page = &s.pages.pages.last.?.data; + // 5 chars + cursor = 6 refs for bold style + try testing.expectEqual( + 6, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + // Cursor has the hyperlink active = 1 count in hyperlink_set + try testing.expectEqual(1, page.hyperlink_set.count()); + try testing.expect(s.cursor.style_id != style.default_id); + try testing.expect(s.cursor.hyperlink_id != 0); + try testing.expect(s.cursor.hyperlink != null); + } + + // This forces the page to change via increaseCapacity. + _ = try s.increaseCapacity( + s.cursor.page_pin.node, + .grapheme_bytes, + ); + + // Style should be preserved + try testing.expectEqual(old_style, s.cursor.style); + try testing.expect(s.cursor.style_id != style.default_id); + + // Hyperlink should be preserved with correct URI + try testing.expect(s.cursor.hyperlink != null); + try testing.expect(s.cursor.hyperlink_id != 0); + try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri); + + // After increaseCapacity, both style and hyperlink are re-added to the new page. + { + const page = &s.pages.pages.last.?.data; + const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); + try testing.expectEqual(6, ref_count); + try testing.expectEqual(1, page.hyperlink_set.count()); + } +} + +test "Screen: increaseCapacity non-cursor page returns early" { + // Test that calling increaseCapacity on a page that is NOT the cursor's + // page properly delegates to pages.increaseCapacity without doing the + // extra cursor accounting (style/hyperlink re-adding). + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10000, + }); + defer s.deinit(); + + // Set up a custom style and hyperlink on the cursor + try s.setAttribute(.bold); + try s.startHyperlink("https://example.com/", null); + try s.testWriteString("Hello"); + + // Store cursor state before growing pages + const old_style = s.cursor.style; + const old_style_id = s.cursor.style_id; + const old_hyperlink = s.cursor.hyperlink; + const old_hyperlink_id = s.cursor.hyperlink_id; + + // The cursor is on the first (and only) page + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + + // Grow pages until we have multiple pages. The cursor's pin stays on + // the first page since we're just adding rows. + const first_page_node = s.pages.pages.first.?; + first_page_node.data.pauseIntegrityChecks(true); + for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| { + _ = try s.pages.grow(); + } + first_page_node.data.pauseIntegrityChecks(false); + _ = try s.pages.grow(); + + // Now we have two pages + try testing.expect(s.pages.pages.first != s.pages.pages.last); + const second_page = s.pages.pages.last.?; + + // Cursor should still be on the first page (where it was created) + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + try testing.expect(s.cursor.page_pin.node != second_page); + + const second_page_styles_cap = second_page.data.capacity.styles; + const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles; + + // Call increaseCapacity on the second page (NOT the cursor's page) + const new_second_page = try s.increaseCapacity(second_page, .styles); + + // The second page should have increased capacity + try testing.expectEqual( + second_page_styles_cap * 2, + new_second_page.data.capacity.styles, + ); + + // The cursor's page (first page) should be unchanged + try testing.expectEqual( + cursor_page_styles_cap, + s.cursor.page_pin.node.data.capacity.styles, + ); + + // Cursor state should be completely unchanged since we didn't touch its page + try testing.expectEqual(old_style, s.cursor.style); + try testing.expectEqual(old_style_id, s.cursor.style_id); + try testing.expectEqual(old_hyperlink, s.cursor.hyperlink); + try testing.expectEqual(old_hyperlink_id, s.cursor.hyperlink_id); + + // Verify hyperlink is still valid + try testing.expect(s.cursor.hyperlink != null); + try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri); +} From 29d4aba03337d7e9d6a0c357969e8924240b84dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 09:07:44 -0800 Subject: [PATCH 474/605] terminal: Screen replace adjust with increaseCapacity --- src/terminal/Screen.zig | 101 ++++++++++++++++++++++++++++------------ src/terminal/page.zig | 2 +- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 8edb72ba5..66dc37006 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1176,14 +1176,19 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { return; } - // If we have a old style then we need to release it from the old page. + // If we have an old style then we need to release it from the old page. const old_style_: ?style.Style = if (self.cursor.style_id == style.default_id) null else self.cursor.style; if (old_style_ != null) { + // Release the style directly from the old page instead of going through + // manualStyleUpdate, because the cursor position may have already been + // updated but the pin has not, which would fail integrity checks. + const old_page: *Page = &self.cursor.page_pin.node.data; + old_page.styles.release(old_page.memory, self.cursor.style_id); self.cursor.style = .{}; - self.manualStyleUpdate() catch unreachable; // Removing a style should never fail + self.cursor.style_id = style.default_id; } // If we have a hyperlink then we need to release it from the old page. @@ -2000,7 +2005,17 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { } /// Call this whenever you manually change the cursor style. -pub fn manualStyleUpdate(self: *Screen) !void { +/// +/// Note that this can return any PageList capacity error, because it +/// is possible for the internal pagelist to not accommodate the new style +/// at all. This WILL attempt to resize our internal pages to fit the style +/// but it is possible that it cannot be done, in which case upstream callers +/// need to split the page or do something else. +/// +/// NOTE(mitchellh): I think in the future we'll do page splitting +/// automatically here and remove this failure scenario. +pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { + defer self.assertIntegrity(); var page: *Page = &self.cursor.page_pin.node.data; // std.log.warn("active styles={}", .{page.styles.count()}); @@ -2019,6 +2034,9 @@ pub fn manualStyleUpdate(self: *Screen) !void { // Clear the cursor style ID to prevent weird things from happening // if the page capacity has to be adjusted which would end up calling // manualStyleUpdate again. + // + // This also ensures that if anything fails below, we fall back to + // clearing our style. self.cursor.style_id = style.default_id; // After setting the style, we need to update our style map. @@ -2030,30 +2048,50 @@ pub fn manualStyleUpdate(self: *Screen) !void { page.memory, self.cursor.style, ) catch |err| id: { - // Our style map is full or needs to be rehashed, - // so we allocate a new page, which will rehash, - // and double the style capacity for it if it was - // full. - const node = try self.adjustCapacity( + // Our style map is full or needs to be rehashed, so we need to + // increase style capacity (or rehash). + const node = try self.increaseCapacity( self.cursor.page_pin.node, switch (err) { - error.OutOfMemory => .{ .styles = page.capacity.styles * 2 }, - error.NeedsRehash => .{}, + error.OutOfMemory => .styles, + error.NeedsRehash => null, }, ); page = &node.data; - break :id try page.styles.add( + break :id page.styles.add( page.memory, self.cursor.style, - ); + ) catch |err2| switch (err2) { + error.OutOfMemory => { + // This shouldn't happen because increaseCapacity is + // guaranteed to increase our capacity by at least one and + // we only need one space, but again, I don't want to crash + // here so let's log loudly and reset. + log.err("style addition failed after capacity increase", .{}); + return error.OutOfMemory; + }, + error.NeedsRehash => { + // This should be impossible because we rehash above + // and rehashing should never result in a duplicate. But + // we don't want to simply hard crash so log it and + // clear our style. + log.err("style rehash resulted in needs rehash", .{}); + return; + }, + }; }; + errdefer page.styles.release(page.memory, id); + self.cursor.style_id = id; - self.assertIntegrity(); } /// Append a grapheme to the given cell within the current cursor row. -pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { +pub fn appendGrapheme( + self: *Screen, + cell: *Cell, + cp: u21, +) PageList.IncreaseCapacityError!void { defer self.cursor.page_pin.node.data.assertIntegrity(); self.cursor.page_pin.node.data.appendGrapheme( self.cursor.page_row, @@ -2073,11 +2111,9 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { // Adjust our capacity. This will update our cursor page pin and // force us to reload. - const original_node = self.cursor.page_pin.node; - const new_bytes = original_node.data.capacity.grapheme_bytes * 2; - _ = try self.adjustCapacity( - original_node, - .{ .grapheme_bytes = new_bytes }, + _ = try self.increaseCapacity( + self.cursor.page_pin.node, + .grapheme_bytes, ); // The cell pointer is now invalid, so we need to get it from @@ -2088,17 +2124,22 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), }; - try self.cursor.page_pin.node.data.appendGrapheme( + self.cursor.page_pin.node.data.appendGrapheme( self.cursor.page_row, reloaded_cell, cp, - ); + ) catch |err2| { + comptime assert(@TypeOf(err2) == error{OutOfMemory}); + // This should never happen because we just increased capacity. + // Log loudly but still return an error so we don't just + // crash. + log.err("grapheme append failed after capacity increase", .{}); + return err2; + }; }, }; } -pub const StartHyperlinkError = Allocator.Error || PageList.AdjustCapacityError; - /// Start the hyperlink state. Future cells will be marked as hyperlinks with /// this state. Note that various terminal operations may clear the hyperlink /// state, such as switching screens (alt screen). @@ -2106,7 +2147,7 @@ pub fn startHyperlink( self: *Screen, uri: []const u8, id_: ?[]const u8, -) StartHyperlinkError!void { +) PageList.IncreaseCapacityError!void { // Create our pending entry. const link: hyperlink.Hyperlink = .{ .uri = uri, @@ -2131,21 +2172,21 @@ pub fn startHyperlink( error.OutOfMemory => return error.OutOfMemory, // strings table is out of memory, adjust it up - error.StringsOutOfMemory => _ = try self.adjustCapacity( + error.StringsOutOfMemory => _ = try self.increaseCapacity( self.cursor.page_pin.node, - .{ .string_bytes = self.cursor.page_pin.node.data.capacity.string_bytes * 2 }, + .string_bytes, ), // hyperlink set is out of memory, adjust it up - error.SetOutOfMemory => _ = try self.adjustCapacity( + error.SetOutOfMemory => _ = try self.increaseCapacity( self.cursor.page_pin.node, - .{ .hyperlink_bytes = self.cursor.page_pin.node.data.capacity.hyperlink_bytes * 2 }, + .hyperlink_bytes, ), // hyperlink set is too full, rehash it - error.SetNeedsRehash => _ = try self.adjustCapacity( + error.SetNeedsRehash => _ = try self.increaseCapacity( self.cursor.page_pin.node, - .{}, + null, ), } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 559f536f1..1150027a4 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -2038,7 +2038,7 @@ pub const Cell = packed struct(u64) { test "Page.layout can take a maxed capacity" { // Our intention is for a maxed-out capacity to always fit - // within a page layout without trigering runtime safety on any + // within a page layout without triggering runtime safety on any // overflow. This simplifies some of our handling downstream of the // call (relevant to: https://github.com/ghostty-org/ghostty/issues/10258) var cap: Capacity = undefined; From 25b7cc9f2cc28071d9d07f3a96ab86c811f1d1e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 10:56:32 -0800 Subject: [PATCH 475/605] terminal: hyperlink state uses increaseCapacity on screen --- src/terminal/Screen.zig | 48 ++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 66dc37006..f524bd7f4 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2248,7 +2248,7 @@ pub fn endHyperlink(self: *Screen) void { } /// Set the current hyperlink state on the current cell. -pub fn cursorSetHyperlink(self: *Screen) !void { +pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { assert(self.cursor.hyperlink_id != 0); var page = &self.cursor.page_pin.node.data; @@ -2263,10 +2263,6 @@ pub fn cursorSetHyperlink(self: *Screen) !void { } else |err| switch (err) { // hyperlink_map is out of space, realloc the page to be larger error.HyperlinkMapOutOfMemory => { - const uri_size = if (self.cursor.hyperlink) |link| link.uri.len else 0; - - var string_bytes = page.capacity.string_bytes; - // Attempt to allocate the space that would be required to // insert a new copy of the cursor hyperlink uri in to the // string alloc, since right now adjustCapacity always just @@ -2274,29 +2270,31 @@ pub fn cursorSetHyperlink(self: *Screen) !void { // If this alloc fails then we know we also need to grow our // string bytes. // - // FIXME: This SUCKS - if (page.string_alloc.alloc( - u8, - page.memory, - uri_size, - )) |slice| { - // We don't bother freeing because we're - // about to free the entire page anyway. - _ = &slice; - } else |_| { - // We didn't have enough room, let's just double our - // string bytes until there's definitely enough room - // for our uri. - const before = string_bytes; - while (string_bytes - before < uri_size) string_bytes *= 2; + // FIXME: increaseCapacity should not do this. + while (self.cursor.hyperlink) |link| { + if (page.string_alloc.alloc( + u8, + page.memory, + link.uri.len, + )) |slice| { + // We don't bother freeing because we're + // about to free the entire page anyway. + _ = slice; + break; + } else |_| {} + + // We didn't have enough room, let's increase string bytes + const new_node = try self.increaseCapacity( + self.cursor.page_pin.node, + .string_bytes, + ); + assert(new_node == self.cursor.page_pin.node); + page = &new_node.data; } - _ = try self.adjustCapacity( + _ = try self.increaseCapacity( self.cursor.page_pin.node, - .{ - .hyperlink_bytes = page.capacity.hyperlink_bytes * 2, - .string_bytes = string_bytes, - }, + .hyperlink_bytes, ); // Retry From c8afc423082ef764c9f5e62b5269cc5dc2fcff22 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 11:37:58 -0800 Subject: [PATCH 476/605] terminal: switch to increaseCapacity --- src/terminal/Terminal.zig | 72 ++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d717a9724..310adaf29 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1634,54 +1634,48 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.scrolling_region.left, self.scrolling_region.right + 1, ) catch |err| { - const cap = dst_p.node.data.capacity; // Adjust our page capacity to make // room for we didn't have space for - _ = self.screens.active.adjustCapacity( + _ = self.screens.active.increaseCapacity( dst_p.node, switch (err) { // Rehash the sets error.StyleSetNeedsRehash, error.HyperlinkSetNeedsRehash, - => .{}, + => null, // Increase style memory error.StyleSetOutOfMemory, - => .{ .styles = cap.styles * 2 }, + => .styles, // Increase string memory error.StringAllocOutOfMemory, - => .{ .string_bytes = cap.string_bytes * 2 }, + => .string_bytes, // Increase hyperlink memory error.HyperlinkSetOutOfMemory, error.HyperlinkMapOutOfMemory, - => .{ .hyperlink_bytes = cap.hyperlink_bytes * 2 }, + => .hyperlink_bytes, // Increase grapheme memory error.GraphemeMapOutOfMemory, error.GraphemeAllocOutOfMemory, - => .{ .grapheme_bytes = cap.grapheme_bytes * 2 }, + => .grapheme_bytes, }, ) catch |e| switch (e) { - // This shouldn't be possible because above we're only - // adjusting capacity _upwards_. So it should have all - // the existing capacity it had to fit the adjusted - // data. Panic since we don't expect this. - error.StyleSetOutOfMemory, - error.StyleSetNeedsRehash, - error.StringAllocOutOfMemory, - error.HyperlinkSetOutOfMemory, - error.HyperlinkSetNeedsRehash, - error.HyperlinkMapOutOfMemory, - error.GraphemeMapOutOfMemory, - error.GraphemeAllocOutOfMemory, - => @panic("adjustCapacity resulted in capacity errors"), - - // The system allocator is OOM. We can't currently do - // anything graceful here. We panic. + // System OOM. We have no way to recover from this + // currently. We should probably change insertLines + // to raise an error here. error.OutOfMemory, - => @panic("adjustCapacity system allocator OOM"), + => @panic("increaseCapacity system allocator OOM"), + + // The page can't accomodate the managed memory required + // for this operation. We previously just corrupted + // memory here so a crash is better. The right long + // term solution is to allocate a new page here + // move this row to the new page, and start over. + error.OutOfSpace, + => @panic("increaseCapacity OutOfSpace"), }; // Continue the loop to try handling this row again. @@ -1834,49 +1828,41 @@ pub fn deleteLines(self: *Terminal, count: usize) void { self.scrolling_region.left, self.scrolling_region.right + 1, ) catch |err| { - const cap = dst_p.node.data.capacity; // Adjust our page capacity to make // room for we didn't have space for - _ = self.screens.active.adjustCapacity( + _ = self.screens.active.increaseCapacity( dst_p.node, switch (err) { // Rehash the sets error.StyleSetNeedsRehash, error.HyperlinkSetNeedsRehash, - => .{}, + => null, // Increase style memory error.StyleSetOutOfMemory, - => .{ .styles = cap.styles * 2 }, + => .styles, // Increase string memory error.StringAllocOutOfMemory, - => .{ .string_bytes = cap.string_bytes * 2 }, + => .string_bytes, // Increase hyperlink memory error.HyperlinkSetOutOfMemory, error.HyperlinkMapOutOfMemory, - => .{ .hyperlink_bytes = cap.hyperlink_bytes * 2 }, + => .hyperlink_bytes, // Increase grapheme memory error.GraphemeMapOutOfMemory, error.GraphemeAllocOutOfMemory, - => .{ .grapheme_bytes = cap.grapheme_bytes * 2 }, + => .grapheme_bytes, }, ) catch |e| switch (e) { - // See insertLines which has the same error capture. - error.StyleSetOutOfMemory, - error.StyleSetNeedsRehash, - error.StringAllocOutOfMemory, - error.HyperlinkSetOutOfMemory, - error.HyperlinkSetNeedsRehash, - error.HyperlinkMapOutOfMemory, - error.GraphemeMapOutOfMemory, - error.GraphemeAllocOutOfMemory, - => @panic("adjustCapacity resulted in capacity errors"), - + // See insertLines error.OutOfMemory, - => @panic("adjustCapacity system allocator OOM"), + => @panic("increaseCapacity system allocator OOM"), + + error.OutOfSpace, + => @panic("increaseCapacity OutOfSpace"), }; // Continue the loop to try handling this row again. From b59ac60a8772ae1e259966efc0eb9feb5c4167cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 11:40:39 -0800 Subject: [PATCH 477/605] terminal: remove Screen.adjustCapacity --- src/terminal/Screen.zig | 359 +++++++++----------------------------- src/terminal/Terminal.zig | 2 +- 2 files changed, 80 insertions(+), 281 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f524bd7f4..1538da5da 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -517,77 +517,6 @@ pub fn clone( result.assertIntegrity(); return result; } - -/// Adjust the capacity of a page within the pagelist of this screen. -/// This handles some accounting if the page being modified is the -/// cursor page. -pub fn adjustCapacity( - self: *Screen, - node: *PageList.List.Node, - adjustment: PageList.AdjustCapacity, -) PageList.AdjustCapacityError!*PageList.List.Node { - // If the page being modified isn't our cursor page then - // this is a quick operation because we have no additional - // accounting. - if (node != self.cursor.page_pin.node) { - return try self.pages.adjustCapacity(node, adjustment); - } - - // We're modifying the cursor page. When we adjust the - // capacity below it will be short the ref count on our - // current style and hyperlink, so we need to init those. - const new_node = try self.pages.adjustCapacity(node, adjustment); - const new_page: *Page = &new_node.data; - - // Re-add the style, if the page somehow doesn't have enough - // memory to add it, we emit a warning and gracefully degrade - // to the default style for the cursor. - if (self.cursor.style_id != 0) { - self.cursor.style_id = new_page.styles.add( - new_page.memory, - self.cursor.style, - ) catch |err| id: { - // TODO: Should we increase the capacity further in this case? - log.warn( - "(Screen.adjustCapacity) Failed to add cursor style back to page, err={}", - .{err}, - ); - - // Reset the cursor style. - self.cursor.style = .{}; - break :id style.default_id; - }; - } - - // Re-add the hyperlink, if the page somehow doesn't have enough - // memory to add it, we emit a warning and gracefully degrade to - // no hyperlink. - if (self.cursor.hyperlink) |link| { - // So we don't attempt to free any memory in the replaced page. - self.cursor.hyperlink_id = 0; - self.cursor.hyperlink = null; - - // Re-add - self.startHyperlinkOnce(link.*) catch |err| { - // TODO: Should we increase the capacity further in this case? - log.warn( - "(Screen.adjustCapacity) Failed to add cursor hyperlink back to page, err={}", - .{err}, - ); - }; - - // Remove our old link - link.deinit(self.alloc); - self.alloc.destroy(link); - } - - // Reload the cursor information because the pin changed. - // So our page row/cell and so on are all off. - self.cursorReload(); - - return new_node; -} - pub fn increaseCapacity( self: *Screen, node: *PageList.List.Node, @@ -2265,7 +2194,7 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { error.HyperlinkMapOutOfMemory => { // Attempt to allocate the space that would be required to // insert a new copy of the cursor hyperlink uri in to the - // string alloc, since right now adjustCapacity always just + // string alloc, since right now increaseCapacity always just // adds an extra copy even if one already exists in the page. // If this alloc fails then we know we also need to grow our // string bytes. @@ -9005,214 +8934,6 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes); } -test "Screen: adjustCapacity cursor style ref count" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); - defer s.deinit(); - - try s.setAttribute(.{ .bold = {} }); - try s.testWriteString("1ABCD"); - - { - const page = &s.pages.pages.last.?.data; - try testing.expectEqual( - 6, // All chars + cursor - page.styles.refCount(page.memory, s.cursor.style_id), - ); - } - - // This forces the page to change. - _ = try s.adjustCapacity( - s.cursor.page_pin.node, - .{ .grapheme_bytes = s.cursor.page_pin.node.data.capacity.grapheme_bytes * 2 }, - ); - - // Our ref counts should still be the same - { - const page = &s.pages.pages.last.?.data; - try testing.expectEqual( - 6, // All chars + cursor - page.styles.refCount(page.memory, s.cursor.style_id), - ); - } -} - -test "Screen: adjustCapacity cursor hyperlink exceeds string alloc size" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - - // Start a hyperlink with a URI that just barely fits in the string alloc. - // This will ensure that the redundant copy added in `adjustCapacity` won't - // fit in the available string alloc space. - const uri = "a" ** (pagepkg.std_capacity.string_bytes - 8); - try s.startHyperlink(uri, null); - - // Write some characters with this so that the URI - // is copied to the new page when adjusting capacity. - try s.testWriteString("Hello"); - - // Adjust the capacity, right now this will cause a redundant copy of - // the URI to be added to the string alloc, but since there isn't room - // for this this will clear the cursor hyperlink. - _ = try s.adjustCapacity(s.cursor.page_pin.node, .{}); - - // The cursor hyperlink should have been cleared by the `adjustCapacity` - // call, because there isn't enough room to add the redundant URI string. - // - // This behavior will change, causing this test to fail, if any of these - // changes are made: - // - // - The string alloc is changed to intern strings. - // - // - The adjustCapacity function is changed to ensure the new - // capacity will fit the redundant copy of the hyperlink uri. - // - // - The cursor managed memory handling is reworked so that it - // doesn't reside in the pages anymore and doesn't need this - // accounting. - // - // In such a case, adjust this test accordingly. - try testing.expectEqual(null, s.cursor.hyperlink); - try testing.expectEqual(0, s.cursor.hyperlink_id); -} - -test "Screen: adjustCapacity cursor style exceeds style set capacity" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - const page = &s.cursor.page_pin.node.data; - - // We add unique styles to the page until no more will fit. - fill: for (0..255) |bg| { - for (0..255) |fg| { - const st: style.Style = .{ - .bg_color = .{ .palette = @intCast(bg) }, - .fg_color = .{ .palette = @intCast(fg) }, - }; - - s.cursor.style = st; - - // Try to insert the new style, if it doesn't fit then - // we succeeded in filling the style set, so we break. - s.cursor.style_id = page.styles.add( - page.memory, - s.cursor.style, - ) catch break :fill; - - try s.testWriteString("a"); - } - } - - // Adjust the capacity, this should cause the style set to reach the - // same state it was in to begin with, since it will clone the page - // in the same order as the styles were added to begin with, meaning - // the cursor style will not be able to be added to the set, which - // should, right now, result in the cursor style being cleared. - _ = try s.adjustCapacity(s.cursor.page_pin.node, .{}); - - // The cursor style should have been cleared by the `adjustCapacity`. - // - // This behavior will change, causing this test to fail, if either - // of these changes are made: - // - // - The adjustCapacity function is changed to ensure the - // new capacity will definitely fit the cursor style. - // - // - The cursor managed memory handling is reworked so that it - // doesn't reside in the pages anymore and doesn't need this - // accounting. - // - // In such a case, adjust this test accordingly. - try testing.expect(s.cursor.style.default()); - try testing.expectEqual(style.default_id, s.cursor.style_id); -} - -test "Screen: cursorDown to page with insufficient capacity" { - // Regression test for https://github.com/ghostty-org/ghostty/issues/10282 - // - // This test exposes a use-after-realloc bug in cursorDown (and similar - // cursor movement functions). The bug pattern: - // - // 1. cursorDown creates a by-value copy of the pin via page_pin.down(n) - // 2. cursorChangePin is called, which may trigger adjustCapacity - // if the target page's style map is full - // 3. adjustCapacity frees the old page and creates a new one - // 4. The local pin copy still points to the freed page - // 5. rowAndCell() on the stale pin accesses freed memory - - const testing = std.testing; - const alloc = testing.allocator; - - // Small screen to make page boundary crossing easy to set up - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); - defer s.deinit(); - - // Scroll down enough to create a second page - const start_page = &s.pages.pages.last.?.data; - const rem = start_page.capacity.rows; - start_page.pauseIntegrityChecks(true); - for (0..rem) |_| try s.cursorDownOrScroll(); - start_page.pauseIntegrityChecks(false); - - // Cursor should now be on a new page - const new_page = &s.cursor.page_pin.node.data; - try testing.expect(start_page != new_page); - - // Fill new_page's style map to capacity. When we move INTO this page - // with a style set, adjustCapacity will be triggered. - { - new_page.pauseIntegrityChecks(true); - defer new_page.pauseIntegrityChecks(false); - defer new_page.assertIntegrity(); - - var n: u24 = 1; - while (new_page.styles.add( - new_page.memory, - .{ .bg_color = .{ .rgb = @bitCast(n) } }, - )) |_| n += 1 else |_| {} - } - - // Move cursor to start of active area and set a style - s.cursorAbsolute(0, 0); - try s.setAttribute(.bold); - try testing.expect(s.cursor.style.flags.bold); - try testing.expect(s.cursor.style_id != style.default_id); - - // Find the row just before the page boundary - for (0..s.pages.rows - 1) |row| { - s.cursorAbsolute(0, @intCast(row)); - const cur_node = s.cursor.page_pin.node; - if (s.cursor.page_pin.down(1)) |next_pin| { - if (next_pin.node != cur_node) { - // Cursor is at 'row', moving down crosses to new_page - try testing.expect(&next_pin.node.data == new_page); - - // This cursorDown triggers the bug: the local page_pin copy - // becomes stale after adjustCapacity, causing rowAndCell() - // to access freed memory. - s.cursorDown(1); - - // If the fix is applied, verify correct state - try testing.expect(s.cursor.y == row + 1); - try testing.expect(s.cursor.style.flags.bold); - - break; - } - } - } else { - // Didn't find boundary - try testing.expect(false); - } -} - test "Screen: increaseCapacity cursor style ref count preserved" { const testing = std.testing; const alloc = testing.allocator; @@ -9448,3 +9169,81 @@ test "Screen: increaseCapacity non-cursor page returns early" { try testing.expect(s.cursor.hyperlink != null); try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri); } + +test "Screen: cursorDown to page with insufficient capacity" { + // Regression test for https://github.com/ghostty-org/ghostty/issues/10282 + // + // This test exposes a use-after-realloc bug in cursorDown (and similar + // cursor movement functions). The bug pattern: + // + // 1. cursorDown creates a by-value copy of the pin via page_pin.down(n) + // 2. cursorChangePin is called, which may trigger adjustCapacity + // if the target page's style map is full + // 3. adjustCapacity frees the old page and creates a new one + // 4. The local pin copy still points to the freed page + // 5. rowAndCell() on the stale pin accesses freed memory + + const testing = std.testing; + const alloc = testing.allocator; + + // Small screen to make page boundary crossing easy to set up + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + defer s.deinit(); + + // Scroll down enough to create a second page + const start_page = &s.pages.pages.last.?.data; + const rem = start_page.capacity.rows; + start_page.pauseIntegrityChecks(true); + for (0..rem) |_| try s.cursorDownOrScroll(); + start_page.pauseIntegrityChecks(false); + + // Cursor should now be on a new page + const new_page = &s.cursor.page_pin.node.data; + try testing.expect(start_page != new_page); + + // Fill new_page's style map to capacity. When we move INTO this page + // with a style set, adjustCapacity will be triggered. + { + new_page.pauseIntegrityChecks(true); + defer new_page.pauseIntegrityChecks(false); + defer new_page.assertIntegrity(); + + var n: u24 = 1; + while (new_page.styles.add( + new_page.memory, + .{ .bg_color = .{ .rgb = @bitCast(n) } }, + )) |_| n += 1 else |_| {} + } + + // Move cursor to start of active area and set a style + s.cursorAbsolute(0, 0); + try s.setAttribute(.bold); + try testing.expect(s.cursor.style.flags.bold); + try testing.expect(s.cursor.style_id != style.default_id); + + // Find the row just before the page boundary + for (0..s.pages.rows - 1) |row| { + s.cursorAbsolute(0, @intCast(row)); + const cur_node = s.cursor.page_pin.node; + if (s.cursor.page_pin.down(1)) |next_pin| { + if (next_pin.node != cur_node) { + // Cursor is at 'row', moving down crosses to new_page + try testing.expect(&next_pin.node.data == new_page); + + // This cursorDown triggers the bug: the local page_pin copy + // becomes stale after adjustCapacity, causing rowAndCell() + // to access freed memory. + s.cursorDown(1); + + // If the fix is applied, verify correct state + try testing.expect(s.cursor.y == row + 1); + try testing.expect(s.cursor.style.flags.bold); + + break; + } + } + } else { + // Didn't find boundary + try testing.expect(false); + } +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 310adaf29..3740397d3 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1669,7 +1669,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { error.OutOfMemory, => @panic("increaseCapacity system allocator OOM"), - // The page can't accomodate the managed memory required + // The page can't accommodate the managed memory required // for this operation. We previously just corrupted // memory here so a crash is better. The right long // term solution is to allocate a new page here From e70452588790f32864400709e38d6eed83966cb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 13:08:13 -0800 Subject: [PATCH 478/605] terminal: PageList remove adjustCapacity --- src/terminal/PageList.zig | 396 ++++++-------------------------------- 1 file changed, 63 insertions(+), 333 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9c63fd578..bf2071497 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1300,31 +1300,30 @@ const ReflowCursor = struct { // If our page can't support an additional cell // with graphemes then we increase capacity. if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { - try self.adjustCapacity(list, .{ - .grapheme_bytes = cap.grapheme_bytes * 2, - }); + try self.increaseCapacity( + list, + .grapheme_bytes, + ); } // Attempt to allocate the space that would be required // for these graphemes, and if it's not available, then - // increase capacity. - if (self.page.grapheme_alloc.alloc( - u21, - self.page.memory, - cps.len, - )) |slice| { - self.page.grapheme_alloc.free(self.page.memory, slice); - } else |_| { - // Grow our capacity until we can - // definitely fit the extra bytes. - const required = cps.len * @sizeOf(u21); - var new_grapheme_capacity: size.GraphemeBytesInt = cap.grapheme_bytes; - while (new_grapheme_capacity - cap.grapheme_bytes < required) { - new_grapheme_capacity *= 2; + // increase capacity. Keep trying until we succeed. + while (true) { + if (self.page.grapheme_alloc.alloc( + u21, + self.page.memory, + cps.len, + )) |slice| { + self.page.grapheme_alloc.free( + self.page.memory, + slice, + ); + break; + } else |_| { + // Grow our capacity until we can fit the extra bytes. + try self.increaseCapacity(list, .grapheme_bytes); } - try self.adjustCapacity(list, .{ - .grapheme_bytes = new_grapheme_capacity, - }); } // This shouldn't fail since we made sure we have space above. @@ -1339,9 +1338,7 @@ const ReflowCursor = struct { // If our page can't support an additional cell // with a hyperlink then we increase capacity. if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity()) { - try self.adjustCapacity(list, .{ - .hyperlink_bytes = cap.hyperlink_bytes * 2, - }); + try self.increaseCapacity(list, .hyperlink_bytes); } // Ensure that the string alloc has sufficient capacity @@ -1352,23 +1349,26 @@ const ReflowCursor = struct { .explicit => |v| v.len, .implicit => 0, }; - if (self.page.string_alloc.alloc( - u8, - self.page.memory, - additional_required_string_capacity, - )) |slice| { - // We have enough capacity, free the test alloc. - self.page.string_alloc.free(self.page.memory, slice); - } else |_| { - // Grow our capacity until we can - // definitely fit the extra bytes. - var new_string_capacity: size.StringBytesInt = cap.string_bytes; - while (new_string_capacity - cap.string_bytes < additional_required_string_capacity) { - new_string_capacity *= 2; + // Keep trying until we have enough capacity. + while (true) { + if (self.page.string_alloc.alloc( + u8, + self.page.memory, + additional_required_string_capacity, + )) |slice| { + // We have enough capacity, free the test alloc. + self.page.string_alloc.free( + self.page.memory, + slice, + ); + break; + } else |_| { + // Grow our capacity until we can fit the extra bytes. + try self.increaseCapacity( + list, + .string_bytes, + ); } - try self.adjustCapacity(list, .{ - .string_bytes = new_string_capacity, - }); } const dst_id = self.page.hyperlink_set.addWithIdContext( @@ -1380,18 +1380,16 @@ const ReflowCursor = struct { ) catch |err| id: { // If the add failed then either the set needs to grow // or it needs to be rehashed. Either one of those can - // be accomplished by adjusting capacity, either with + // be accomplished by increasing capacity, either with // no actual change or with an increased hyperlink cap. - try self.adjustCapacity(list, switch (err) { - error.OutOfMemory => .{ - .hyperlink_bytes = cap.hyperlink_bytes * 2, - }, - error.NeedsRehash => .{}, + try self.increaseCapacity(list, switch (err) { + error.OutOfMemory => .hyperlink_bytes, + error.NeedsRehash => null, }); // We assume this one will succeed. We dupe the link // again, and don't have to worry about the other one - // because adjusting the capacity naturally clears up + // because increasing the capacity naturally clears up // any managed memory not associated with a cell yet. break :id try self.page.hyperlink_set.addWithIdContext( self.page.memory, @@ -1424,13 +1422,11 @@ const ReflowCursor = struct { ) catch |err| id: { // If the add failed then either the set needs to grow // or it needs to be rehashed. Either one of those can - // be accomplished by adjusting capacity, either with + // be accomplished by increasing capacity, either with // no actual change or with an increased style cap. - try self.adjustCapacity(list, switch (err) { - error.OutOfMemory => .{ - .styles = cap.styles * 2, - }, - error.NeedsRehash => .{}, + try self.increaseCapacity(list, switch (err) { + error.OutOfMemory => .styles, + error.NeedsRehash => null, }); // We assume this one will succeed. @@ -1507,11 +1503,11 @@ const ReflowCursor = struct { } } - /// Adjust the capacity of the current page. - fn adjustCapacity( + /// Increase the capacity of the current page. + fn increaseCapacity( self: *ReflowCursor, list: *PageList, - adjustment: AdjustCapacity, + adjustment: ?IncreaseCapacity, ) !void { const old_x = self.x; const old_y = self.y; @@ -1522,7 +1518,7 @@ const ReflowCursor = struct { // be correct during a reflow. list.pauseIntegrityChecks(true); defer list.pauseIntegrityChecks(false); - break :node try list.adjustCapacity( + break :node try list.increaseCapacity( self.node, adjustment, ); @@ -2642,100 +2638,6 @@ pub fn grow(self: *PageList) !?*List.Node { return next_node; } -/// Adjust the capacity of the given page in the list. -pub const AdjustCapacity = struct { - /// Adjust the number of styles in the page. This may be - /// rounded up if necessary to fit alignment requirements, - /// but it will never be rounded down. - styles: ?size.StyleCountInt = null, - - /// Adjust the number of available grapheme bytes in the page. - grapheme_bytes: ?size.GraphemeBytesInt = null, - - /// Adjust the number of available hyperlink bytes in the page. - hyperlink_bytes: ?size.HyperlinkCountInt = null, - - /// Adjust the number of available string bytes in the page. - string_bytes: ?size.StringBytesInt = null, -}; - -pub const AdjustCapacityError = Allocator.Error || Page.CloneFromError; - -/// Adjust the capacity of the given page in the list. This should -/// be used in cases where OutOfMemory is returned by some operation -/// i.e to increase style counts, grapheme counts, etc. -/// -/// Adjustment works by increasing the capacity of the desired -/// dimension to a certain amount and increases the memory allocation -/// requirement for the backing memory of the page. We currently -/// never split pages or anything like that. Because increased allocation -/// has to happen outside our memory pool, its generally much slower -/// so pages should be sized to be large enough to handle all but -/// exceptional cases. -/// -/// This can currently only INCREASE capacity size. It cannot -/// decrease capacity size. This limitation is only because we haven't -/// yet needed that use case. If we ever do, this can be added. Currently -/// any requests to decrease will be ignored. -pub fn adjustCapacity( - self: *PageList, - node: *List.Node, - adjustment: AdjustCapacity, -) AdjustCapacityError!*List.Node { - defer self.assertIntegrity(); - const page: *Page = &node.data; - - // We always start with the base capacity of the existing page. This - // ensures we never shrink from what we need. - var cap = page.capacity; - - // All ceilPowerOfTwo is unreachable because we're always same or less - // bit width so maxInt is always possible. - if (adjustment.styles) |v| { - const aligned = std.math.ceilPowerOfTwo(size.StyleCountInt, v) catch unreachable; - cap.styles = @max(cap.styles, aligned); - } - if (adjustment.grapheme_bytes) |v| { - const aligned = std.math.ceilPowerOfTwo(size.GraphemeBytesInt, v) catch unreachable; - cap.grapheme_bytes = @max(cap.grapheme_bytes, aligned); - } - if (adjustment.hyperlink_bytes) |v| { - const aligned = std.math.ceilPowerOfTwo(size.HyperlinkCountInt, v) catch unreachable; - cap.hyperlink_bytes = @max(cap.hyperlink_bytes, aligned); - } - if (adjustment.string_bytes) |v| { - const aligned = std.math.ceilPowerOfTwo(size.StringBytesInt, v) catch unreachable; - cap.string_bytes = @max(cap.string_bytes, aligned); - } - - log.info("adjusting page capacity={}", .{cap}); - - // Create our new page and clone the old page into it. - const new_node = try self.createPage(cap); - errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; - assert(new_page.capacity.rows >= page.capacity.rows); - assert(new_page.capacity.cols >= page.capacity.cols); - new_page.size.rows = page.size.rows; - new_page.size.cols = page.size.cols; - try new_page.cloneFrom(page, 0, page.size.rows); - - // Fix up all our tracked pins to point to the new page. - const pin_keys = self.tracked_pins.keys(); - for (pin_keys) |p| { - if (p.node != node) continue; - p.node = new_node; - } - - // Insert this page and destroy the old page - self.pages.insertBefore(node, new_node); - self.pages.remove(node); - self.destroyNode(node); - - new_page.assertIntegrity(); - return new_node; -} - /// Possible dimensions to increase capacity for. pub const IncreaseCapacity = enum { styles, @@ -4991,21 +4893,14 @@ test "PageList grow prune required with a single page" { // behavior during a refactor. This is setting up a scenario that is // possible to trigger a bug (#2280). { - // Adjust our capacity until our page is larger than the standard size. + // Increase our capacity until our page is larger than the standard size. // This is important because it triggers a scenario where our calculated // minSize() which is supposed to accommodate 2 pages is no longer true. - var cap = std_capacity; while (true) { - cap.grapheme_bytes *= 2; - const layout = Page.layout(cap); + const layout = Page.layout(s.pages.first.?.data.capacity); if (layout.total_size > std_size) break; + _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } - - // Adjust to that capacity. After we should still have one page. - _ = try s.adjustCapacity( - s.pages.first.?, - .{ .grapheme_bytes = cap.grapheme_bytes }, - ); try testing.expect(s.pages.first != null); try testing.expect(s.pages.first == s.pages.last); } @@ -6424,168 +6319,6 @@ test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" }, s.scrollbar()); } -test "PageList adjustCapacity to increase styles" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - { - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - - // Write all our data so we can assert its the same after - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = @intCast(x) }, - }; - } - } - } - - // Increase our styles - _ = try s.adjustCapacity( - s.pages.first.?, - .{ .styles = std_capacity.styles * 2 }, - ); - - { - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - try testing.expectEqual( - @as(u21, @intCast(x)), - rac.cell.content.codepoint, - ); - } - } - } -} - -test "PageList adjustCapacity to increase graphemes" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - { - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - - // Write all our data so we can assert its the same after - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = @intCast(x) }, - }; - } - } - } - - // Increase our graphemes - _ = try s.adjustCapacity( - s.pages.first.?, - .{ .grapheme_bytes = std_capacity.grapheme_bytes * 2 }, - ); - - { - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - try testing.expectEqual( - @as(u21, @intCast(x)), - rac.cell.content.codepoint, - ); - } - } - } -} - -test "PageList adjustCapacity to increase hyperlinks" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - { - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - - // Write all our data so we can assert its the same after - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - rac.cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = @intCast(x) }, - }; - } - } - } - - // Increase our graphemes - _ = try s.adjustCapacity( - s.pages.first.?, - .{ .hyperlink_bytes = @max(std_capacity.hyperlink_bytes * 2, 2048) }, - ); - - { - try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; - for (0..s.rows) |y| { - for (0..s.cols) |x| { - const rac = page.getRowAndCell(x, y); - try testing.expectEqual( - @as(u21, @intCast(x)), - rac.cell.content.codepoint, - ); - } - } - } -} - -test "PageList adjustCapacity after col shrink" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 2, 0); - defer s.deinit(); - - // Shrink columns - this updates size.cols but not capacity.cols - try s.resize(.{ .cols = 5, .reflow = false }); - try testing.expectEqual(5, s.cols); - - { - const page = &s.pages.first.?.data; - // capacity.cols is still 10, but size.cols should be 5 - try testing.expectEqual(5, page.size.cols); - try testing.expect(page.capacity.cols >= 10); - } - - // Now adjust capacity (e.g., to increase styles) - // This should preserve the current size.cols, not revert to capacity.cols - _ = try s.adjustCapacity( - s.pages.first.?, - .{ .styles = std_capacity.styles * 2 }, - ); - - { - const page = &s.pages.first.?.data; - // After adjustCapacity, size.cols should still be 5, not 10 - try testing.expectEqual(5, page.size.cols); - try testing.expectEqual(5, s.cols); - } -} - test "PageList increaseCapacity to increase styles" { const testing = std.testing; const alloc = testing.allocator; @@ -6799,13 +6532,12 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - // Set styles capacity to a value that will overflow when doubled - // We need to create a page with capacity at more than half of max + // Keep increasing styles capacity until we're at more than half of max const max_styles = std.math.maxInt(size.StyleCountInt); const half_max = max_styles / 2 + 1; - - // Adjust capacity to near-max first - _ = try s.adjustCapacity(s.pages.first.?, .{ .styles = half_max }); + while (s.pages.first.?.data.capacity.styles < half_max) { + _ = try s.increaseCapacity(s.pages.first.?, .styles); + } // Now increaseCapacity should fail with OutOfSpace try testing.expectError( @@ -11485,12 +11217,10 @@ test "PageList grow reuses non-standard page without leak" { var s = try init(alloc, 80, 24, 3 * std_size); defer s.deinit(); - // Save the first page node before adjustment - const first_before = s.pages.first.?; - - // Adjust the first page to have non-standard capacity. We use a small - // increase that makes it just slightly larger than std_size. - _ = try s.adjustCapacity(first_before, .{ .grapheme_bytes = std_size + 1 }); + // Increase the first page capacity to make it non-standard (larger than std_size). + while (s.pages.first.?.data.memory.len <= std_size) { + _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); + } // The first page should now have non-standard memory size. try testing.expect(s.pages.first.?.data.memory.len > std_size); From 6b2455828e1aba8898d3c11fc5b9e06f78a1b91c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 15:26:12 -0800 Subject: [PATCH 479/605] terminal: resizeWithoutReflowGrowCols can only fail for OOM --- src/terminal/PageList.zig | 89 +++++++++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index bf2071497..043a8f191 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1652,7 +1652,7 @@ const ReflowCursor = struct { } }; -fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { +fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { // We only set the new min_max_size if we're not reflowing. If we are // reflowing, then resize handles this for us. const old_min_max_size = self.min_max_size; @@ -1812,26 +1812,44 @@ fn resizeWithoutReflowGrowCols( self: *PageList, cols: size.CellCountInt, chunk: PageIterator.Chunk, -) !void { +) Allocator.Error!void { assert(cols > self.cols); const page = &chunk.node.data; - const cap = try page.capacity.adjust(.{ .cols = cols }); // Update our col count const old_cols = self.cols; - self.cols = cap.cols; + self.cols = cols; errdefer self.cols = old_cols; // Unlikely fast path: we have capacity in the page. This // is only true if we resized to less cols earlier. - if (page.capacity.cols >= cap.cols) { - page.size.cols = cap.cols; + if (page.capacity.cols >= cols) { + page.size.cols = cols; return; } // Likely slow path: we don't have capacity, so we need // to allocate a page, and copy the old data into it. + // Try to fit our new column size into our existing page capacity. + // If that doesn't work then use a non-standard page with the + // given columns. + const cap = page.capacity.adjust( + .{ .cols = cols }, + ) catch |err| err: { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + + // We verify all maxed out page layouts work. + var cap = page.capacity; + cap.cols = cols; + + // We're growing columns so we can only get less rows so use + // the lesser of our capacity and size so we minimize wasted + // rows. + cap.rows = @min(page.size.rows, cap.rows); + break :err cap; + }; + // On error, we need to undo all the pages we've added. const prev = chunk.node.prev; errdefer { @@ -1895,6 +1913,31 @@ fn resizeWithoutReflowGrowCols( assert(prev_page.size.rows <= prev_page.capacity.rows); } + // If we have an error, we clear the rows we just added to our prev page. + const prev_copied = copied; + errdefer if (prev_copied > 0) { + const prev_page = &prev.?.data; + const prev_size = prev_page.size.rows - prev_copied; + const prev_rows = prev_page.rows.ptr(prev_page.memory)[prev_size..prev_page.size.rows]; + for (prev_rows) |*row| prev_page.clearCells( + row, + 0, + prev_page.size.cols, + ); + prev_page.size.rows = prev_size; + }; + + // We delete any of the nodes we added. + errdefer { + var it = chunk.node.prev; + while (it) |node| { + if (node == prev) break; + it = node.prev; + self.pages.remove(node); + self.destroyNode(node); + } + } + // We need to loop because our col growth may force us // to split pages. while (copied < page.size.rows) { @@ -1907,19 +1950,33 @@ fn resizeWithoutReflowGrowCols( // Perform the copy const y_start = copied; - const y_end = copied + len; - const src_rows = page.rows.ptr(page.memory)[y_start..y_end]; + const src_rows = page.rows.ptr(page.memory)[y_start .. copied + len]; const dst_rows = new_node.data.rows.ptr(new_node.data.memory)[0..len]; for (dst_rows, src_rows) |*dst_row, *src_row| { new_node.data.size.rows += 1; - errdefer new_node.data.size.rows -= 1; - try new_node.data.cloneRowFrom( + if (new_node.data.cloneRowFrom( page, dst_row, src_row, - ); + )) |_| { + copied += 1; + } else |err| { + // I don't THINK this should be possible, because while our + // row count may diminish due to the adjustment, our + // prior capacity should have been sufficient to hold all the + // managed memory. + log.warn( + "unexpected cloneRowFrom failure during resizeWithoutReflowGrowCols: {}", + .{err}, + ); + + // We can actually safely handle this though by exiting + // this loop early and cutting our copy short. + new_node.data.size.rows -= 1; + break; + } } - copied = y_end; + const y_end = copied; // Insert our new page self.pages.insertBefore(chunk.node, new_node); @@ -1936,6 +1993,10 @@ fn resizeWithoutReflowGrowCols( } assert(copied == page.size.rows); + // Our prior errdeferes are invalid after this point so ensure + // we don't have any more errors. + errdefer comptime unreachable; + // Remove the old page. // Deallocate the old page. self.pages.remove(chunk.node); @@ -2514,7 +2575,7 @@ inline fn growRequiredForActive(self: *const PageList) bool { /// adhere to max_size. /// /// This returns the newly allocated page node if there is one. -pub fn grow(self: *PageList) !?*List.Node { +pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); const last = self.pages.last.?; @@ -2533,7 +2594,7 @@ pub fn grow(self: *PageList) !?*List.Node { // Get the layout first so our failable work is done early. // We'll need this for both paths. - const cap = try std_capacity.adjust(.{ .cols = self.cols }); + const cap = initialCapacity(self.cols); // If allocation would exceed our max size, we prune the first page. // We don't need to reallocate because we can simply reuse that first From d626984418cf91910543683f64ba95d24815f4ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 15 Jan 2026 20:31:22 -0800 Subject: [PATCH 480/605] terminal: reflowCursor improve error handling on assumed cases --- src/terminal/PageList.zig | 233 +++++++++++++++++++++++++++++++------- 1 file changed, 190 insertions(+), 43 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 043a8f191..755e0a393 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -992,32 +992,71 @@ fn resizeCols( } else null; defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin); - const first = self.pages.first.?; - var it = self.rowIterator(.right_down, .{ .screen = .{} }, null); + // Create the first node that contains our reflow. + const first_rewritten_node = node: { + const page = &self.pages.first.?.data; + const cap = page.capacity.adjust( + .{ .cols = cols }, + ) catch |err| err: { + comptime assert(@TypeOf(err) == error{OutOfMemory}); - const dst_node = try self.createPage(try first.data.capacity.adjust(.{ .cols = cols })); - dst_node.data.size.rows = 1; + // We verify all maxed out page layouts work. + var cap = page.capacity; + cap.cols = cols; + + // We're growing columns so we can only get less rows so use + // the lesser of our capacity and size so we minimize wasted + // rows. + cap.rows = @min(page.size.rows, cap.rows); + break :err cap; + }; + + const node = try self.createPage(cap); + node.data.size.rows = 1; + break :node node; + }; + + // We need to grab our rowIterator now before we rewrite our + // linked list below. + var it = self.rowIterator( + .right_down, + .{ .screen = .{} }, + null, + ); + errdefer { + // If an error occurs, we're in a pretty disastrous broken state, + // but we should still try to clean up our leaked memory. Free + // any of the remaining orphaned pages from before. If we reflowed + // successfully this will be null. + var node_: ?*Node = if (it.chunk) |chunk| chunk.node else null; + while (node_) |node| { + node_ = node.next; + self.destroyNode(node); + } + } // Set our new page as the only page. This orphans the existing pages // in the list, but that's fine since we're gonna delete them anyway. - self.pages.first = dst_node; - self.pages.last = dst_node; + self.pages.first = first_rewritten_node; + self.pages.last = first_rewritten_node; // Reflow all our rows. { - var dst_cursor = ReflowCursor.init(dst_node); + var reflow_cursor: ReflowCursor = .init(first_rewritten_node); while (it.next()) |row| { - try dst_cursor.reflowRow(self, row); + try reflow_cursor.reflowRow(self, row); - // Once we're done reflowing a page, destroy it. + // Once we're done reflowing a page, destroy it immediately. + // This frees memory and makes it more likely in memory + // constrained environments that the next reflow will work. if (row.y == row.node.data.size.rows - 1) { self.destroyNode(row.node); } } // At the end of the reflow, setup our total row cache - // log.warn("total old={} new={}", .{ self.total_rows, dst_cursor.total_rows }); - self.total_rows = dst_cursor.total_rows; + // log.warn("total old={} new={}", .{ self.total_rows, reflow_cursor.total_rows }); + self.total_rows = reflow_cursor.total_rows; } // If our total rows is less than our active rows, we need to grow. @@ -1114,16 +1153,11 @@ const ReflowCursor = struct { const src_page: *Page = &row.node.data; const src_row = row.rowAndCell().row; const src_y = row.y; - - // Inherit increased styles or grapheme bytes from - // the src page we're reflowing from for new pages. - const cap = try src_page.capacity.adjust(.{ .cols = self.page.size.cols }); - const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols]; + // Calculate the columns in this row. First up we trim non-semantic + // rightmost blanks. var cols_len = src_page.size.cols; - - // If the row is wrapped, all empty cells are meaningful. if (!src_row.wrap) { while (cols_len > 0) { if (!cells[cols_len - 1].isEmpty()) break; @@ -1145,9 +1179,10 @@ const ReflowCursor = struct { // If this pin is in the blanks on the right and past the end // of the dst col width then we move it to the end of the dst // col width instead. - if (p.x >= cols_len) { - p.x = @min(p.x, cap.cols - 1 - self.x); - } + if (p.x >= cols_len) p.x = @min( + p.x, + self.page.size.cols - 1 - self.x, + ); // We increase our col len to at least include this pin. // This ensures that blank rows with pins are processed, @@ -1162,16 +1197,29 @@ const ReflowCursor = struct { // If this blank row was a wrap continuation somehow // then we won't need to write it since it should be // a part of the previously written row. - if (!src_row.wrap_continuation) { - self.new_rows += 1; - } + if (!src_row.wrap_continuation) self.new_rows += 1; return; } + // Inherit increased styles or grapheme bytes from the src page + // we're reflowing from for new pages. + const cap = src_page.capacity.adjust( + .{ .cols = self.page.size.cols }, + ) catch |err| err: { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + + var cap = src_page.capacity; + cap.cols = self.page.size.cols; + // We're already a non-standard page. We don't want to + // inherit a massive set of rows, so cap it at our std size. + cap.rows = @min(src_page.size.rows, std_capacity.rows); + break :err cap; + }; + // Our row isn't blank, write any new rows we deferred. while (self.new_rows > 0) { - self.new_rows -= 1; try self.cursorScrollOrNewPage(list, cap); + self.new_rows -= 1; } self.copyRowMetadata(src_row); @@ -1326,12 +1374,30 @@ const ReflowCursor = struct { } } - // This shouldn't fail since we made sure we have space above. - try self.page.setGraphemes(self.page_row, self.page_cell, cps); + self.page.setGraphemes( + self.page_row, + self.page_cell, + cps, + ) catch |err| { + // This shouldn't fail since we made sure we have space + // above. There is no reasonable behavior we can take here + // so we have a warn level log. This is ALMOST non-recoverable, + // though we choose to recover by corrupting the cell + // to a non-grapheme codepoint. + log.err("setGraphemes failed after capacity increase err={}", .{err}); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + // Unsafe builds we throw away grapheme data! + cell.content_tag = .codepoint; + cell.content = .{ .codepoint = 0xFFFD }; + }; } // Copy hyperlink data. - if (cell.hyperlink) { + if (cell.hyperlink) hyperlink: { const src_id = src_page.lookupHyperlink(cell).?; const src_link = src_page.hyperlink_set.get(src_page.memory, src_id); @@ -1371,10 +1437,25 @@ const ReflowCursor = struct { } } + const dst_link = src_link.dupe( + src_page, + self.page, + ) catch |err| { + // This shouldn't fail since we did a capacity + // check above. + log.err("link dupe failed with capacity check err={}", .{err}); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + cell.hyperlink = false; + break :hyperlink; + }; + const dst_id = self.page.hyperlink_set.addWithIdContext( self.page.memory, - // We made sure there was enough capacity for this above. - try src_link.dupe(src_page, self.page), + dst_link, src_id, .{ .page = self.page }, ) catch |err| id: { @@ -1387,29 +1468,80 @@ const ReflowCursor = struct { error.NeedsRehash => null, }); + // We dupe the link again, and don't have to worry about + // freeing the other one because increasing the capacity + // destroyed the prior page. + const dst_link2 = src_link.dupe( + src_page, + self.page, + ) catch |err2| { + // This shouldn't fail since we did a capacity + // check above. + log.err("link dupe failed with capacity check err={}", .{err2}); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + cell.hyperlink = false; + break :hyperlink; + }; + // We assume this one will succeed. We dupe the link // again, and don't have to worry about the other one // because increasing the capacity naturally clears up // any managed memory not associated with a cell yet. - break :id try self.page.hyperlink_set.addWithIdContext( + break :id self.page.hyperlink_set.addWithIdContext( self.page.memory, - try src_link.dupe(src_page, self.page), + dst_link2, src_id, .{ .page = self.page }, - ); + ) catch |err2| { + // This shouldn't happen since we increased capacity + // above so we handle it like the other similar + // cases and log it, crash in safe builds, and + // remove the hyperlink in unsafe builds. + log.err( + "addWithIdContext failed after capacity increase err={}", + .{err2}, + ); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + dst_link2.free(self.page); + cell.hyperlink = false; + break :hyperlink; + }; } orelse src_id; - // We expect this to succeed due to the - // hyperlinkCapacity check we did before. - try self.page.setHyperlink( + // We expect this to succeed due to the hyperlinkCapacity + // check we did before. If it doesn't succeed let's + // log it, crash (in safe builds), and clear our state. + self.page.setHyperlink( self.page_row, self.page_cell, dst_id, - ); + ) catch |err| { + log.err( + "setHyperlink failed after capacity increase err={}", + .{err}, + ); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + // Unsafe builds we throw away hyperlink data! + self.page.hyperlink_set.release(self.page.memory, dst_id); + cell.hyperlink = false; + break :hyperlink; + }; } // Copy style data. - if (cell.hasStyling()) { + if (cell.hasStyling()) style: { const style = src_page.styles.get( src_page.memory, cell.style_id, @@ -1430,15 +1562,29 @@ const ReflowCursor = struct { }); // We assume this one will succeed. - break :id try self.page.styles.addWithId( + break :id self.page.styles.addWithId( self.page.memory, style, cell.style_id, - ); + ) catch |err2| { + // Should not fail since we just modified capacity + // above. Log it, crash in safe builds, clear style + // in unsafe builds. + log.err( + "addWithId failed after capacity increase err={}", + .{err2}, + ); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + cell.style_id = stylepkg.default_id; + break :style; + }; } orelse cell.style_id; self.page_row.styled = true; - self.page_cell.style_id = id; } @@ -1574,12 +1720,13 @@ const ReflowCursor = struct { self: *ReflowCursor, list: *PageList, cap: Capacity, - ) !void { + ) Allocator.Error!void { // Remember our new row count so we can restore it // after reinitializing our cursor on the new page. const new_rows = self.new_rows; const node = try list.createPage(cap); + errdefer comptime unreachable; node.data.size.rows = 1; list.pages.insertAfter(self.node, node); @@ -1594,7 +1741,7 @@ const ReflowCursor = struct { self: *ReflowCursor, list: *PageList, cap: Capacity, - ) !void { + ) Allocator.Error!void { // The functions below may overwrite self so we need to cache // our total rows. We add one because no matter what when this // returns we'll have one more row added. From 25643ec806e5be828b236b8ead5814a0fcabaff8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 06:22:05 -0800 Subject: [PATCH 481/605] terminal: reflowRow extract writeCell --- src/terminal/PageList.zig | 733 ++++++++++++++++++++------------------ 1 file changed, 384 insertions(+), 349 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 755e0a393..0ca00f45c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1247,355 +1247,19 @@ const ReflowCursor = struct { } } - const cell = &cells[x]; - x += 1; - - // Copy cell contents. - switch (cell.content_tag) { - .codepoint, - .codepoint_grapheme, - => switch (cell.wide) { - .narrow => self.page_cell.* = cell.*, - - .wide => if (self.page.size.cols > 1) { - if (self.x == self.page.size.cols - 1) { - // If there's a wide character in the last column of - // the reflowed page then we need to insert a spacer - // head and wrap before handling it. - self.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, - .wide = .spacer_head, - }; - - // Decrement the source position so that when we - // 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.*; - } - } else { - // Edge case, when resizing to 1 column, wide - // characters are just destroyed and replaced - // with empty narrow cells. - self.page_cell.content.codepoint = 0; - self.page_cell.wide = .narrow; - self.cursorForward(); - // Skip spacer tail so it doesn't cause a wrap. - x += 1; - continue; - }, - - .spacer_tail => if (self.page.size.cols > 1) { - self.page_cell.* = cell.*; - } else { - // Edge case, when resizing to 1 column, wide - // characters are just destroyed and replaced - // with empty narrow cells, so we should just - // discard any spacer tails. - continue; - }, - - .spacer_head => { - // Spacer heads should be ignored. If we need a - // spacer head in our reflowed page, it is added - // when processing the wide cell it belongs to. - continue; - }, - }, - - .bg_color_palette, - .bg_color_rgb, - => { - // These are guaranteed to have no style or grapheme - // data associated with them so we can fast path them. - self.page_cell.* = cell.*; - self.cursorForward(); - continue; - }, + switch (try self.writeCell( + list, + &cells[x], + src_page, + )) { + // Wrote the cell, move to the next. + .success => x += 1, + // Wrote the cell but request to skip the next so skip it. + // This is used for things like spacers. + .skip_next => x += 2, + // Didn't write the cell, repeat writing this same cell. + .repeat => {}, } - - // These will create issues by trying to clone managed memory that - // isn't set if the current dst row needs to be moved to a new page. - // They'll be fixed once we do properly copy the relevant memory. - self.page_cell.content_tag = .codepoint; - 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 - const cps = src_page.lookupGrapheme(cell).?; - - // If our page can't support an additional cell - // with graphemes then we increase capacity. - if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { - try self.increaseCapacity( - list, - .grapheme_bytes, - ); - } - - // Attempt to allocate the space that would be required - // for these graphemes, and if it's not available, then - // increase capacity. Keep trying until we succeed. - while (true) { - if (self.page.grapheme_alloc.alloc( - u21, - self.page.memory, - cps.len, - )) |slice| { - self.page.grapheme_alloc.free( - self.page.memory, - slice, - ); - break; - } else |_| { - // Grow our capacity until we can fit the extra bytes. - try self.increaseCapacity(list, .grapheme_bytes); - } - } - - self.page.setGraphemes( - self.page_row, - self.page_cell, - cps, - ) catch |err| { - // This shouldn't fail since we made sure we have space - // above. There is no reasonable behavior we can take here - // so we have a warn level log. This is ALMOST non-recoverable, - // though we choose to recover by corrupting the cell - // to a non-grapheme codepoint. - log.err("setGraphemes failed after capacity increase err={}", .{err}); - if (comptime std.debug.runtime_safety) { - // Force a crash with safe builds. - unreachable; - } - - // Unsafe builds we throw away grapheme data! - cell.content_tag = .codepoint; - cell.content = .{ .codepoint = 0xFFFD }; - }; - } - - // Copy hyperlink data. - if (cell.hyperlink) hyperlink: { - const src_id = src_page.lookupHyperlink(cell).?; - const src_link = src_page.hyperlink_set.get(src_page.memory, src_id); - - // If our page can't support an additional cell - // with a hyperlink then we increase capacity. - if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity()) { - try self.increaseCapacity(list, .hyperlink_bytes); - } - - // Ensure that the string alloc has sufficient capacity - // to dupe the link (and the ID if it's not implicit). - const additional_required_string_capacity = - src_link.uri.len + - switch (src_link.id) { - .explicit => |v| v.len, - .implicit => 0, - }; - // Keep trying until we have enough capacity. - while (true) { - if (self.page.string_alloc.alloc( - u8, - self.page.memory, - additional_required_string_capacity, - )) |slice| { - // We have enough capacity, free the test alloc. - self.page.string_alloc.free( - self.page.memory, - slice, - ); - break; - } else |_| { - // Grow our capacity until we can fit the extra bytes. - try self.increaseCapacity( - list, - .string_bytes, - ); - } - } - - const dst_link = src_link.dupe( - src_page, - self.page, - ) catch |err| { - // This shouldn't fail since we did a capacity - // check above. - log.err("link dupe failed with capacity check err={}", .{err}); - if (comptime std.debug.runtime_safety) { - // Force a crash with safe builds. - unreachable; - } - - cell.hyperlink = false; - break :hyperlink; - }; - - const dst_id = self.page.hyperlink_set.addWithIdContext( - self.page.memory, - dst_link, - src_id, - .{ .page = self.page }, - ) catch |err| id: { - // If the add failed then either the set needs to grow - // or it needs to be rehashed. Either one of those can - // be accomplished by increasing capacity, either with - // no actual change or with an increased hyperlink cap. - try self.increaseCapacity(list, switch (err) { - error.OutOfMemory => .hyperlink_bytes, - error.NeedsRehash => null, - }); - - // We dupe the link again, and don't have to worry about - // freeing the other one because increasing the capacity - // destroyed the prior page. - const dst_link2 = src_link.dupe( - src_page, - self.page, - ) catch |err2| { - // This shouldn't fail since we did a capacity - // check above. - log.err("link dupe failed with capacity check err={}", .{err2}); - if (comptime std.debug.runtime_safety) { - // Force a crash with safe builds. - unreachable; - } - - cell.hyperlink = false; - break :hyperlink; - }; - - // We assume this one will succeed. We dupe the link - // again, and don't have to worry about the other one - // because increasing the capacity naturally clears up - // any managed memory not associated with a cell yet. - break :id self.page.hyperlink_set.addWithIdContext( - self.page.memory, - dst_link2, - src_id, - .{ .page = self.page }, - ) catch |err2| { - // This shouldn't happen since we increased capacity - // above so we handle it like the other similar - // cases and log it, crash in safe builds, and - // remove the hyperlink in unsafe builds. - log.err( - "addWithIdContext failed after capacity increase err={}", - .{err2}, - ); - if (comptime std.debug.runtime_safety) { - // Force a crash with safe builds. - unreachable; - } - - dst_link2.free(self.page); - cell.hyperlink = false; - break :hyperlink; - }; - } orelse src_id; - - // We expect this to succeed due to the hyperlinkCapacity - // check we did before. If it doesn't succeed let's - // log it, crash (in safe builds), and clear our state. - self.page.setHyperlink( - self.page_row, - self.page_cell, - dst_id, - ) catch |err| { - log.err( - "setHyperlink failed after capacity increase err={}", - .{err}, - ); - if (comptime std.debug.runtime_safety) { - // Force a crash with safe builds. - unreachable; - } - - // Unsafe builds we throw away hyperlink data! - self.page.hyperlink_set.release(self.page.memory, dst_id); - cell.hyperlink = false; - break :hyperlink; - }; - } - - // Copy style data. - if (cell.hasStyling()) style: { - const style = src_page.styles.get( - src_page.memory, - cell.style_id, - ).*; - - const id = self.page.styles.addWithId( - self.page.memory, - style, - cell.style_id, - ) catch |err| id: { - // If the add failed then either the set needs to grow - // or it needs to be rehashed. Either one of those can - // be accomplished by increasing capacity, either with - // no actual change or with an increased style cap. - try self.increaseCapacity(list, switch (err) { - error.OutOfMemory => .styles, - error.NeedsRehash => null, - }); - - // We assume this one will succeed. - break :id self.page.styles.addWithId( - self.page.memory, - style, - cell.style_id, - ) catch |err2| { - // Should not fail since we just modified capacity - // above. Log it, crash in safe builds, clear style - // in unsafe builds. - log.err( - "addWithId failed after capacity increase err={}", - .{err2}, - ); - if (comptime std.debug.runtime_safety) { - // Force a crash with safe builds. - unreachable; - } - - cell.style_id = stylepkg.default_id; - break :style; - }; - } orelse cell.style_id; - - self.page_row.styled = true; - self.page_cell.style_id = id; - } - - if (comptime build_options.kitty_graphics) { - // Copy Kitty virtual placeholder status - if (cell.codepoint() == kitty.graphics.unicode.placeholder) { - self.page_row.kitty_virtual_placeholder = true; - } - } - - self.cursorForward(); } // If the source row isn't wrapped then we should scroll afterwards. @@ -1604,6 +1268,377 @@ const ReflowCursor = struct { } } + /// Write a cell. On error, this will not unwrite the cell but + /// the cell may be incomplete (but valid). For example, if the source + /// cell is styled and we failed to allocate space for styles, the + /// written cell may not be styled but it is valid. + /// + /// The key failure to recognize for callers is when we can't increase + /// capacity in our destination page. In this case, the caller may want + /// to split the page at this row, rewrite the row into a new page + /// and continue from there. + /// + /// But this function guarantees the terminal/page will be in a + /// coherent state even on error. + fn writeCell( + self: *ReflowCursor, + list: *PageList, + cell: *const pagepkg.Cell, + src_page: *const Page, + ) IncreaseCapacityError!enum { + success, + repeat, + skip_next, + } { + // Copy cell contents. + switch (cell.content_tag) { + .codepoint, + .codepoint_grapheme, + => switch (cell.wide) { + .narrow => self.page_cell.* = cell.*, + + .wide => if (self.page.size.cols > 1) { + if (self.x == self.page.size.cols - 1) { + // If there's a wide character in the last column of + // the reflowed page then we need to insert a spacer + // head and wrap before handling it. + self.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + + // Move to the next row (this sets pending wrap + // which will cause us to wrap on the next + // iteration). + self.cursorForward(); + + // Decrement the source position so that when we + // loop we'll process this source cell again, + // since we can't copy it into a spacer head. + return .repeat; + } else { + self.page_cell.* = cell.*; + } + } else { + // Edge case, when resizing to 1 column, wide + // characters are just destroyed and replaced + // with empty narrow cells. + self.page_cell.content.codepoint = 0; + self.page_cell.wide = .narrow; + self.cursorForward(); + + // Skip spacer tail so it doesn't cause a wrap. + return .skip_next; + }, + + .spacer_tail => if (self.page.size.cols > 1) { + self.page_cell.* = cell.*; + } else { + // Edge case, when resizing to 1 column, wide + // characters are just destroyed and replaced + // with empty narrow cells, so we should just + // discard any spacer tails. + return .success; + }, + + .spacer_head => { + // Spacer heads should be ignored. If we need a + // spacer head in our reflowed page, it is added + // when processing the wide cell it belongs to. + return .success; + }, + }, + + .bg_color_palette, + .bg_color_rgb, + => { + // These are guaranteed to have no style or grapheme + // data associated with them so we can fast path them. + self.page_cell.* = cell.*; + self.cursorForward(); + return .success; + }, + } + + // These will create issues by trying to clone managed memory that + // isn't set if the current dst row needs to be moved to a new page. + // They'll be fixed once we do properly copy the relevant memory. + self.page_cell.content_tag = .codepoint; + 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, + // }); + + if (comptime build_options.kitty_graphics) { + // Copy Kitty virtual placeholder status + if (cell.codepoint() == kitty.graphics.unicode.placeholder) { + self.page_row.kitty_virtual_placeholder = true; + } + } + + // Copy grapheme data. + if (cell.content_tag == .codepoint_grapheme) { + // Copy the graphemes + const cps = src_page.lookupGrapheme(cell).?; + + // If our page can't support an additional cell + // with graphemes then we increase capacity. + if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { + try self.increaseCapacity( + list, + .grapheme_bytes, + ); + } + + // Attempt to allocate the space that would be required + // for these graphemes, and if it's not available, then + // increase capacity. Keep trying until we succeed. + while (true) { + if (self.page.grapheme_alloc.alloc( + u21, + self.page.memory, + cps.len, + )) |slice| { + self.page.grapheme_alloc.free( + self.page.memory, + slice, + ); + break; + } else |_| { + // Grow our capacity until we can fit the extra bytes. + try self.increaseCapacity(list, .grapheme_bytes); + } + } + + self.page.setGraphemes( + self.page_row, + self.page_cell, + cps, + ) catch |err| { + // This shouldn't fail since we made sure we have space + // above. There is no reasonable behavior we can take here + // so we have a warn level log. This is ALMOST non-recoverable, + // though we choose to recover by corrupting the cell + // to a non-grapheme codepoint. + log.err("setGraphemes failed after capacity increase err={}", .{err}); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + // Unsafe builds we throw away grapheme data! + cell.content_tag = .codepoint; + cell.content = .{ .codepoint = 0xFFFD }; + }; + } + + // Copy hyperlink data. + if (cell.hyperlink) hyperlink: { + const src_id = src_page.lookupHyperlink(cell).?; + const src_link = src_page.hyperlink_set.get(src_page.memory, src_id); + + // If our page can't support an additional cell + // with a hyperlink then we increase capacity. + if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity()) { + try self.increaseCapacity(list, .hyperlink_bytes); + } + + // Ensure that the string alloc has sufficient capacity + // to dupe the link (and the ID if it's not implicit). + const additional_required_string_capacity = + src_link.uri.len + + switch (src_link.id) { + .explicit => |v| v.len, + .implicit => 0, + }; + // Keep trying until we have enough capacity. + while (true) { + if (self.page.string_alloc.alloc( + u8, + self.page.memory, + additional_required_string_capacity, + )) |slice| { + // We have enough capacity, free the test alloc. + self.page.string_alloc.free( + self.page.memory, + slice, + ); + break; + } else |_| { + // Grow our capacity until we can fit the extra bytes. + try self.increaseCapacity( + list, + .string_bytes, + ); + } + } + + const dst_link = src_link.dupe( + src_page, + self.page, + ) catch |err| { + // This shouldn't fail since we did a capacity + // check above. + log.err("link dupe failed with capacity check err={}", .{err}); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + break :hyperlink; + }; + + const dst_id = self.page.hyperlink_set.addWithIdContext( + self.page.memory, + dst_link, + src_id, + .{ .page = self.page }, + ) catch |err| id: { + // Always free our original link in case the increaseCap + // call fails so we aren't leaking memory. + dst_link.free(self.page); + + // If the add failed then either the set needs to grow + // or it needs to be rehashed. Either one of those can + // be accomplished by increasing capacity, either with + // no actual change or with an increased hyperlink cap. + try self.increaseCapacity(list, switch (err) { + error.OutOfMemory => .hyperlink_bytes, + error.NeedsRehash => null, + }); + + // We need to recreate the link into the new page. + const dst_link2 = src_link.dupe( + src_page, + self.page, + ) catch |err2| { + // This shouldn't fail since we did a capacity + // check above. + log.err("link dupe failed with capacity check err={}", .{err2}); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + cell.hyperlink = false; + break :hyperlink; + }; + + // We assume this one will succeed. We dupe the link + // again, and don't have to worry about the other one + // because increasing the capacity naturally clears up + // any managed memory not associated with a cell yet. + break :id self.page.hyperlink_set.addWithIdContext( + self.page.memory, + dst_link2, + src_id, + .{ .page = self.page }, + ) catch |err2| { + // This shouldn't happen since we increased capacity + // above so we handle it like the other similar + // cases and log it, crash in safe builds, and + // remove the hyperlink in unsafe builds. + log.err( + "addWithIdContext failed after capacity increase err={}", + .{err2}, + ); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + dst_link2.free(self.page); + cell.hyperlink = false; + break :hyperlink; + }; + } orelse src_id; + + // We expect this to succeed due to the hyperlinkCapacity + // check we did before. If it doesn't succeed let's + // log it, crash (in safe builds), and clear our state. + self.page.setHyperlink( + self.page_row, + self.page_cell, + dst_id, + ) catch |err| { + log.err( + "setHyperlink failed after capacity increase err={}", + .{err}, + ); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + // Unsafe builds we throw away hyperlink data! + self.page.hyperlink_set.release(self.page.memory, dst_id); + cell.hyperlink = false; + break :hyperlink; + }; + } + + // Copy style data. + if (cell.hasStyling()) style: { + const style = src_page.styles.get( + src_page.memory, + cell.style_id, + ).*; + + const id = self.page.styles.addWithId( + self.page.memory, + style, + cell.style_id, + ) catch |err| id: { + // If the add failed then either the set needs to grow + // or it needs to be rehashed. Either one of those can + // be accomplished by increasing capacity, either with + // no actual change or with an increased style cap. + try self.increaseCapacity(list, switch (err) { + error.OutOfMemory => .styles, + error.NeedsRehash => null, + }); + + // We assume this one will succeed. + break :id self.page.styles.addWithId( + self.page.memory, + style, + cell.style_id, + ) catch |err2| { + // Should not fail since we just modified capacity + // above. Log it, crash in safe builds, clear style + // in unsafe builds. + log.err( + "addWithId failed after capacity increase err={}", + .{err2}, + ); + if (comptime std.debug.runtime_safety) { + // Force a crash with safe builds. + unreachable; + } + + cell.style_id = stylepkg.default_id; + break :style; + }; + } orelse cell.style_id; + + self.page_row.styled = true; + self.page_cell.style_id = id; + } + + self.cursorForward(); + return .success; + } + /// Create a new page in the provided list with the provided /// capacity then clone the row currently being worked on to /// it and delete it from the old page. Places cursor in the @@ -1654,7 +1689,7 @@ const ReflowCursor = struct { self: *ReflowCursor, list: *PageList, adjustment: ?IncreaseCapacity, - ) !void { + ) IncreaseCapacityError!void { const old_x = self.x; const old_y = self.y; const old_total_rows = self.total_rows; From 97621ece935228efe82bfb451d3be75051e12cf1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 07:21:17 -0800 Subject: [PATCH 482/605] terminal: handle reflowRow OutOfSpace by no-op --- src/terminal/PageList.zig | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 0ca00f45c..2045ccc01 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -874,7 +874,7 @@ pub const Resize = struct { /// Resize /// TODO: docs -pub fn resize(self: *PageList, opts: Resize) !void { +pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); if (comptime std.debug.runtime_safety) { @@ -944,7 +944,7 @@ fn resizeCols( self: *PageList, cols: size.CellCountInt, cursor: ?Resize.Cursor, -) !void { +) Allocator.Error!void { assert(cols != self.cols); // Update our cols. We have to do this early because grow() that we @@ -1149,7 +1149,7 @@ const ReflowCursor = struct { self: *ReflowCursor, list: *PageList, row: Pin, - ) !void { + ) Allocator.Error!void { const src_page: *Page = &row.node.data; const src_row = row.rowAndCell().row; const src_y = row.y; @@ -1247,11 +1247,11 @@ const ReflowCursor = struct { } } - switch (try self.writeCell( + if (self.writeCell( list, &cells[x], src_page, - )) { + )) |result| switch (result) { // Wrote the cell, move to the next. .success => x += 1, // Wrote the cell but request to skip the next so skip it. @@ -1259,6 +1259,24 @@ const ReflowCursor = struct { .skip_next => x += 2, // Didn't write the cell, repeat writing this same cell. .repeat => {}, + } else |err| switch (err) { + // System out of memory, we can't fix this. + error.OutOfMemory => return error.OutOfMemory, + + // We reached the capacity of a single page and can't + // add any more of some type of managed memory. + error.OutOfSpace => { + log.warn("OutOfSpace during reflow at src_y={} src_x={} dst_y={} dst_x={} cp={X}", .{ + src_y, + x, + self.y, + self.x, + cells[x].content.codepoint, + }); + + // TODO: Split the page + x += 1; + }, } } From 42321cc7d59f3c3f02c14e225c0d374c8a002339 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 07:24:37 -0800 Subject: [PATCH 483/605] terminal: write to the proper cell --- src/terminal/PageList.zig | 160 ++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 75 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 2045ccc01..84518e8b7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1308,84 +1308,98 @@ const ReflowCursor = struct { repeat, skip_next, } { - // Copy cell contents. - switch (cell.content_tag) { - .codepoint, - .codepoint_grapheme, - => switch (cell.wide) { - .narrow => self.page_cell.* = cell.*, + // Initialize self.page_cell with basic, unmanaged memory contents. + { + // This must not fail because we want to make sure we atomically + // setup our page cell to be valid. + errdefer comptime unreachable; - .wide => if (self.page.size.cols > 1) { - if (self.x == self.page.size.cols - 1) { - // If there's a wide character in the last column of - // the reflowed page then we need to insert a spacer - // head and wrap before handling it. - self.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, - .wide = .spacer_head, - }; + // Copy cell contents. + switch (cell.content_tag) { + .codepoint, + .codepoint_grapheme, + => switch (cell.wide) { + .narrow => self.page_cell.* = cell.*, - // Move to the next row (this sets pending wrap - // which will cause us to wrap on the next - // iteration). + .wide => if (self.page.size.cols > 1) { + if (self.x == self.page.size.cols - 1) { + // If there's a wide character in the last column of + // the reflowed page then we need to insert a spacer + // head and wrap before handling it. + self.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; + + // Move to the next row (this sets pending wrap + // which will cause us to wrap on the next + // iteration). + self.cursorForward(); + + // Decrement the source position so that when we + // loop we'll process this source cell again, + // since we can't copy it into a spacer head. + return .repeat; + } else { + self.page_cell.* = cell.*; + } + } else { + // Edge case, when resizing to 1 column, wide + // characters are just destroyed and replaced + // with empty narrow cells. + self.page_cell.content.codepoint = 0; + self.page_cell.wide = .narrow; self.cursorForward(); - // Decrement the source position so that when we - // loop we'll process this source cell again, - // since we can't copy it into a spacer head. - return .repeat; - } else { + // Skip spacer tail so it doesn't cause a wrap. + return .skip_next; + }, + + .spacer_tail => if (self.page.size.cols > 1) { self.page_cell.* = cell.*; - } - } else { - // Edge case, when resizing to 1 column, wide - // characters are just destroyed and replaced - // with empty narrow cells. - self.page_cell.content.codepoint = 0; - self.page_cell.wide = .narrow; - self.cursorForward(); + } else { + // Edge case, when resizing to 1 column, wide + // characters are just destroyed and replaced + // with empty narrow cells, so we should just + // discard any spacer tails. + return .success; + }, - // Skip spacer tail so it doesn't cause a wrap. - return .skip_next; + .spacer_head => { + // Spacer heads should be ignored. If we need a + // spacer head in our reflowed page, it is added + // when processing the wide cell it belongs to. + return .success; + }, }, - .spacer_tail => if (self.page.size.cols > 1) { + .bg_color_palette, + .bg_color_rgb, + => { + // These are guaranteed to have no style or grapheme + // data associated with them so we can fast path them. self.page_cell.* = cell.*; - } else { - // Edge case, when resizing to 1 column, wide - // characters are just destroyed and replaced - // with empty narrow cells, so we should just - // discard any spacer tails. + self.cursorForward(); return .success; }, + } - .spacer_head => { - // Spacer heads should be ignored. If we need a - // spacer head in our reflowed page, it is added - // when processing the wide cell it belongs to. - return .success; - }, - }, + // These will create issues by trying to clone managed memory that + // isn't set if the current dst row needs to be moved to a new page. + // They'll be fixed once we do properly copy the relevant memory. + self.page_cell.content_tag = .codepoint; + self.page_cell.hyperlink = false; + self.page_cell.style_id = stylepkg.default_id; - .bg_color_palette, - .bg_color_rgb, - => { - // These are guaranteed to have no style or grapheme - // data associated with them so we can fast path them. - self.page_cell.* = cell.*; - self.cursorForward(); - return .success; - }, + if (comptime build_options.kitty_graphics) { + // Copy Kitty virtual placeholder status + if (cell.codepoint() == kitty.graphics.unicode.placeholder) { + self.page_row.kitty_virtual_placeholder = true; + } + } } - // These will create issues by trying to clone managed memory that - // isn't set if the current dst row needs to be moved to a new page. - // They'll be fixed once we do properly copy the relevant memory. - self.page_cell.content_tag = .codepoint; - 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, @@ -1397,12 +1411,10 @@ const ReflowCursor = struct { // self.page_cell.wide, // }); - if (comptime build_options.kitty_graphics) { - // Copy Kitty virtual placeholder status - if (cell.codepoint() == kitty.graphics.unicode.placeholder) { - self.page_row.kitty_virtual_placeholder = true; - } - } + // From this point on we're moving on to failable, managed memory. + // If we reach an error, we do the minimal cleanup necessary to + // not leave dangling memory but otherwise we gracefully degrade + // into some functional but not strictly correct cell. // Copy grapheme data. if (cell.content_tag == .codepoint_grapheme) { @@ -1455,8 +1467,8 @@ const ReflowCursor = struct { } // Unsafe builds we throw away grapheme data! - cell.content_tag = .codepoint; - cell.content = .{ .codepoint = 0xFFFD }; + self.page_cell.content_tag = .codepoint; + self.page_cell.content = .{ .codepoint = 0xFFFD }; }; } @@ -1548,7 +1560,6 @@ const ReflowCursor = struct { unreachable; } - cell.hyperlink = false; break :hyperlink; }; @@ -1576,7 +1587,6 @@ const ReflowCursor = struct { } dst_link2.free(self.page); - cell.hyperlink = false; break :hyperlink; }; } orelse src_id; @@ -1600,7 +1610,7 @@ const ReflowCursor = struct { // Unsafe builds we throw away hyperlink data! self.page.hyperlink_set.release(self.page.memory, dst_id); - cell.hyperlink = false; + self.page_cell.hyperlink = false; break :hyperlink; }; } @@ -1644,7 +1654,7 @@ const ReflowCursor = struct { unreachable; } - cell.style_id = stylepkg.default_id; + self.page_cell.style_id = stylepkg.default_id; break :style; }; } orelse cell.style_id; From c9d15949d85564aa47763de2da341ae6654f6e90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 08:22:53 -0800 Subject: [PATCH 484/605] terminal: on reflow OutOfSpace, move last row to new page and try again --- src/terminal/PageList.zig | 60 +++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 84518e8b7..710c0aaa7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1264,18 +1264,22 @@ const ReflowCursor = struct { error.OutOfMemory => return error.OutOfMemory, // We reached the capacity of a single page and can't - // add any more of some type of managed memory. - error.OutOfSpace => { - log.warn("OutOfSpace during reflow at src_y={} src_x={} dst_y={} dst_x={} cp={X}", .{ - src_y, - x, - self.y, - self.x, - cells[x].content.codepoint, - }); - - // TODO: Split the page + // add any more of some type of managed memory. When this + // happens we split out the current row we're working on + // into a new page and continue from there. + error.OutOfSpace => if (self.y == 0) { + // If we're already on the first-row, we can't split + // any further, so we just ignore bad cells and take + // corrupted (but valid) cell contents. + log.warn("reflowRow OutOfSpace on first row, discarding cell managed memory", .{}); x += 1; + self.cursorForward(); + } else { + // Move our last row to a new page. + try self.moveLastRowToNewPage(list, cap); + + // Do NOT increment x so that we retry writing + // the same existing cell. }, } } @@ -1683,7 +1687,7 @@ const ReflowCursor = struct { self: *ReflowCursor, list: *PageList, cap: Capacity, - ) !void { + ) Allocator.Error!void { assert(self.y == self.page.size.rows - 1); assert(!self.pending_wrap); @@ -1693,15 +1697,41 @@ const ReflowCursor = struct { const old_x = self.x; try self.cursorNewPage(list, cap); + assert(self.node != old_node); + assert(self.y == 0); // Restore the x position of the cursor. self.cursorAbsolute(old_x, 0); - // We expect to have enough capacity to clone the row. - try self.page.cloneRowFrom(old_page, self.page_row, old_row); + // Copy our old data. This should NOT fail because we have the + // capacity of the old page which already fits the data we requested. + self.page.cloneRowFrom( + old_page, + self.page_row, + old_row, + ) catch |err| { + log.err( + "error cloning single row for moveLastRowToNewPage err={}", + .{err}, + ); + @panic("unexpected copy row failure"); + }; + + // Move any tracked pins from that last row into this new node. + { + const pin_keys = list.tracked_pins.keys(); + for (pin_keys) |p| { + if (&p.node.data != old_page or + p.y != old_page.size.rows - 1) continue; + + p.node = self.node; + p.y = self.y; + // p.x remains the same since we're copying the row as-is + } + } // Clear the row from the old page and truncate it. - old_page.clearCells(old_row, 0, self.page.size.cols); + old_page.clearCells(old_row, 0, old_page.size.cols); old_page.size.rows -= 1; // If that was the last row in that page From ec0a1500984403eefffbbf7528cf22230b08cfb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 09:12:41 -0800 Subject: [PATCH 485/605] terminal: moveLastRowToNewPage needs to fix up total_rows --- src/terminal/PageList.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 710c0aaa7..69edcafb1 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1696,10 +1696,18 @@ const ReflowCursor = struct { const old_row = self.page_row; const old_x = self.x; + // Our total row count never changes, because we're removing one + // row from the last page and moving it into a new page. + const old_total_rows = self.total_rows; + defer self.total_rows = old_total_rows; + try self.cursorNewPage(list, cap); assert(self.node != old_node); assert(self.y == 0); + // We have no cleanup for our old state from here on out. No failures! + errdefer comptime unreachable; + // Restore the x position of the cursor. self.cursorAbsolute(old_x, 0); @@ -1752,7 +1760,7 @@ const ReflowCursor = struct { const old_y = self.y; const old_total_rows = self.total_rows; - self.* = .init(node: { + const node = node: { // Pause integrity checks because the total row count won't // be correct during a reflow. list.pauseIntegrityChecks(true); @@ -1761,8 +1769,12 @@ const ReflowCursor = struct { self.node, adjustment, ); - }); + }; + // We must not fail after this, we've modified our self.node + // and we need to fix it up. + errdefer comptime unreachable; + self.* = .init(node); self.cursorAbsolute(old_x, old_y); self.total_rows = old_total_rows; } @@ -1824,7 +1836,6 @@ const ReflowCursor = struct { list.pages.insertAfter(self.node, node); self.* = .init(node); - self.new_rows = new_rows; } From f89b6433c2470f363609ffbf3fbc4fbd2dd99c58 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 16 Jan 2026 16:26:54 -0500 Subject: [PATCH 486/605] osc: add failing test for osc 133 parsing trailing ; This actually causes a crash lol, bad indexing of a slice with `1..0` because it's `key.len + 1 ..` and the length is `0`. --- src/terminal/osc/parsers/semantic_prompt.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 510fe3447..811b6d055 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -348,6 +348,18 @@ test "OSC 133: prompt_start with special_key empty" { try testing.expect(cmd.prompt_start.special_key == false); } +test "OSC 133: prompt_start with trailing ;" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); +} + test "OSC 133: prompt_start with click_events true" { const testing = std.testing; From 4e5c1dcdc17d67757d32faeb2bffe0e27376db00 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 16 Jan 2026 16:29:39 -0500 Subject: [PATCH 487/605] osc: fix bad indexing for empty kv in semantic prompt --- src/terminal/osc/parsers/semantic_prompt.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 811b6d055..ac7298267 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -186,6 +186,15 @@ const SemanticPromptKVIterator = struct { break :kv kv; }; + // If we have an empty item, we return an empty key and value. + // + // This allows for trailing semicolons, but also lets us parse + // (or rather, ignore) empty fields; for example `a=b;;e=f`. + if (kv.len < 1) return .{ + .key = kv, + .value = kv, + }; + const key = key: { const index = std.mem.indexOfScalar(u8, kv, '=') orelse break :key kv; kv[index] = 0; From 85a3d623b2f3452a49eb634c10e46ba6746d0cd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 13:42:34 -0800 Subject: [PATCH 488/605] terminal: increaseCapacity should reach maxInt before overflow --- src/terminal/PageList.zig | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 69edcafb1..b027f54d4 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3003,8 +3003,16 @@ pub fn increaseCapacity( // overflow it means we're out of space in this dimension, // since pages can take up to their maxInt capacity in any // category. - const new = std.math.mul(Int, old, 2) catch |err| { + const new = std.math.mul( + Int, + old, + 2, + ) catch |err| overflow: { comptime assert(@TypeOf(err) == error{Overflow}); + // Our final doubling would overflow since maxInt is + // 2^N - 1 for an unsignged int of N bits. So, if we overflow + // and we haven't used all the bits, use all the bits. + if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); return error.OutOfSpace; }; @field(cap, field_name) = new; @@ -6844,18 +6852,19 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - // Keep increasing styles capacity until we're at more than half of max + // Keep increasing styles capacity until we get OutOfSpace const max_styles = std.math.maxInt(size.StyleCountInt); - const half_max = max_styles / 2 + 1; - while (s.pages.first.?.data.capacity.styles < half_max) { - _ = try s.increaseCapacity(s.pages.first.?, .styles); + while (true) { + _ = s.increaseCapacity( + s.pages.first.?, + .styles, + ) catch |err| { + // Before OutOfSpace, we should have reached maxInt + try testing.expectEqual(error.OutOfSpace, err); + try testing.expectEqual(max_styles, s.pages.first.?.data.capacity.styles); + break; + }; } - - // Now increaseCapacity should fail with OutOfSpace - try testing.expectError( - error.OutOfSpace, - s.increaseCapacity(s.pages.first.?, .styles), - ); } test "PageList increaseCapacity after col shrink" { From 69066200ef24985f4f5214f0adc38ce6c40cb975 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 16 Jan 2026 16:58:58 -0500 Subject: [PATCH 489/605] fix: handle double tmux control mode exit command --- src/termio/stream_handler.zig | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index c647e3ba2..082a0fa10 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -398,11 +398,16 @@ pub const StreamHandler = struct { break :tmux; }, - .exit => if (self.tmux_viewer) |viewer| { - // Free our viewer state - viewer.deinit(); - self.alloc.destroy(viewer); - self.tmux_viewer = null; + .exit => { + // Free our viewer state if we have one + if (self.tmux_viewer) |viewer| { + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + } + + // And always break since we assert below + // that we're not handling an exit command. break :tmux; }, From 442a395850f30a2b6fc455414359368dfbefb9fc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 14:24:02 -0800 Subject: [PATCH 490/605] terminal: ensure our std_capacity fits within max page size --- src/terminal/PageList.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b027f54d4..d8354916b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -292,7 +292,8 @@ fn initialCapacity(cols: size.CellCountInt) Capacity { comptime { var cap = std_capacity; cap.cols = std.math.maxInt(size.CellCountInt); - _ = Page.layout(cap); + const layout = Page.layout(cap); + assert(layout.total_size <= size.max_page_size); } if (std_capacity.adjust( From df35363b15ef1c1284ab0ad3615e43fec4777316 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 14:35:14 -0800 Subject: [PATCH 491/605] terminal: more robust handling of max_page_size not 100% --- src/terminal/PageList.zig | 19 ++++++++++++++++++- src/terminal/page.zig | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index d8354916b..3e6a39a3f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -414,6 +414,10 @@ fn initPages( const pooled = layout.total_size <= std_size; const page_alloc = pool.pages.arena.child_allocator; + // Guaranteed by comptime checks in initialCapacity but + // redundant here for safety. + assert(layout.total_size <= size.max_page_size); + var rem = rows; while (rem > 0) { const node = try pool.nodes.create(); @@ -2091,7 +2095,7 @@ fn resizeWithoutReflowGrowCols( ) catch |err| err: { comptime assert(@TypeOf(err) == error{OutOfMemory}); - // We verify all maxed out page layouts work. + // We verify all maxed out page layouts don't overflow, var cap = page.capacity; cap.cols = cols; @@ -3017,6 +3021,14 @@ pub fn increaseCapacity( return error.OutOfSpace; }; @field(cap, field_name) = new; + + // If our capacity exceeds the maximum page size, treat it + // as an OutOfSpace because things like page splitting will + // help. + const layout = Page.layout(cap); + if (layout.total_size > size.max_page_size) { + return error.OutOfSpace; + } }, }; @@ -3091,6 +3103,11 @@ inline fn createPageExt( const pooled = layout.total_size <= std_size; const page_alloc = pool.pages.arena.child_allocator; + // It would be better to encode this into the Zig error handling + // system but that is a big undertaking and we only have a few + // centralized call sites so it is handled on its own currently. + assert(layout.total_size <= size.max_page_size); + // Our page buffer comes from our standard memory pool if it // is within our standard size since this is what the pool // dispenses. Otherwise, we use the heap allocator to allocate. diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 1150027a4..075f03b57 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -2046,6 +2046,8 @@ test "Page.layout can take a maxed capacity" { @field(cap, field.name) = std.math.maxInt(field.type); } + // Note that a max capacity will exceed our max_page_size so we + // can't init a page with it, but it should layout. _ = Page.layout(cap); } From 464c31328e6ced1c8bbcafe42aac67097d7c505e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 16 Jan 2026 20:43:42 -0800 Subject: [PATCH 492/605] terminal: grow prune check should not prune if required for active Fixes #10352 The bug was that non-standard pages would mix the old `growRequiredForActive` check and make our active area insufficient in the PageList. But, since scrollbars now require we have a cached `total_rows` that our safety checks always verify, we can remove the old linked list traversal and switch to some simple math in general across all page sizes. --- src/terminal/PageList.zig | 104 ++++++++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 22 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 3e6a39a3f..c5897b7e7 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2812,18 +2812,6 @@ pub fn maxSize(self: *const PageList) usize { return @max(self.explicit_max_size, self.min_max_size); } -/// Returns true if we need to grow into our active area. -inline fn growRequiredForActive(self: *const PageList) bool { - var rows: usize = 0; - var page = self.pages.last; - while (page) |p| : (page = p.prev) { - rows += p.data.size.rows; - if (rows >= self.rows) return false; - } - - return true; -} - /// Grow the active area by exactly one row. /// /// This may allocate, but also may not if our current page has more @@ -2864,16 +2852,22 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { self.pages.first != self.pages.last and self.page_size + PagePool.item_size > self.maxSize()) prune: { - // If we need to add more memory to ensure our active area is - // satisfied then we do not prune. - if (self.growRequiredForActive()) break :prune; - const first = self.pages.popFirst().?; assert(first != last); // Decrease our total row count from the pruned page self.total_rows -= first.data.size.rows; + // If our total row count is now less than our required + // rows then we can't prune. The "+ 1" is because we'll add one + // more row below. + if (self.total_rows + 1 < self.rows) { + self.pages.prepend(first); + assert(self.pages.first == first); + self.total_rows += first.data.size.rows; + break :prune; + } + // If we have a pin viewport cache then we need to update it. if (self.viewport == .pin) viewport: { if (self.viewport_pin_row_offset) |*v| { @@ -2902,12 +2896,8 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { } self.viewport_pin.garbage = false; - // If our first node has non-standard memory size, we can't reuse - // it. This is because our initBuf below would change the underlying - // memory length which would break our memory free outside the pool. - // It is easiest in this case to prune the node. + // Non-standard pages can't be reused, just destroy them. if (first.data.memory.len > std_size) { - // Node is already removed so we can just destroy it. self.destroyNode(first); break :prune; } @@ -11585,7 +11575,7 @@ test "PageList grow reuses non-standard page without leak" { try testing.expect(s.pages.first.?.data.memory.len > std_size); // Verify we have enough rows for active area (so prune path isn't skipped) - try testing.expect(!s.growRequiredForActive()); + try testing.expect(s.totalRows() >= s.rows); // Verify last page is full (so grow will need to allocate/reuse) try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows); @@ -11619,3 +11609,73 @@ test "PageList grow reuses non-standard page without leak" { try testing.expectEqual(0, tracked_pin.y); try testing.expect(tracked_pin.garbage); } + +test "PageList grow non-standard page prune protection" { + const testing = std.testing; + const alloc = testing.allocator; + + // This test specifically verifies the fix for the bug where pruning a + // non-standard page would cause totalRows() < self.rows. + // + // Bug trigger conditions (all must be true simultaneously): + // 1. first page is non-standard (memory.len > std_size) + // 2. page_size + PagePool.item_size > maxSize() (triggers prune consideration) + // 3. pages.first != pages.last (have multiple pages) + // 4. total_rows >= self.rows (have enough rows for active area) + // 5. total_rows - first.size.rows + 1 < self.rows (prune would lose too many) + + // This is kind of magic and likely depends on std_size. + const rows_count = 600; + var s = try init(alloc, 80, rows_count, std_size); + defer s.deinit(); + + // Make the first page non-standard + while (s.pages.first.?.data.memory.len <= std_size) { + _ = try s.increaseCapacity( + s.pages.first.?, + .grapheme_bytes, + ); + } + try testing.expect(s.pages.first.?.data.memory.len > std_size); + + const first_page_node = s.pages.first.?; + const first_page_cap = first_page_node.data.capacity.rows; + + // Fill first page to capacity + while (first_page_node.data.size.rows < first_page_cap) _ = try s.grow(); + + // Grow until we have a second page (first page fills up first) + var second_node: ?*List.Node = null; + while (s.pages.first == s.pages.last) second_node = try s.grow(); + try testing.expect(s.pages.first != s.pages.last); + + // Fill the second page to capacity so that the next grow() triggers prune + const last_node = s.pages.last.?; + const second_cap = last_node.data.capacity.rows; + while (last_node.data.size.rows < second_cap) _ = try s.grow(); + + // Now the last page is full. The next grow must either: + // 1. Prune the first page and reuse it, OR + // 2. Allocate a new page + const total = s.totalRows(); + const would_remain = total - first_page_cap + 1; + + // Verify the bug condition is present: pruning first page would leave < rows + try testing.expect(would_remain < s.rows); + + // Verify prune path conditions are met + try testing.expect(s.pages.first != s.pages.last); + try testing.expect(s.page_size + PagePool.item_size > s.maxSize()); + try testing.expect(s.totalRows() >= s.rows); + + // Verify last page is at capacity (so grow must prune or allocate new) + try testing.expectEqual(second_cap, last_node.data.size.rows); + + // The next grow should trigger prune consideration. + // Without the fix, this would destroy the non-standard first page, + // leaving only second_cap + 1 rows, which is < self.rows. + _ = try s.grow(); + + // Verify the invariant holds - the fix prevents the destructive prune + try testing.expect(s.totalRows() >= s.rows); +} From 73a8d64b8a0410f3f57225bae52eeb8c7f0f0bbb Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:16:59 +0000 Subject: [PATCH 493/605] 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 c3e2de9f8..d5c06259a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz", - .hash = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", + .hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 022a7401e..b12216bd9 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV": { + "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz", - "hash": "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", + "hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index d845ea10e..430619e74 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV"; + name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz"; - hash = "sha256-bWKQxRggz/ZLr6w0Zt/hTnnAAb13VQWV70ScCsNFIZk="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz"; + hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 0eeb7c5f1..72597a650 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 7749eb67e..3e2b1e26d 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251229-150532-f279991.tgz", - "dest": "vendor/p/N-V-__8AAIdIAwAO4ro1DOaG7QTFq3ewrTQIViIKJ3lKY6lV", - "sha256": "6d6290c51820cff64bafac3466dfe14e79c001bd77550595ef449c0ac3452199" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", + "dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7", + "sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5" }, { "type": "archive", From 5423d64c6aba7c5e160a076c808cb72b39a092e3 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 17 Jan 2026 20:13:05 -0500 Subject: [PATCH 494/605] ssh-cache: use AtomicFile to write the cache file We previously wrote our new cache file into a temporary directory and the (atomically) renamed it to the canonical cache file path. This rename operation unfortunately only works when both files are on the same file system, and that's not always the case (e.g. when $TMPDIR is on its own file system). Instead, we can use Zig's AtomicFile to safely perform this operation inside of the cache directory. There's a new risk of a crash leaving the temporary file around in this directory (and not getting cleaned up like $TMPDIR-based files), but the probability is low and those files will only be readable by the creating user (mode 0o600). There's a new test cash that verifies the expected AtomicFile clean up behavior. I also switched the file-oriented tests to use testing.tmpDir rather than using our application-level TempDir type. --- src/cli/ssh-cache/DiskCache.zig | 75 +++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 62620ecb0..6214d0429 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -9,7 +9,6 @@ const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const internal_os = @import("../../os/main.zig"); const xdg = internal_os.xdg; -const TempDir = internal_os.TempDir; const Entry = @import("Entry.zig"); // 512KB - sufficient for approximately 10k entries @@ -125,7 +124,7 @@ pub fn add( break :update .updated; }; - try self.writeCacheFile(alloc, entries, null); + try self.writeCacheFile(entries, null); return result; } @@ -166,7 +165,7 @@ pub fn remove( alloc.free(kv.value.terminfo_version); } - try self.writeCacheFile(alloc, entries, null); + try self.writeCacheFile(entries, null); } /// Check if a hostname exists in the cache. @@ -209,32 +208,30 @@ fn fixupPermissions(file: std.fs.File) (std.fs.File.StatError || std.fs.File.Chm fn writeCacheFile( self: DiskCache, - alloc: Allocator, entries: std.StringHashMap(Entry), expire_days: ?u32, ) !void { - var td: TempDir = try .init(); - defer td.deinit(); + const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; + const cache_basename = std.fs.path.basename(self.path); - const tmp_file = try td.dir.createFile("ssh-cache", .{ .mode = 0o600 }); - defer tmp_file.close(); - const tmp_path = try td.dir.realpathAlloc(alloc, "ssh-cache"); - defer alloc.free(tmp_path); + var dir = try std.fs.cwd().openDir(cache_dir, .{}); + defer dir.close(); var buf: [1024]u8 = undefined; - var writer = tmp_file.writer(&buf); + var atomic_file = try dir.atomicFile(cache_basename, .{ + .mode = 0o600, + .write_buffer = &buf, + }); + defer atomic_file.deinit(); + var iter = entries.iterator(); while (iter.next()) |kv| { // Only write non-expired entries if (kv.value_ptr.isExpired(expire_days)) continue; - try kv.value_ptr.format(&writer.interface); + try kv.value_ptr.format(&atomic_file.file_writer.interface); } - // Don't forget to flush!! - try writer.interface.flush(); - - // Atomic replace - try std.fs.renameAbsolute(tmp_path, self.path); + try atomic_file.finish(); } /// List all entries in the cache. @@ -382,16 +379,16 @@ test "disk cache clear" { const alloc = testing.allocator; // Create our path - var td: TempDir = try .init(); - defer td.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); var buf: [4096]u8 = undefined; { - var file = try td.dir.createFile("cache", .{}); + var file = try tmp.dir.createFile("cache", .{}); defer file.close(); var file_writer = file.writer(&buf); try file_writer.interface.writeAll("HELLO!"); } - const path = try td.dir.realpathAlloc(alloc, "cache"); + const path = try tmp.dir.realpathAlloc(alloc, "cache"); defer alloc.free(path); // Setup our cache @@ -401,7 +398,7 @@ test "disk cache clear" { // Verify the file is gone try testing.expectError( error.FileNotFound, - td.dir.openFile("cache", .{}), + tmp.dir.openFile("cache", .{}), ); } @@ -410,18 +407,18 @@ test "disk cache operations" { const alloc = testing.allocator; // Create our path - var td: TempDir = try .init(); - defer td.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); var buf: [4096]u8 = undefined; { - var file = try td.dir.createFile("cache", .{}); + var file = try tmp.dir.createFile("cache", .{}); defer file.close(); var file_writer = file.writer(&buf); const writer = &file_writer.interface; try writer.writeAll("HELLO!"); try writer.flush(); } - const path = try td.dir.realpathAlloc(alloc, "cache"); + const path = try tmp.dir.realpathAlloc(alloc, "cache"); defer alloc.free(path); // Setup our cache @@ -453,6 +450,32 @@ test "disk cache operations" { ); } +test "disk cache cleans up temp files" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com")); + try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org")); + + // Verify only the cache file exists and no temp files left behind + var count: usize = 0; + var iter = tmp.dir.iterate(); + while (try iter.next()) |entry| { + count += 1; + try testing.expectEqualStrings("cache", entry.name); + } + try testing.expectEqual(1, count); +} + test isValidHost { const testing = std.testing; From b0c868811d8233d33f8efb48dd4f2f558951a043 Mon Sep 17 00:00:00 2001 From: shivaduke28 Date: Sun, 18 Jan 2026 16:22:11 +0900 Subject: [PATCH 495/605] use underline instead of inverting colors for preedit text --- src/renderer/generic.zig | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 5fa3432a6..4181badc3 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -3370,10 +3370,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { screen_bg: terminal.color.RGB, screen_fg: terminal.color.RGB, ) !void { - // Preedit is rendered inverted - const bg = screen_fg; - const fg = screen_bg; - // Render the glyph for our preedit text const render_ = self.font_grid.renderCodepoint( self.alloc, @@ -3392,11 +3388,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Add our opaque background cell self.cells.bgCell(coord.y, coord.x).* = .{ - bg.r, bg.g, bg.b, 255, + screen_bg.r, screen_bg.g, screen_bg.b, 255, }; if (cp.wide and coord.x < self.cells.size.columns - 1) { self.cells.bgCell(coord.y, coord.x + 1).* = .{ - bg.r, bg.g, bg.b, 255, + screen_bg.r, screen_bg.g, screen_bg.b, 255, }; } @@ -3404,7 +3400,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try self.cells.add(self.alloc, .text, .{ .atlas = .grayscale, .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, - .color = .{ fg.r, fg.g, fg.b, 255 }, + .color = .{ screen_fg.r, screen_fg.g, screen_fg.b, 255 }, .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, .glyph_size = .{ render.glyph.width, render.glyph.height }, .bearings = .{ @@ -3412,6 +3408,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @intCast(render.glyph.offset_y), }, }); + + // Add underline + try self.addUnderline(@intCast(coord.x), @intCast(coord.y), .single, screen_fg, 255); + if (cp.wide and coord.x < self.cells.size.columns - 1) { + try self.addUnderline(@intCast(coord.x + 1), @intCast(coord.y), .single, screen_fg, 255); + } } /// Sync the atlas data to the given texture. This copies the bytes From c8f56ddaf8cd9b909de471f88d5721ee7a5ee83a Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Sun, 18 Jan 2026 10:08:51 -0800 Subject: [PATCH 496/605] feat(macos): if the search box is empty directly close the box --- macos/Sources/Ghostty/Surface View/SurfaceView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 524cb1298..a0e735715 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -431,7 +431,12 @@ extension Ghostty { } #if canImport(AppKit) .onExitCommand { - Ghostty.moveFocus(to: surfaceView) + if searchState.needle.isEmpty { + Ghostty.moveFocus(to: surfaceView) + onClose() + } else { + Ghostty.moveFocus(to: surfaceView) + } } #endif .backport.onKeyPress(.return) { modifiers in From 3ee30058ab8eeeefa9b6503693abd0f3e1328ae7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Jan 2026 14:43:19 -0800 Subject: [PATCH 497/605] terminal: increaseCapacity should preserve dirty flag This never caused any known issues, but it's a bug! `increaseCapacity` should produce a node with identical contents, just more capacity. We were forgetting to copy over the dirty flag. I looked back at `adjustCapacity` and it also didn't preserve the dirty flag so presumably downstream consumers have been handling this case manually. But, I think semantically it makes sense for `increaseCapacity` to preserve the dirty flag. This bug was found by AI (while I was doing another task). I fixed it and wrote the test by hand though. --- src/terminal/PageList.zig | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c5897b7e7..ea6168caa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3045,6 +3045,9 @@ pub fn increaseCapacity( @panic("unexpected clone failure"); }; + // Preserve page-level dirty flag (cloneFrom only copies row data) + new_page.dirty = page.dirty; + // Must not fail after this because the operations we do after this // can't be recovered. errdefer comptime unreachable; @@ -6942,6 +6945,37 @@ test "PageList increaseCapacity multi-page" { ); } +test "PageList increaseCapacity preserves dirty flag" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + + // Set page dirty flag and mark some rows as dirty + const page = &s.pages.first.?.data; + page.dirty = true; + + const rows = page.rows.ptr(page.memory); + rows[0].dirty = true; + rows[1].dirty = false; + rows[2].dirty = true; + rows[3].dirty = false; + + // Increase capacity + const new_node = try s.increaseCapacity(s.pages.first.?, .styles); + + // The page dirty flag should be preserved + try testing.expect(new_node.data.dirty); + + // Row dirty flags should be preserved + const new_rows = new_node.data.rows.ptr(new_node.data.memory); + try testing.expect(new_rows[0].dirty); + try testing.expect(!new_rows[1].dirty); + try testing.expect(new_rows[2].dirty); + try testing.expect(!new_rows[3].dirty); +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; From 836d794b9e686cf17ec419c04dd2ad94bef16037 Mon Sep 17 00:00:00 2001 From: Tobias Kohlbau Date: Tue, 25 Nov 2025 22:19:57 +0100 Subject: [PATCH 498/605] termio: report color scheme synchronously The reporting of color scheme was handled asynchronously by queuing a handler in the surface. This could lead to race conditions where the DSR is reported after subsequent VT sequences. Fixes #5922 --- src/Surface.zig | 24 +----------------------- src/apprt/surface.zig | 5 ----- src/termio/Termio.zig | 21 +++++++++++++++++++++ src/termio/Thread.zig | 1 + src/termio/message.zig | 6 ++++++ src/termio/stream_handler.zig | 6 +++--- 6 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4103b91fb..5ca75d865 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1073,8 +1073,6 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), - .report_color_scheme => |force| self.reportColorScheme(force), - .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), @@ -1386,26 +1384,6 @@ fn passwordInput(self: *Surface, v: bool) !void { try self.queueRender(); } -/// Sends a DSR response for the current color scheme to the pty. If -/// force is false then we only send the response if the terminal mode -/// 2031 is enabled. -fn reportColorScheme(self: *Surface, force: bool) void { - if (!force) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - if (!self.renderer_state.terminal.modes.get(.report_color_scheme)) { - return; - } - } - - const output = switch (self.config_conditional_state.theme) { - .light => "\x1B[?997;2n", - .dark => "\x1B[?997;1n", - }; - - self.queueIo(.{ .write_stable = output }, .unlocked); -} - fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { // IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE // to access anything other than values that never change on the surface. @@ -5039,7 +5017,7 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { self.notifyConfigConditionalState(); // If mode 2031 is on, then we report the change live. - self.reportColorScheme(false); + self.queueIo(.{ .color_scheme_report = .{ .force = false } }, .unlocked); } pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordinate { diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index be2f59149..5c25281c8 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -63,11 +63,6 @@ pub const Message = union(enum) { /// Health status change for the renderer. renderer_health: renderer.Health, - /// Report the color scheme. The bool parameter is whether to force or not. - /// If force is true, the color scheme should be reported even if mode - /// 2031 is not set. - report_color_scheme: bool, - /// Tell the surface to present itself to the user. This may require raising /// a window and switching tabs. present_surface: void, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 7263418a7..a1bcea6d3 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -165,6 +165,7 @@ pub const DerivedConfig = struct { osc_color_report_format: configpkg.Config.OSCColorReportFormat, clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, + conditional_state: configpkg.ConditionalState, pub fn init( alloc_gpa: Allocator, @@ -185,6 +186,7 @@ pub const DerivedConfig = struct { .osc_color_report_format = config.@"osc-color-report-format", .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), + .conditional_state = config._conditional_state, // This has to be last so that we copy AFTER the arena allocations // above happen (Zig assigns in order). @@ -712,6 +714,25 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { } } +/// Sends a DSR response for the current color scheme to the pty. +pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + try self.colorSchemeReportLocked(td, force); +} + +pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !void { + if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { + return; + } + const output = switch (self.config.conditional_state.theme) { + .light => "\x1B[?997;2n", + .dark => "\x1B[?997;1n", + }; + try self.queueWrite(td, output, false); +} + /// ThreadData is the data created and stored in the termio thread /// when the thread is started and destroyed when the thread is /// stopped. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index b111d5a52..6aa5e1c26 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -311,6 +311,7 @@ fn drainMailbox( log.debug("mailbox message={s}", .{@tagName(message)}); switch (message) { + .color_scheme_report => |v| try io.colorSchemeReport(data, v.force), .crash => @panic("crash request, crashing intentionally"), .change_config => |config| { defer config.alloc.destroy(config.ptr); diff --git a/src/termio/message.zig b/src/termio/message.zig index f78da2058..d7a59bf5e 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -16,6 +16,12 @@ pub const Message = union(enum) { /// in the future. pub const WriteReq = MessageData(u8, 38); + /// Request a color scheme report is sent to the pty. + color_scheme_report: struct { + /// Force write the current color scheme + force: bool, + }, + /// Purposely crash the renderer. This is used for testing and debugging. /// See the "crash" binding action. crash: void, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 082a0fa10..75f6da57e 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -119,7 +119,7 @@ pub const StreamHandler = struct { }; // The config could have changed any of our colors so update mode 2031 - self.surfaceMessageWriter(.{ .report_color_scheme = false }); + self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); } inline fn surfaceMessageWriter( @@ -871,7 +871,7 @@ pub const StreamHandler = struct { self.messageWriter(msg); }, - .color_scheme => self.surfaceMessageWriter(.{ .report_color_scheme = true }), + .color_scheme => self.messageWriter(.{ .color_scheme_report = .{ .force = true } }), } } @@ -956,7 +956,7 @@ pub const StreamHandler = struct { try self.setMouseShape(.text); // Reset resets our palette so we report it for mode 2031. - self.surfaceMessageWriter(.{ .report_color_scheme = false }); + self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); // Clear the progress bar self.progressReport(.{ .state = .remove }); From 6db4e437ca3facec1ecfdb264ba5025d8fe51af3 Mon Sep 17 00:00:00 2001 From: Steven Lu Date: Mon, 19 Jan 2026 17:34:34 +0700 Subject: [PATCH 499/605] splits: make resize_split and toggle_split_zoom non-performable with single pane When a tab contains only a single split, resize_split and toggle_split_zoom actions now return false (not performed). This allows keybindings marked with `performable: true` to pass the event through to the terminal program. The performable flag causes unperformed actions to be treated as if the binding didn't exist, so the key event is sent to the terminal instead of being consumed. - Add isSplit() helper to SplitTree to detect single-pane vs split state - Update GTK resizeSplit/toggleSplitZoom to return false when single pane - Update macOS resizeSplit/toggleSplitZoom to return Bool and check isSplit - Add unit test for isSplit method --- macos/Sources/Ghostty/Ghostty.App.swift | 36 +++++++++++++++------- src/apprt/gtk/class/application.zig | 18 ++++++++++- src/datastruct/split_tree.zig | 41 +++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 89a3ddd7d..5dbb6bea6 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -505,13 +505,13 @@ extension Ghostty { return gotoWindow(app, target: target, direction: action.action.goto_window) case GHOSTTY_ACTION_RESIZE_SPLIT: - resizeSplit(app, target: target, resize: action.action.resize_split) + return resizeSplit(app, target: target, resize: action.action.resize_split) case GHOSTTY_ACTION_EQUALIZE_SPLITS: equalizeSplits(app, target: target) case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: - toggleSplitZoom(app, target: target) + return toggleSplitZoom(app, target: target) case GHOSTTY_ACTION_INSPECTOR: controlInspector(app, target: target, mode: action.action.inspector) @@ -1244,16 +1244,21 @@ extension Ghostty { private static func resizeSplit( _ app: ghostty_app_t, target: ghostty_target_s, - resize: ghostty_action_resize_split_s) { + resize: ghostty_action_resize_split_s) -> Bool { switch (target.tag) { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("resize split does nothing with an app target") - return + return false case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return } - guard let surfaceView = self.surfaceView(from: surface) else { return } - guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return } + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false } + + // If the window has no splits, the action is not performable + guard controller.surfaceTree.isSplit else { return false } + + guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return false } NotificationCenter.default.post( name: Notification.didResizeSplit, object: surfaceView, @@ -1262,9 +1267,11 @@ extension Ghostty { Notification.ResizeSplitAmountKey: resize.amount, ] ) + return true default: assertionFailure() + return false } } @@ -1292,23 +1299,30 @@ extension Ghostty { private static func toggleSplitZoom( _ app: ghostty_app_t, - target: ghostty_target_s) { + target: ghostty_target_s) -> Bool { switch (target.tag) { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle split zoom does nothing with an app target") - return + return false case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return } - guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false } + + // If the window has no splits, the action is not performable + guard controller.surfaceTree.isSplit else { return false } + NotificationCenter.default.post( name: Notification.didToggleSplitZoom, object: surfaceView ) + return true default: assertionFailure() + return false } } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index bc83c09a4..0a336fd79 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2400,10 +2400,14 @@ const Action = struct { SplitTree, surface.as(gtk.Widget), ) orelse { - log.warn("surface is not in a split tree, ignoring goto_split", .{}); + log.warn("surface is not in a split tree, ignoring resize_split", .{}); return false; }; + // If the tree has no splits (only one leaf), this action is not performable. + // This allows the key event to pass through to the terminal. + if (!tree.isSplit()) return false; + return tree.resize( switch (value.direction) { .up => .up, @@ -2550,6 +2554,18 @@ const Action = struct { .surface => |core| { // TODO: pass surface ID when we have that const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring toggle_split_zoom", .{}); + return false; + }; + + // If the tree has no splits (only one leaf), this action is not performable. + // This allows the key event to pass through to the terminal. + if (!tree.isSplit()) return false; + return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0; }, } diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index be24187f6..93daa77e9 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -170,6 +170,19 @@ pub fn SplitTree(comptime V: type) type { return self.nodes.len == 0; } + /// Returns true if this tree has more than one split (i.e., the root + /// is a split node). This is useful for determining if actions like + /// resize_split or toggle_split_zoom are performable. + pub fn isSplit(self: *const Self) bool { + // An empty tree is not split. + if (self.isEmpty()) return false; + // The root node is at index 0. If it's a split, we have multiple splits. + return switch (self.nodes[0]) { + .split => true, + .leaf => false, + }; + } + /// An iterator over all the views in the tree. pub fn iterator( self: *const Self, @@ -1326,6 +1339,34 @@ const TestView = struct { } }; +test "SplitTree: isSplit" { + const testing = std.testing; + const alloc = testing.allocator; + + // Empty tree should not be split + var empty: TestTree = .empty; + defer empty.deinit(); + try testing.expect(!empty.isSplit()); + + // Single node tree should not be split + var v1: TestView = .{ .label = "A" }; + var single: TestTree = try TestTree.init(alloc, &v1); + defer single.deinit(); + try testing.expect(!single.isSplit()); + + // Split tree should be split + var v2: TestView = .{ .label = "B" }; + var split = try single.split( + alloc, + .root, + .right, + 0.5, + &v2, + ); + defer split.deinit(); + try testing.expect(split.isSplit()); +} + test "SplitTree: empty tree" { const testing = std.testing; const alloc = testing.allocator; From 1daba40247432a20f14de3fe99ede98bad199b66 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 19 Jan 2026 10:53:54 -0600 Subject: [PATCH 500/605] osc 133: handle bare keys with no '=' Fixes #10379 --- src/terminal/osc/parsers/semantic_prompt.zig | 114 ++++++++++++++----- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index ac7298267..652fe34da 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -33,64 +33,69 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { return null; }; while (it.next()) |kv| { - if (std.mem.eql(u8, kv.key, "aid")) { + const key = kv.key orelse continue; + if (std.mem.eql(u8, key, "aid")) { parser.command.prompt_start.aid = kv.value; - } else if (std.mem.eql(u8, kv.key, "redraw")) redraw: { + } else if (std.mem.eql(u8, key, "redraw")) redraw: { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers // Kitty supports a "redraw" option for prompt_start. I can't find // this documented anywhere but can see in the code that this is used // by shell environments to tell the terminal that the shell will NOT // redraw the prompt so we should attempt to resize it. parser.command.prompt_start.redraw = (value: { - if (kv.value.len != 1) break :value null; - switch (kv.value[0]) { + const value = kv.value orelse break :value null; + if (value.len != 1) break :value null; + switch (value[0]) { '0' => break :value false, '1' => break :value true, else => break :value null, } }) orelse { - log.info("OSC 133 A: invalid redraw value: {s}", .{kv.value}); + log.info("OSC 133 A: invalid redraw value: {?s}", .{kv.value}); break :redraw; }; - } else if (std.mem.eql(u8, kv.key, "special_key")) redraw: { + } else if (std.mem.eql(u8, key, "special_key")) redraw: { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers parser.command.prompt_start.special_key = (value: { - if (kv.value.len != 1) break :value null; - switch (kv.value[0]) { + const value = kv.value orelse break :value null; + if (value.len != 1) break :value null; + switch (value[0]) { '0' => break :value false, '1' => break :value true, else => break :value null, } }) orelse { - log.info("OSC 133 A invalid special_key value: {s}", .{kv.value}); + log.info("OSC 133 A invalid special_key value: {?s}", .{kv.value}); break :redraw; }; - } else if (std.mem.eql(u8, kv.key, "click_events")) redraw: { + } else if (std.mem.eql(u8, key, "click_events")) redraw: { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers parser.command.prompt_start.click_events = (value: { - if (kv.value.len != 1) break :value null; - switch (kv.value[0]) { + const value = kv.value orelse break :value null; + if (value.len != 1) break :value null; + switch (value[0]) { '0' => break :value false, '1' => break :value true, else => break :value null, } }) orelse { - log.info("OSC 133 A invalid click_events value: {s}", .{kv.value}); + log.info("OSC 133 A invalid click_events value: {?s}", .{kv.value}); break :redraw; }; - } else if (std.mem.eql(u8, kv.key, "k")) k: { + } else if (std.mem.eql(u8, key, "k")) k: { // The "k" marks the kind of prompt, or "primary" if we don't know. // This can be used to distinguish between the first (initial) prompt, // a continuation, etc. - if (kv.value.len != 1) break :k; - parser.command.prompt_start.kind = switch (kv.value[0]) { + const value = kv.value orelse break :k; + if (value.len != 1) break :k; + parser.command.prompt_start.kind = switch (value[0]) { 'c' => .continuation, 's' => .secondary, 'r' => .right, 'i' => .primary, else => .primary, }; - } else log.info("OSC 133 A: unknown semantic prompt option: {s}", .{kv.key}); + } else log.info("OSC 133 A: unknown semantic prompt option: {?s}", .{kv.key}); } }, 'B' => prompt_end: { @@ -105,7 +110,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { return null; }; while (it.next()) |kv| { - log.info("OSC 133 B: unknown semantic prompt option: {s}", .{kv.key}); + log.info("OSC 133 B: unknown semantic prompt option: {?s}", .{kv.key}); } }, 'C' => end_of_input: { @@ -122,12 +127,13 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { return null; }; while (it.next()) |kv| { - if (std.mem.eql(u8, kv.key, "cmdline")) { - parser.command.end_of_input.cmdline = string_encoding.printfQDecode(kv.value) catch null; - } else if (std.mem.eql(u8, kv.key, "cmdline_url")) { - parser.command.end_of_input.cmdline = string_encoding.urlPercentDecode(kv.value) catch null; + const key = kv.key orelse continue; + if (std.mem.eql(u8, key, "cmdline")) { + parser.command.end_of_input.cmdline = if (kv.value) |value| string_encoding.printfQDecode(value) catch null else null; + } else if (std.mem.eql(u8, key, "cmdline_url")) { + parser.command.end_of_input.cmdline = if (kv.value) |value| string_encoding.urlPercentDecode(value) catch null else null; } else { - log.info("OSC 133 C: unknown semantic prompt option: {s}", .{kv.key}); + log.info("OSC 133 C: unknown semantic prompt option: {s}", .{key}); } } }, @@ -159,8 +165,8 @@ const SemanticPromptKVIterator = struct { string: []u8, pub const SemanticPromptKV = struct { - key: [:0]u8, - value: [:0]u8, + key: ?[:0]u8, + value: ?[:0]u8, }; pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!SemanticPromptKVIterator { @@ -186,17 +192,24 @@ const SemanticPromptKVIterator = struct { break :kv kv; }; - // If we have an empty item, we return an empty key and value. + // If we have an empty item, we return a null key and value. // // This allows for trailing semicolons, but also lets us parse // (or rather, ignore) empty fields; for example `a=b;;e=f`. if (kv.len < 1) return .{ - .key = kv, - .value = kv, + .key = null, + .value = null, }; const key = key: { - const index = std.mem.indexOfScalar(u8, kv, '=') orelse break :key kv; + const index = std.mem.indexOfScalar(u8, kv, '=') orelse { + // If there is no '=' return entire `kv` string as the key and + // a null value. + return .{ + .key = kv, + .value = null, + }; + }; kv[index] = 0; const key = kv[0..index :0]; break :key key; @@ -408,6 +421,36 @@ test "OSC 133: prompt_start with click_events empty" { try testing.expect(cmd.prompt_start.click_events == false); } +test "OSC 133: prompt_start with click_events bare key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;click_events"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + +test "OSC 133: prompt_start with invalid bare key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;barekey"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.aid == null); + try testing.expectEqual(.primary, cmd.prompt_start.kind); + try testing.expect(cmd.prompt_start.redraw == true); + try testing.expect(cmd.prompt_start.special_key == false); + try testing.expect(cmd.prompt_start.click_events == false); +} + test "OSC 133: end_of_command no exit code" { const testing = std.testing; @@ -713,3 +756,16 @@ test "OSC 133: end_of_input with cmdline_url 9" { try testing.expect(cmd == .end_of_input); try testing.expect(cmd.end_of_input.cmdline == null); } + +test "OSC 133: end_of_input with bare key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C;cmdline_url"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} From d67dd08a21bdacb1cca0fcb73cdff392083ca8b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Jan 2026 07:09:02 -0800 Subject: [PATCH 501/605] terminal: remap tracked pins in backfill path during resize Fixes #10369 When `resizeWithoutReflowGrowCols` copies rows to a previous page with spare capacity, tracked pins pointing to those rows were not being remapped. This left pins pointing to the original page which was subsequently destroyed. The fix adds pin remapping for rows copied to the previous page, matching the existing remapping logic for rows copied to new pages. I also added new integrity checks to verify that our tracked pins are always valid at points where internal operations complete. Thanks to @grishy for finding this! --- src/terminal/PageList.zig | 106 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ea6168caa..b4f51a7c9 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -485,10 +485,11 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { } const IntegrityError = error{ + PageSerialInvalid, TotalRowsMismatch, + TrackedPinInvalid, ViewportPinOffsetMismatch, ViewportPinInsufficientRows, - PageSerialInvalid, }; /// Verify the integrity of the PageList. This is expensive and should @@ -529,6 +530,11 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { return IntegrityError.TotalRowsMismatch; } + // Verify that all our tracked pins point to valid pages. + for (self.tracked_pins.keys()) |p| { + if (!self.pinIsValid(p.*)) return error.TrackedPinInvalid; + } + if (self.viewport == .pin) { // Verify that our viewport pin row offset is correct. const actual_offset: usize = offset: { @@ -750,8 +756,8 @@ pub fn clone( ); errdefer pool.deinit(); - // Our viewport pin is always undefined since our viewport in a clones - // goes back to the top + // Create our viewport. In a clone, the viewport always goes + // to the top. const viewport_pin = try pool.pins.create(); var tracked_pins: PinSet = .{}; errdefer tracked_pins.deinit(pool.alloc); @@ -817,6 +823,10 @@ pub fn clone( } } + // Initialize our viewport pin to point to the first cloned page + // so it points to valid memory. + viewport_pin.* = .{ .node = page_list.first.? }; + var result: PageList = .{ .pool = pool, .pages = page_list, @@ -1259,9 +1269,26 @@ const ReflowCursor = struct { )) |result| switch (result) { // Wrote the cell, move to the next. .success => x += 1, + // Wrote the cell but request to skip the next so skip it. // This is used for things like spacers. - .skip_next => x += 2, + .skip_next => { + // Remap any tracked pins at the skipped position (x+1) + // since we won't process that cell in the loop. + const pin_keys = list.tracked_pins.keys(); + for (pin_keys) |p| { + if (&p.node.data != src_page or + p.y != src_y or + p.x != x + 1) continue; + + p.node = self.node; + p.x = self.x; + p.y = self.y; + } + + x += 2; + }, + // Didn't write the cell, repeat writing this same cell. .repeat => {}, } else |err| switch (err) { @@ -2167,6 +2194,14 @@ fn resizeWithoutReflowGrowCols( assert(copied == len); assert(prev_page.size.rows <= prev_page.capacity.rows); + + // Remap any tracked pins that pointed to rows we just copied to prev. + const pin_keys = self.tracked_pins.keys(); + for (pin_keys) |p| { + if (p.node != chunk.node or p.y >= len) continue; + p.node = prev_node; + p.y += prev_page.size.rows - len; + } } // If we have an error, we clear the rows we just added to our prev page. @@ -11713,3 +11748,66 @@ test "PageList grow non-standard page prune protection" { // Verify the invariant holds - the fix prevents the destructive prune try testing.expect(s.totalRows() >= s.rows); } + +test "PageList resize (no reflow) more cols remaps pins in backfill path" { + // Regression test: when resizeWithoutReflowGrowCols copies rows to a previous + // page with spare capacity, tracked pins in those rows must be remapped. + // Without the fix, pins become dangling pointers when the original page is destroyed. + const testing = std.testing; + const alloc = testing.allocator; + + const cols: size.CellCountInt = 5; + const cap = try std_capacity.adjust(.{ .cols = cols }); + var s = try init(alloc, cols, cap.rows, null); + defer s.deinit(); + + // Grow until we have two pages. + while (s.pages.first == s.pages.last) { + _ = try s.grow(); + } + const first_page = s.pages.first.?; + const second_page = s.pages.last.?; + try testing.expect(first_page != second_page); + + // Trim a history row so the first page has spare capacity. + // This triggers the backfill path in resizeWithoutReflowGrowCols. + s.eraseRows(.{ .history = .{} }, .{ .history = .{ .y = 0 } }); + try testing.expect(first_page.data.size.rows < first_page.data.capacity.rows); + + // Ensure the resize takes the slow path (new capacity > current capacity). + const new_cols: size.CellCountInt = cols + 1; + const adjusted = try second_page.data.capacity.adjust(.{ .cols = new_cols }); + try testing.expect(second_page.data.capacity.cols < adjusted.cols); + + // Track a pin in row 0 of the second page. This row will be copied + // to the first page during backfill and the pin must be remapped. + const tracked = try s.trackPin(.{ .node = second_page, .x = 0, .y = 0 }); + defer s.untrackPin(tracked); + + // Write a marker character to the tracked cell so we can verify + // the pin points to the correct cell after resize. + const marker: u21 = 'X'; + tracked.rowAndCell().cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = marker }, + }; + + try s.resize(.{ .cols = new_cols, .reflow = false }); + + // Verify the pin points to a valid node still in the page list. + var found = false; + var it = s.pages.first; + while (it) |node| : (it = node.next) { + if (node == tracked.node) { + found = true; + break; + } + } + try testing.expect(found); + try testing.expect(tracked.y < tracked.node.data.size.rows); + + // Verify the pin still points to the cell with our marker content. + const cell = tracked.rowAndCell().cell; + try testing.expectEqual(.codepoint, cell.content_tag); + try testing.expectEqual(marker, cell.content.codepoint); +} From 93436217c8315d660214ef105b7eb59e92095342 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Jan 2026 07:21:41 -0800 Subject: [PATCH 502/605] terminal: page.exactRowCapacity --- src/terminal/bitmap_allocator.zig | 50 +++ src/terminal/page.zig | 628 +++++++++++++++++++++++++++++- src/terminal/ref_counted_set.zig | 17 +- 3 files changed, 688 insertions(+), 7 deletions(-) diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 258d73071..23a5048e1 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -63,6 +63,14 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { }; } + /// Returns the number of bytes required to allocate n elements of + /// type T. This accounts for the chunk size alignment used by the + /// bitmap allocator. + pub fn bytesRequired(comptime T: type, n: usize) usize { + const byte_count = @sizeOf(T) * n; + return alignForward(usize, byte_count, chunk_size); + } + /// Allocate n elements of type T. This will return error.OutOfMemory /// if there isn't enough space in the backing buffer. /// @@ -955,3 +963,45 @@ test "BitmapAllocator alloc and free two 1.5 bitmaps offset 0.75" { bm.bitmap.ptr(buf)[0..4], ); } + +test "BitmapAllocator bytesRequired" { + const testing = std.testing; + + // Chunk size of 16 bytes (like grapheme_chunk in page.zig) + { + const Alloc = BitmapAllocator(16); + + // Single byte rounds up to chunk size + try testing.expectEqual(16, Alloc.bytesRequired(u8, 1)); + try testing.expectEqual(16, Alloc.bytesRequired(u8, 16)); + try testing.expectEqual(32, Alloc.bytesRequired(u8, 17)); + + // u21 (4 bytes each) + try testing.expectEqual(16, Alloc.bytesRequired(u21, 1)); // 4 bytes -> 16 + try testing.expectEqual(16, Alloc.bytesRequired(u21, 4)); // 16 bytes -> 16 + try testing.expectEqual(32, Alloc.bytesRequired(u21, 5)); // 20 bytes -> 32 + try testing.expectEqual(32, Alloc.bytesRequired(u21, 6)); // 24 bytes -> 32 + } + + // Chunk size of 4 bytes + { + const Alloc = BitmapAllocator(4); + + try testing.expectEqual(4, Alloc.bytesRequired(u8, 1)); + try testing.expectEqual(4, Alloc.bytesRequired(u8, 4)); + try testing.expectEqual(8, Alloc.bytesRequired(u8, 5)); + + // u32 (4 bytes each) - exactly one chunk per element + try testing.expectEqual(4, Alloc.bytesRequired(u32, 1)); + try testing.expectEqual(8, Alloc.bytesRequired(u32, 2)); + } + + // Chunk size of 32 bytes (like string_chunk in page.zig) + { + const Alloc = BitmapAllocator(32); + + try testing.expectEqual(32, Alloc.bytesRequired(u8, 1)); + try testing.expectEqual(32, Alloc.bytesRequired(u8, 32)); + try testing.expectEqual(64, Alloc.bytesRequired(u8, 33)); + } +} diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 075f03b57..6a5958681 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -633,6 +633,114 @@ pub const Page = struct { HyperlinkError || GraphemeError; + /// Compute the exact capacity required to store a range of rows from + /// this page. + /// + /// The returned capacity will have the same number of columns as this + /// page and the number of rows equal to the range given. The returned + /// capacity is by definition strictly less than or equal to this + /// page's capacity, so the layout is guaranteed to succeed. + /// + /// Preconditions: + /// - Range must be at least 1 row + /// - Start and end must be valid for this page + pub fn exactRowCapacity( + self: *const Page, + y_start: usize, + y_end: usize, + ) Capacity { + assert(y_start < y_end); + assert(y_end <= self.size.rows); + + // Track unique IDs using a bitset. Both style IDs and hyperlink IDs + // are CellCountInt (u16), so we reuse this set for both to save + // stack memory (~8KB instead of ~16KB). + const CellCountSet = std.StaticBitSet(std.math.maxInt(size.CellCountInt) + 1); + comptime assert(size.StyleCountInt == size.CellCountInt); + comptime assert(size.HyperlinkCountInt == size.CellCountInt); + + // Accumulators + var id_set: CellCountSet = .initEmpty(); + var grapheme_bytes: usize = 0; + var string_bytes: usize = 0; + + // First pass: count styles and grapheme bytes + const rows = self.rows.ptr(self.memory)[y_start..y_end]; + for (rows) |*row| { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |*cell| { + if (cell.style_id != stylepkg.default_id) { + id_set.set(cell.style_id); + } + + if (cell.hasGrapheme()) { + if (self.lookupGrapheme(cell)) |cps| { + grapheme_bytes += GraphemeAlloc.bytesRequired(u21, cps.len); + } + } + } + } + const styles_cap = StyleSet.capacityForCount(id_set.count()); + + // Second pass: count hyperlinks and string bytes + // We count both unique hyperlinks (for hyperlink_set) and total + // hyperlink cells (for hyperlink_map capacity). + id_set = .initEmpty(); + var hyperlink_cells: usize = 0; + for (rows) |*row| { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |*cell| { + if (cell.hyperlink) { + hyperlink_cells += 1; + if (self.lookupHyperlink(cell)) |id| { + // Only count each unique hyperlink once for set sizing + if (!id_set.isSet(id)) { + id_set.set(id); + + // Get the hyperlink entry to compute string bytes + const entry = self.hyperlink_set.get(self.memory, id); + string_bytes += StringAlloc.bytesRequired(u8, entry.uri.len); + + switch (entry.id) { + .implicit => {}, + .explicit => |slice| { + string_bytes += StringAlloc.bytesRequired(u8, slice.len); + }, + } + } + } + } + } + } + + // The hyperlink_map capacity in layout() is computed as: + // hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2) + // We need enough hyperlink_bytes so that when layout() computes + // the map capacity, it can accommodate all hyperlink cells. This + // is unit tested. + const hyperlink_cap = cap: { + const hyperlink_count = id_set.count(); + const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count); + const hyperlink_map_min = std.math.divCeil( + usize, + hyperlink_cells, + hyperlink_cell_multiplier, + ) catch 0; + break :cap @max(hyperlink_set_cap, hyperlink_map_min); + }; + + // All the intCasts below are safe because we should have a + // capacity strictly less than or equal to this page's capacity. + return .{ + .cols = self.size.cols, + .rows = @intCast(y_end - y_start), + .styles = @intCast(styles_cap), + .grapheme_bytes = @intCast(grapheme_bytes), + .hyperlink_bytes = @intCast(hyperlink_cap * @sizeOf(hyperlink.Set.Item)), + .string_bytes = @intCast(string_bytes), + }; + } + /// Clone the contents of another page into this page. The capacities /// can be different, but the size of the other page must fit into /// this page. @@ -1569,10 +1677,13 @@ pub const Page = struct { const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align.toByteUnits()); const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size; - const grapheme_count = std.math.ceilPowerOfTwo( - usize, - @divFloor(cap.grapheme_bytes, grapheme_chunk), - ) catch unreachable; + const grapheme_count: usize = count: { + if (cap.grapheme_bytes == 0) break :count 0; + // Use divCeil to match GraphemeAlloc.layout() which uses alignForward, + // ensuring grapheme_map has capacity when grapheme_alloc has chunks. + const base = std.math.divCeil(usize, cap.grapheme_bytes, grapheme_chunk) catch unreachable; + break :count std.math.ceilPowerOfTwo(usize, base) catch unreachable; + }; const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count)); const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits()); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; @@ -3217,3 +3328,512 @@ test "Page verifyIntegrity zero cols" { page.verifyIntegrity(testing.allocator), ); } + +test "Page exactRowCapacity empty rows" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // Empty page: all capacity fields should be 0 (except cols/rows) + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(10, cap.cols); + try testing.expectEqual(5, cap.rows); + try testing.expectEqual(0, cap.styles); + try testing.expectEqual(0, cap.grapheme_bytes); + try testing.expectEqual(0, cap.hyperlink_bytes); + try testing.expectEqual(0, cap.string_bytes); +} + +test "Page exactRowCapacity styles" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // No styles: capacity should be 0 + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(0, cap.styles); + } + + // Add one style to a cell + const style1_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } }); + { + const rac = page.getRowAndCell(0, 0); + rac.row.styled = true; + rac.cell.style_id = style1_id; + } + + // One unique style - capacity accounts for load factor + const cap_one_style = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(StyleSet.capacityForCount(1), cap_one_style.styles); + } + + // Add same style to another cell (duplicate) - capacity unchanged + { + const rac = page.getRowAndCell(1, 0); + rac.cell.style_id = style1_id; + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_one_style.styles, cap.styles); + } + + // Add a different style + const style2_id = try page.styles.add(page.memory, .{ .flags = .{ .italic = true } }); + { + const rac = page.getRowAndCell(2, 0); + rac.cell.style_id = style2_id; + } + + // Two unique styles - capacity accounts for load factor + const cap_two_styles = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(StyleSet.capacityForCount(2), cap_two_styles.styles); + try testing.expect(cap_two_styles.styles > cap_one_style.styles); + } + + // Style outside the row range should not be counted + { + const rac = page.getRowAndCell(0, 7); + rac.row.styled = true; + rac.cell.style_id = try page.styles.add(page.memory, .{ .flags = .{ .underline = .single } }); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_two_styles.styles, cap.styles); + } + + // Full range includes the new style + { + const cap = page.exactRowCapacity(0, 10); + try testing.expectEqual(StyleSet.capacityForCount(3), cap.styles); + } + + // Verify clone works with exact capacity and produces same result + { + const cap = page.exactRowCapacity(0, 5); + var cloned = try Page.init(cap); + defer cloned.deinit(); + for (0..5) |y| { + const src_row = &page.rows.ptr(page.memory)[y]; + const dst_row = &cloned.rows.ptr(cloned.memory)[y]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + } + const cloned_cap = cloned.exactRowCapacity(0, 5); + try testing.expectEqual(cap, cloned_cap); + } +} + +test "Page exactRowCapacity single style clone" { + // Regression test: verify a single style can be cloned with exact capacity. + // This tests that capacityForCount properly accounts for ID 0 being reserved. + var page = try Page.init(.{ + .cols = 10, + .rows = 2, + .styles = 8, + }); + defer page.deinit(); + + // Add exactly one style to row 0 + const style_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } }); + { + const rac = page.getRowAndCell(0, 0); + rac.row.styled = true; + rac.cell.style_id = style_id; + } + + // exactRowCapacity for just row 0 should give capacity for 1 style + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(StyleSet.capacityForCount(1), cap.styles); + + // Create a new page with exact capacity and clone + var cloned = try Page.init(cap); + defer cloned.deinit(); + + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + + // This must not fail with StyleSetOutOfMemory + try cloned.cloneRowFrom(&page, dst_row, src_row); + + // Verify the style was cloned correctly + const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0]; + try testing.expect(cloned_cell.style_id != stylepkg.default_id); +} + +test "Page exactRowCapacity styles max single row" { + var page = try Page.init(.{ + .cols = std.math.maxInt(size.CellCountInt), + .rows = 1, + .styles = std.math.maxInt(size.StyleCountInt), + }); + defer page.deinit(); + + // Style our first row + const row = &page.rows.ptr(page.memory)[0]; + row.styled = true; + + // Fill cells with styles until we get OOM, but limit to a reasonable count + // to avoid overflow when computing capacityForCount near maxInt + const cells = row.cells.ptr(page.memory)[0..page.size.cols]; + var count: usize = 0; + const max_count: usize = 1000; // Limit to avoid overflow in capacity calculation + for (cells, 0..) |*cell, i| { + if (count >= max_count) break; + const style_id = page.styles.add(page.memory, .{ + .fg_color = .{ .rgb = .{ + .r = @intCast(i & 0xFF), + .g = @intCast((i >> 8) & 0xFF), + .b = 0, + } }, + }) catch break; + cell.style_id = style_id; + count += 1; + } + + // Verify we added a meaningful number of styles + try testing.expect(count > 0); + + // Capacity should be at least count (adjusted for load factor) + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(StyleSet.capacityForCount(count), cap.styles); +} + +test "Page exactRowCapacity grapheme_bytes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // No graphemes: capacity should be 0 + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(0, cap.grapheme_bytes); + } + + // Add one grapheme (1 codepoint) to a cell - rounds up to grapheme_chunk + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init('a'); + try page.appendGrapheme(rac.row, rac.cell, 0x0301); // combining acute accent + } + { + const cap = page.exactRowCapacity(0, 5); + // 1 codepoint = 4 bytes, rounds up to grapheme_chunk (16) + try testing.expectEqual(grapheme_chunk, cap.grapheme_bytes); + } + + // Add another grapheme to a different cell - should sum + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .init('e'); + try page.appendGrapheme(rac.row, rac.cell, 0x0300); // combining grave accent + } + { + const cap = page.exactRowCapacity(0, 5); + // 2 graphemes, each 1 codepoint = 2 * grapheme_chunk + try testing.expectEqual(grapheme_chunk * 2, cap.grapheme_bytes); + } + + // Add a larger grapheme (multiple codepoints) that fits in one chunk + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .init('o'); + try page.appendGrapheme(rac.row, rac.cell, 0x0301); + try page.appendGrapheme(rac.row, rac.cell, 0x0302); + try page.appendGrapheme(rac.row, rac.cell, 0x0303); + } + { + const cap = page.exactRowCapacity(0, 5); + // First two cells: 2 * grapheme_chunk + // Third cell: 3 codepoints = 12 bytes, rounds up to grapheme_chunk + try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes); + } + + // Grapheme outside the row range should not be counted + { + const rac = page.getRowAndCell(0, 7); + rac.cell.* = .init('x'); + try page.appendGrapheme(rac.row, rac.cell, 0x0304); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes); + } + + // Full range includes the new grapheme + { + const cap = page.exactRowCapacity(0, 10); + try testing.expectEqual(grapheme_chunk * 4, cap.grapheme_bytes); + } + + // Verify clone works with exact capacity and produces same result + { + const cap = page.exactRowCapacity(0, 5); + var cloned = try Page.init(cap); + defer cloned.deinit(); + for (0..5) |y| { + const src_row = &page.rows.ptr(page.memory)[y]; + const dst_row = &cloned.rows.ptr(cloned.memory)[y]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + } + const cloned_cap = cloned.exactRowCapacity(0, 5); + try testing.expectEqual(cap, cloned_cap); + } +} + +test "Page exactRowCapacity grapheme_bytes larger than chunk" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Add a grapheme larger than one chunk (grapheme_chunk_len = 4 codepoints) + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init('a'); + + // Add 6 codepoints - requires 2 chunks (6 * 4 = 24 bytes, rounds up to 32) + for (0..6) |i| { + try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0300 + i)); + } + + const cap = page.exactRowCapacity(0, 1); + // 6 codepoints = 24 bytes, alignForward(24, 16) = 32 + try testing.expectEqual(32, cap.grapheme_bytes); + + // Verify clone works with exact capacity and produces same result + var cloned = try Page.init(cap); + defer cloned.deinit(); + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + const cloned_cap = cloned.exactRowCapacity(0, 1); + try testing.expectEqual(cap, cloned_cap); +} + +test "Page exactRowCapacity hyperlinks" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // No hyperlinks: capacity should be 0 + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(0, cap.hyperlink_bytes); + try testing.expectEqual(0, cap.string_bytes); + } + + // Add one hyperlink with implicit ID + const uri1 = "https://example.com"; + const id1 = blk: { + const rac = page.getRowAndCell(0, 0); + + // Create and add hyperlink entry + const id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = uri1, + }); + try page.setHyperlink(rac.row, rac.cell, id); + break :blk id; + }; + // 1 hyperlink - capacity accounts for load factor + const cap_one_link = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap_one_link.hyperlink_bytes); + // URI "https://example.com" = 19 bytes, rounds up to string_chunk (32) + try testing.expectEqual(string_chunk, cap_one_link.string_bytes); + } + + // Add same hyperlink to another cell (duplicate ID) - capacity unchanged + { + const rac = page.getRowAndCell(1, 0); + + // Use the same hyperlink ID for another cell + page.hyperlink_set.use(page.memory, id1); + try page.setHyperlink(rac.row, rac.cell, id1); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_one_link.hyperlink_bytes, cap.hyperlink_bytes); + try testing.expectEqual(cap_one_link.string_bytes, cap.string_bytes); + } + + // Add a different hyperlink with explicit ID + const uri2 = "https://other.example.org/path"; + const explicit_id = "my-link-id"; + { + const rac = page.getRowAndCell(2, 0); + + const id = try page.insertHyperlink(.{ + .id = .{ .explicit = explicit_id }, + .uri = uri2, + }); + try page.setHyperlink(rac.row, rac.cell, id); + } + // 2 hyperlinks - capacity accounts for load factor + const cap_two_links = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(hyperlink.Set.capacityForCount(2) * @sizeOf(hyperlink.Set.Item), cap_two_links.hyperlink_bytes); + // First URI: 19 bytes -> 32, Second URI: 30 bytes -> 32, Explicit ID: 10 bytes -> 32 + try testing.expectEqual(string_chunk * 3, cap_two_links.string_bytes); + } + + // Hyperlink outside the row range should not be counted + { + const rac = page.getRowAndCell(0, 7); // row 7 is outside range [0, 5) + + const id = try page.insertHyperlink(.{ + .id = .{ .implicit = 99 }, + .uri = "https://outside.example.com", + }); + try page.setHyperlink(rac.row, rac.cell, id); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_two_links.hyperlink_bytes, cap.hyperlink_bytes); + try testing.expectEqual(cap_two_links.string_bytes, cap.string_bytes); + } + + // Full range includes the new hyperlink + { + const cap = page.exactRowCapacity(0, 10); + try testing.expectEqual(hyperlink.Set.capacityForCount(3) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes); + // Third URI: 27 bytes -> 32 + try testing.expectEqual(string_chunk * 4, cap.string_bytes); + } + + // Verify clone works with exact capacity and produces same result + { + const cap = page.exactRowCapacity(0, 5); + var cloned = try Page.init(cap); + defer cloned.deinit(); + for (0..5) |y| { + const src_row = &page.rows.ptr(page.memory)[y]; + const dst_row = &cloned.rows.ptr(cloned.memory)[y]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + } + const cloned_cap = cloned.exactRowCapacity(0, 5); + try testing.expectEqual(cap, cloned_cap); + } +} + +test "Page exactRowCapacity single hyperlink clone" { + // Regression test: verify a single hyperlink can be cloned with exact capacity. + // This tests that capacityForCount properly accounts for ID 0 being reserved. + var page = try Page.init(.{ + .cols = 10, + .rows = 2, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // Add exactly one hyperlink to row 0 + const uri = "https://example.com"; + const id = blk: { + const rac = page.getRowAndCell(0, 0); + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = uri, + }); + try page.setHyperlink(rac.row, rac.cell, link_id); + break :blk link_id; + }; + _ = id; + + // exactRowCapacity for just row 0 should give capacity for 1 hyperlink + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes); + + // Create a new page with exact capacity and clone + var cloned = try Page.init(cap); + defer cloned.deinit(); + + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + + // This must not fail with HyperlinkSetOutOfMemory + try cloned.cloneRowFrom(&page, dst_row, src_row); + + // Verify the hyperlink was cloned correctly + const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0]; + try testing.expect(cloned_cell.hyperlink); +} + +test "Page exactRowCapacity hyperlink map capacity for many cells" { + // A single hyperlink spanning many cells requires hyperlink_map capacity + // based on cell count, not unique hyperlink count. + const cols = 50; + var page = try Page.init(.{ + .cols = cols, + .rows = 2, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // Add one hyperlink spanning all 50 columns in row 0 + const uri = "https://example.com"; + const id = blk: { + const rac = page.getRowAndCell(0, 0); + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = uri, + }); + try page.setHyperlink(rac.row, rac.cell, link_id); + break :blk link_id; + }; + + // Apply same hyperlink to remaining cells in row 0 + for (1..cols) |x| { + const rac = page.getRowAndCell(@intCast(x), 0); + page.hyperlink_set.use(page.memory, id); + try page.setHyperlink(rac.row, rac.cell, id); + } + + // exactRowCapacity must account for 50 hyperlink cells, not just 1 unique hyperlink + const cap = page.exactRowCapacity(0, 1); + + // The hyperlink_bytes must be large enough that layout() computes sufficient + // hyperlink_map capacity. With hyperlink_cell_multiplier=16, we need at least + // ceil(50/16) = 4 hyperlink entries worth of bytes for the map. + const min_for_map = std.math.divCeil(usize, cols, hyperlink_cell_multiplier) catch 0; + const min_hyperlink_bytes = min_for_map * @sizeOf(hyperlink.Set.Item); + try testing.expect(cap.hyperlink_bytes >= min_hyperlink_bytes); + + // Create a new page with exact capacity and clone - must not fail + var cloned = try Page.init(cap); + defer cloned.deinit(); + + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + + // This must not fail with HyperlinkMapOutOfMemory + try cloned.cloneRowFrom(&page, dst_row, src_row); + + // Verify all hyperlinks were cloned correctly + for (0..cols) |x| { + const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[x]; + try testing.expect(cloned_cell.hyperlink); + } +} diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 883dd2f0d..8040039ae 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -64,6 +64,20 @@ pub fn RefCountedSet( @alignOf(Id), )); + /// This is the max load until the set returns OutOfMemory and + /// requires more capacity. + /// + /// Experimentally, this load factor works quite well. + pub const load_factor = 0.8125; + + /// Returns the minimum capacity needed to store `n` items, + /// accounting for the load factor and the reserved ID 0. + pub fn capacityForCount(n: usize) usize { + if (n == 0) return 0; + // +1 because ID 0 is reserved, so we need at least n+1 slots. + return @intFromFloat(@ceil(@as(f64, @floatFromInt(n + 1)) / load_factor)); + } + /// Set item pub const Item = struct { /// The value this item represents. @@ -154,9 +168,6 @@ pub fn RefCountedSet( /// The returned layout `cap` property will be 1 more than the number /// of items that the set can actually store, since ID 0 is reserved. pub fn init(cap: usize) Layout { - // Experimentally, this load factor works quite well. - const load_factor = 0.8125; - assert(cap <= @as(usize, @intCast(std.math.maxInt(Id))) + 1); // Zero-cap set is valid, return special case From f9699eceb03c2be01f40e9b90e906540e7185192 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Jan 2026 14:25:45 -0800 Subject: [PATCH 503/605] terminal: PageList.compact --- src/terminal/PageList.zig | 202 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b4f51a7c9..90bd3b12f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2685,6 +2685,74 @@ pub fn scrollClear(self: *PageList) !void { for (0..non_empty) |_| _ = try self.grow(); } +/// Compact a page to use the minimum required memory for the contents +/// it stores. Returns the new node pointer if compaction occurred, or null +/// if the page was already compact or compaction would not provide meaningful +/// savings. +/// +/// The current design of PageList at the time of writing this doesn't +/// allow for smaller than `std_size` nodes so if the current node's backing +/// page is standard size or smaller, no compaction will occur. In the +/// future we should fix this up. +/// +/// If this returns OOM, the PageList is left unchanged and no dangling +/// memory references exist. It is safe to ignore the error and continue using +/// the uncompacted page. +pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { + defer self.assertIntegrity(); + const page: *Page = &node.data; + + // We should never have empty rows in our pagelist anyways... + assert(page.size.rows > 0); + + // We never compact standard size or smaller pages because changing + // the capacity to something smaller won't save memory. + if (page.memory.len <= std_size) return null; + + // Compute the minimum capacity required for this page's content + const req_cap = page.exactRowCapacity(0, page.size.rows); + const new_size = Page.layout(req_cap).total_size; + const old_size = page.memory.len; + if (new_size >= old_size) return null; + + // Create the new smaller page + const new_node = try self.createPage(req_cap); + errdefer self.destroyNode(new_node); + const new_page: *Page = &new_node.data; + new_page.size = page.size; + new_page.dirty = page.dirty; + new_page.cloneFrom( + page, + 0, + page.size.rows, + ) catch |err| { + // cloneFrom should not fail when compacting since req_cap is + // computed to exactly fit the source content and our expectation + // of exactRowCapacity ensures it can fit all the requested + // data. + log.err("compact clone failed err={}", .{err}); + + // In this case, let's gracefully degrade by pretending we + // didn't need to compact. + self.destroyNode(new_node); + return null; + }; + + // Fix up all tracked pins to point to the new page + const pin_keys = self.tracked_pins.keys(); + for (pin_keys) |p| { + if (p.node != node) continue; + p.node = new_node; + } + + // Insert the new page and destroy the old one + self.pages.insertBefore(node, new_node); + self.pages.remove(node); + self.destroyNode(node); + + new_page.assertIntegrity(); + return new_node; +} /// This represents the state necessary to render a scrollbar for this /// PageList. It has the total size, the offset, and the size of the viewport. pub const Scrollbar = struct { @@ -11811,3 +11879,137 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { try testing.expectEqual(.codepoint, cell.content_tag); try testing.expectEqual(marker, cell.content.codepoint); } + +test "PageList compact std_size page returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // A freshly created page should be at std_size + const node = s.pages.first.?; + try testing.expect(node.data.memory.len <= std_size); + + // compact should return null since there's nothing to compact + const result = try s.compact(node); + try testing.expectEqual(null, result); + + // Page should still be the same + try testing.expectEqual(node, s.pages.first.?); +} + +test "PageList compact oversized page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow until we have multiple pages + const page1_node = s.pages.first.?; + page1_node.data.pauseIntegrityChecks(true); + for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + _ = try s.grow(); + } + page1_node.data.pauseIntegrityChecks(false); + _ = try s.grow(); + try testing.expect(s.pages.first != s.pages.last); + + var node = s.pages.first.?; + + // Write content to verify it's preserved + { + const page = &node.data; + for (0..page.size.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + y * s.cols) }, + }; + } + } + } + + // Create a tracked pin on this page + const tracked = try s.trackPin(.{ .node = node, .x = 5, .y = 10 }); + defer s.untrackPin(tracked); + + // Make the page oversized + while (node.data.memory.len <= std_size) { + node = try s.increaseCapacity(node, .grapheme_bytes); + } + try testing.expect(node.data.memory.len > std_size); + const oversized_len = node.data.memory.len; + const original_size = node.data.size; + const second_node = node.next.?; + + // Set dirty flag after increaseCapacity + node.data.dirty = true; + + // Compact the page + const new_node = try s.compact(node); + try testing.expect(new_node != null); + + // Verify memory is smaller + try testing.expect(new_node.?.data.memory.len < oversized_len); + + // Verify size preserved + try testing.expectEqual(original_size.rows, new_node.?.data.size.rows); + try testing.expectEqual(original_size.cols, new_node.?.data.size.cols); + + // Verify dirty flag preserved + try testing.expect(new_node.?.data.dirty); + + // Verify linked list integrity + try testing.expectEqual(new_node.?, s.pages.first.?); + try testing.expectEqual(null, new_node.?.prev); + try testing.expectEqual(second_node, new_node.?.next); + try testing.expectEqual(new_node.?, second_node.prev); + + // Verify pin updated correctly + try testing.expectEqual(new_node.?, tracked.node); + try testing.expectEqual(@as(size.CellCountInt, 5), tracked.x); + try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y); + + // Verify content preserved + const page = &new_node.?.data; + for (0..page.size.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x + y * s.cols)), + rac.cell.content.codepoint, + ); + } + } +} + +test "PageList compact insufficient savings returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + var node = s.pages.first.?; + + // Make the page slightly oversized (just one increase) + // This might not provide enough savings to justify compaction + node = try s.increaseCapacity(node, .grapheme_bytes); + + // If the page is still at or below std_size, compact returns null + if (node.data.memory.len <= std_size) { + const result = try s.compact(node); + try testing.expectEqual(null, result); + } else { + // If it did grow beyond std_size, verify that compaction + // works or returns null based on savings calculation + const result = try s.compact(node); + // Either it compacted or determined insufficient savings + if (result) |new_node| { + try testing.expect(new_node.data.memory.len < node.data.memory.len); + } + } +} From 06130d40da7ddc1e33da743b97ba15ea42d1ebc3 Mon Sep 17 00:00:00 2001 From: Steven Lu Date: Tue, 20 Jan 2026 00:55:50 +0700 Subject: [PATCH 504/605] split_tree: fix test passing wrong type to split() The test was passing *TestView instead of *TestTree to the split() function, which caused a compilation error. --- src/datastruct/split_tree.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 93daa77e9..e3be5b49f 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1356,12 +1356,14 @@ test "SplitTree: isSplit" { // Split tree should be split var v2: TestView = .{ .label = "B" }; + var tree2: TestTree = try TestTree.init(alloc, &v2); + defer tree2.deinit(); var split = try single.split( alloc, .root, .right, 0.5, - &v2, + &tree2, ); defer split.deinit(); try testing.expect(split.isSplit()); From 5ee56409c7bc06a7f881da1fdc18c91799a2b6d4 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 19 Jan 2026 11:58:58 -0600 Subject: [PATCH 505/605] macos: support mouse buttons 8/9 (back/forward) Add support for mouse buttons 4-11 in the macOS app. Previously only left, right, and middle buttons were handled. Now otherMouseDown/Up events properly map NSEvent.buttonNumber to the corresponding Ghostty mouse button, enabling back/forward button support. Fixes: https://github.com/ghostty-org/ghostty/issues/2425 Amp-Thread-ID: https://ampcode.com/threads/T-019bd74e-6b2b-731d-b43a-ac73b3460c32 Co-authored-by: Amp --- include/ghostty.h | 8 +++++ macos/Sources/Ghostty/Ghostty.Input.swift | 35 +++++++++++++++++++ .../Surface View/SurfaceView_AppKit.swift | 8 ++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b884ebc08..0133fac73 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -66,6 +66,14 @@ typedef enum { GHOSTTY_MOUSE_LEFT, GHOSTTY_MOUSE_RIGHT, GHOSTTY_MOUSE_MIDDLE, + GHOSTTY_MOUSE_FOUR, + GHOSTTY_MOUSE_FIVE, + GHOSTTY_MOUSE_SIX, + GHOSTTY_MOUSE_SEVEN, + GHOSTTY_MOUSE_EIGHT, + GHOSTTY_MOUSE_NINE, + GHOSTTY_MOUSE_TEN, + GHOSTTY_MOUSE_ELEVEN, } ghostty_input_mouse_button_e; typedef enum { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 4b3fb9937..7b2905abb 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -370,6 +370,14 @@ extension Ghostty.Input { case left case right case middle + case four + case five + case six + case seven + case eight + case nine + case ten + case eleven var cMouseButton: ghostty_input_mouse_button_e { switch self { @@ -377,6 +385,33 @@ extension Ghostty.Input { case .left: GHOSTTY_MOUSE_LEFT case .right: GHOSTTY_MOUSE_RIGHT case .middle: GHOSTTY_MOUSE_MIDDLE + case .four: GHOSTTY_MOUSE_FOUR + case .five: GHOSTTY_MOUSE_FIVE + case .six: GHOSTTY_MOUSE_SIX + case .seven: GHOSTTY_MOUSE_SEVEN + case .eight: GHOSTTY_MOUSE_EIGHT + case .nine: GHOSTTY_MOUSE_NINE + case .ten: GHOSTTY_MOUSE_TEN + case .eleven: GHOSTTY_MOUSE_ELEVEN + } + } + + /// Initialize from NSEvent.buttonNumber + /// NSEvent buttonNumber: 0=left, 1=right, 2=middle, 3=back (button 8), 4=forward (button 9), etc. + init(fromNSEventButtonNumber buttonNumber: Int) { + switch buttonNumber { + case 0: self = .left + case 1: self = .right + case 2: self = .middle + case 3: self = .eight // Back button + case 4: self = .nine // Forward button + case 5: self = .six + case 6: self = .seven + case 7: self = .four + case 8: self = .five + case 9: self = .ten + case 10: self = .eleven + default: self = .unknown } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 80de2e823..0ddfe57b8 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -860,16 +860,16 @@ extension Ghostty { override func otherMouseDown(with event: NSEvent) { guard let surface = self.surface else { return } - guard event.buttonNumber == 2 else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods) + let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, button.cMouseButton, mods) } override func otherMouseUp(with event: NSEvent) { guard let surface = self.surface else { return } - guard event.buttonNumber == 2 else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods) + let button = Ghostty.Input.MouseButton(fromNSEventButtonNumber: event.buttonNumber) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, mods) } From 93a070c6de47cac5d02d525e33846361b423b382 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 18 Jan 2026 06:48:49 -0800 Subject: [PATCH 506/605] terminal: PageList split operation --- src/terminal/PageList.zig | 685 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 685 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 90bd3b12f..c639f15cd 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2753,6 +2753,98 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { new_page.assertIntegrity(); return new_node; } + +pub const SplitError = error{ + // Allocator OOM + OutOfMemory, + // Page can't be split further because it is already a single row. + OutOfSpace, +}; + +/// Split the given node in the PageList at the given pin. +/// +/// The row at the pin and after will be moved into a new page with +/// the same capacity as the original page. Alternatively, you can "split +/// above" by splitting the row following the desired split row. +/// +/// Since the split happens below the pin, the pin remains valid. +pub fn split( + self: *PageList, + p: Pin, +) SplitError!void { + if (build_options.slow_runtime_safety) assert(self.pinIsValid(p)); + + // Ran into a bug that I can only explain via aliasing. If a tracked + // pin is passed in, its possible Zig will alias the memory and then + // when we modify it later it updates our p here. Coyping the node + // fixes this. + const original_node = p.node; + const page: *Page = &original_node.data; + + // A page that is already 1 row can't be split. In the future we can + // theoretically maybe split by soft-wrapping multiple pages but that + // seems crazy and the rest of our PageList can't handle heterogeneously + // sized pages today. + if (page.size.rows <= 1) return error.OutOfSpace; + + // Splitting at row 0 is a no-op since there's nothing before the split point. + if (p.y == 0) return; + + // At this point we're doing actual modification so make sure + // on the return that we're good. + defer self.assertIntegrity(); + + // Create a new node with the same capacity of managed memory. + const target = try self.createPage(page.capacity); + errdefer self.destroyNode(target); + + // Determine how many rows we're copying + const y_start = p.y; + const y_end = page.size.rows; + target.data.size.rows = y_end - y_start; + assert(target.data.size.rows <= target.data.capacity.rows); + + // Copy our old data. This should NOT fail because we have the + // capacity of the old page which already fits the data we requested. + target.data.cloneFrom(page, y_start, y_end) catch |err| { + log.err( + "error cloning rows for split err={}", + .{err}, + ); + + // Rather than crash, we return an OutOfSpace to show that + // we couldn't split and let our callers gracefully handle it. + // Realistically though... this should not happen. + return error.OutOfSpace; + }; + + // From this point forward there is no going back. We have no + // error handling. It is possible but we haven't written it. + errdefer comptime unreachable; + + // Move any tracked pins from the copied rows + for (self.tracked_pins.keys()) |tracked| { + if (&tracked.node.data != page or + tracked.y < p.y) continue; + + tracked.node = target; + tracked.y -= p.y; + // p.x remains the same since we're copying the row as-is + } + + // Clear our rows + for (page.rows.ptr(page.memory)[y_start..y_end]) |*row| { + page.clearCells( + row, + 0, + page.size.cols, + ); + } + page.size.rows -= y_end - y_start; + + self.pages.insertAfter(original_node, target); +} + /// This represents the state necessary to render a scrollbar for this /// PageList. It has the total size, the offset, and the size of the viewport. pub const Scrollbar = struct { @@ -12013,3 +12105,596 @@ test "PageList compact insufficient savings returns null" { } } } + +test "PageList split at middle row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc. + for (0..page.size.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Split at row 5 (middle) + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; + try s.split(split_pin); + + // Verify two pages exist + try testing.expect(s.pages.first != null); + try testing.expect(s.pages.first.?.next != null); + + const first_page = &s.pages.first.?.data; + const second_page = &s.pages.first.?.next.?.data; + + // First page should have rows 0-4 (5 rows) + try testing.expectEqual(@as(usize, 5), first_page.size.rows); + // Second page should have rows 5-9 (5 rows) + try testing.expectEqual(@as(usize, 5), second_page.size.rows); + + // Verify content in first page is preserved (rows 0-4 have codepoints 0-4) + for (0..5) |y| { + const rac = first_page.getRowAndCell(0, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Verify content in second page (original rows 5-9, now at y=0-4) + for (0..5) |y| { + const rac = second_page.getRowAndCell(0, y); + try testing.expectEqual(@as(u21, @intCast(y + 5)), rac.cell.content.codepoint); + } +} + +test "PageList split at row 0 is no-op" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Write content to all rows + for (0..page.size.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Split at row 0 should be a no-op + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 0, .x = 0 }; + try s.split(split_pin); + + // Verify only one page exists (no split occurred) + try testing.expect(s.pages.first != null); + try testing.expect(s.pages.first.?.next == null); + + // Verify all content is still in the original page + try testing.expectEqual(@as(usize, 10), page.size.rows); + for (0..10) |y| { + const rac = page.getRowAndCell(0, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } +} + +test "PageList split at last row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Write content to all rows + for (0..page.size.rows) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Split at last row (row 9) + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 9, .x = 0 }; + try s.split(split_pin); + + // Verify two pages exist + try testing.expect(s.pages.first != null); + try testing.expect(s.pages.first.?.next != null); + + const first_page = &s.pages.first.?.data; + const second_page = &s.pages.first.?.next.?.data; + + // First page should have 9 rows + try testing.expectEqual(@as(usize, 9), first_page.size.rows); + // Second page should have 1 row + try testing.expectEqual(@as(usize, 1), second_page.size.rows); + + // Verify content in second page (original row 9, now at y=0) + const rac = second_page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 9), rac.cell.content.codepoint); +} + +test "PageList split single row page returns OutOfSpace" { + const testing = std.testing; + const alloc = testing.allocator; + + // Initialize with 1 row + var s = try init(alloc, 10, 1, 0); + defer s.deinit(); + + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 0, .x = 0 }; + const result = s.split(split_pin); + + try testing.expectError(error.OutOfSpace, result); +} + +test "PageList split moves tracked pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Track a pin at row 7 + const tracked = try s.trackPin(.{ .node = s.pages.first.?, .y = 7, .x = 3 }); + defer s.untrackPin(tracked); + + // Split at row 5 + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; + try s.split(split_pin); + + // The tracked pin should now be in the second page + try testing.expect(tracked.node == s.pages.first.?.next.?); + // y should be adjusted: was 7, split at 5, so new y = 7 - 5 = 2 + try testing.expectEqual(@as(usize, 2), tracked.y); + // x should remain unchanged + try testing.expectEqual(@as(usize, 3), tracked.x); +} + +test "PageList split tracked pin before split point unchanged" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const original_node = s.pages.first.?; + + // Track a pin at row 2 (before the split point) + const tracked = try s.trackPin(.{ .node = original_node, .y = 2, .x = 5 }); + defer s.untrackPin(tracked); + + // Split at row 5 + const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 }; + try s.split(split_pin); + + // The tracked pin should remain in the original page + try testing.expect(tracked.node == s.pages.first.?); + // y and x should be unchanged + try testing.expectEqual(@as(usize, 2), tracked.y); + try testing.expectEqual(@as(usize, 5), tracked.x); +} + +test "PageList split tracked pin at split point moves to new page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const original_node = s.pages.first.?; + + // Track a pin at the exact split point (row 5) + const tracked = try s.trackPin(.{ .node = original_node, .y = 5, .x = 4 }); + defer s.untrackPin(tracked); + + // Split at row 5 + const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 }; + try s.split(split_pin); + + // The tracked pin should be in the new page + try testing.expect(tracked.node == s.pages.first.?.next.?); + // y should be 0 since it was at the split point: 5 - 5 = 0 + try testing.expectEqual(@as(usize, 0), tracked.y); + // x should remain unchanged + try testing.expectEqual(@as(usize, 4), tracked.x); +} + +test "PageList split multiple tracked pins across regions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const original_node = s.pages.first.?; + + // Track multiple pins in different regions + const pin_before = try s.trackPin(.{ .node = original_node, .y = 1, .x = 0 }); + defer s.untrackPin(pin_before); + const pin_at_split = try s.trackPin(.{ .node = original_node, .y = 5, .x = 2 }); + defer s.untrackPin(pin_at_split); + const pin_after1 = try s.trackPin(.{ .node = original_node, .y = 7, .x = 3 }); + defer s.untrackPin(pin_after1); + const pin_after2 = try s.trackPin(.{ .node = original_node, .y = 9, .x = 8 }); + defer s.untrackPin(pin_after2); + + // Split at row 5 + const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 }; + try s.split(split_pin); + + const first_page = s.pages.first.?; + const second_page = first_page.next.?; + + // Pin before split point stays in original page + try testing.expect(pin_before.node == first_page); + try testing.expectEqual(@as(usize, 1), pin_before.y); + try testing.expectEqual(@as(usize, 0), pin_before.x); + + // Pin at split point moves to new page with y=0 + try testing.expect(pin_at_split.node == second_page); + try testing.expectEqual(@as(usize, 0), pin_at_split.y); + try testing.expectEqual(@as(usize, 2), pin_at_split.x); + + // Pins after split point move to new page with adjusted y + try testing.expect(pin_after1.node == second_page); + try testing.expectEqual(@as(usize, 2), pin_after1.y); // 7 - 5 = 2 + try testing.expectEqual(@as(usize, 3), pin_after1.x); + + try testing.expect(pin_after2.node == second_page); + try testing.expectEqual(@as(usize, 4), pin_after2.y); // 9 - 5 = 4 + try testing.expectEqual(@as(usize, 8), pin_after2.x); +} + +test "PageList split tracked viewport_pin in split region moves correctly" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const original_node = s.pages.first.?; + + // Set viewport_pin to row 7 (after split point) + s.viewport_pin.node = original_node; + s.viewport_pin.y = 7; + s.viewport_pin.x = 6; + + // Split at row 5 + const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 }; + try s.split(split_pin); + + // viewport_pin should be in the new page + try testing.expect(s.viewport_pin.node == s.pages.first.?.next.?); + // y should be adjusted: 7 - 5 = 2 + try testing.expectEqual(@as(usize, 2), s.viewport_pin.y); + // x should remain unchanged + try testing.expectEqual(@as(usize, 6), s.viewport_pin.x); +} + +test "PageList split middle page preserves linked list order" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create a single page with 12 rows + var s = try init(alloc, 10, 12, 0); + defer s.deinit(); + + // Split at row 4 to create: page1 (rows 0-3), page2 (rows 4-11) + const first_node = s.pages.first.?; + const split_pin1: Pin = .{ .node = first_node, .y = 4, .x = 0 }; + try s.split(split_pin1); + + // Now we have 2 pages + const page1 = s.pages.first.?; + const page2 = s.pages.first.?.next.?; + try testing.expectEqual(@as(usize, 4), page1.data.size.rows); + try testing.expectEqual(@as(usize, 8), page2.data.size.rows); + + // Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7) + const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 }; + try s.split(split_pin2); + + // Now we have 3 pages + const first = s.pages.first.?; + const middle = first.next.?; + const last = middle.next.?; + + // Verify linked list order: first -> middle -> last + try testing.expectEqual(page1, first); + try testing.expectEqual(page2, middle); + try testing.expectEqual(s.pages.last.?, last); + + // Verify prev pointers + try testing.expect(first.prev == null); + try testing.expectEqual(first, middle.prev.?); + try testing.expectEqual(middle, last.prev.?); + + // Verify next pointers + try testing.expectEqual(middle, first.next.?); + try testing.expectEqual(last, middle.next.?); + try testing.expect(last.next == null); + + // Verify row counts + try testing.expectEqual(@as(usize, 4), first.data.size.rows); + try testing.expectEqual(@as(usize, 4), middle.data.size.rows); + try testing.expectEqual(@as(usize, 4), last.data.size.rows); +} + +test "PageList split last page makes new page the last" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create a single page with 10 rows + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + // Split to create 2 pages first + const first_node = s.pages.first.?; + const split_pin1: Pin = .{ .node = first_node, .y = 5, .x = 0 }; + try s.split(split_pin1); + + // Now split the last page + const last_before_split = s.pages.last.?; + try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows); + + const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 }; + try s.split(split_pin2); + + // The new page should be the new last + const new_last = s.pages.last.?; + try testing.expect(new_last != last_before_split); + try testing.expectEqual(last_before_split, new_last.prev.?); + try testing.expect(new_last.next == null); + + // Verify row counts: original last has 2 rows, new last has 3 rows + try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows); + try testing.expectEqual(@as(usize, 3), new_last.data.size.rows); +} + +test "PageList split first page keeps original as first" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create 2 pages by splitting + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const original_first = s.pages.first.?; + const split_pin1: Pin = .{ .node = original_first, .y = 5, .x = 0 }; + try s.split(split_pin1); + + // Get second page (created by first split) + const second_page = s.pages.first.?.next.?; + + // Now split the first page again + const split_pin2: Pin = .{ .node = s.pages.first.?, .y = 2, .x = 0 }; + try s.split(split_pin2); + + // Original first should still be first + try testing.expectEqual(original_first, s.pages.first.?); + try testing.expect(s.pages.first.?.prev == null); + + // New page should be inserted between first and second + const inserted = s.pages.first.?.next.?; + try testing.expect(inserted != second_page); + try testing.expectEqual(second_page, inserted.next.?); + + // Verify row counts: first has 2, inserted has 3, second has 5 + try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows); + try testing.expectEqual(@as(usize, 3), inserted.data.size.rows); + try testing.expectEqual(@as(usize, 5), second_page.data.size.rows); +} + +test "PageList split preserves wrap flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Set wrap flags on rows that will be in the second page after split + // Row 5: wrap = true (this is the start of a wrapped line) + // Row 6: wrap_continuation = true (this continues the wrap) + // Row 7: wrap = true, wrap_continuation = true (wrapped and continues) + { + const rac5 = page.getRowAndCell(0, 5); + rac5.row.wrap = true; + + const rac6 = page.getRowAndCell(0, 6); + rac6.row.wrap_continuation = true; + + const rac7 = page.getRowAndCell(0, 7); + rac7.row.wrap = true; + rac7.row.wrap_continuation = true; + } + + // Split at row 5 + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; + try s.split(split_pin); + + const second_page = &s.pages.first.?.next.?.data; + + // Verify wrap flags are preserved in new page + // Original row 5 is now row 0 in second page + { + const rac0 = second_page.getRowAndCell(0, 0); + try testing.expect(rac0.row.wrap); + try testing.expect(!rac0.row.wrap_continuation); + } + + // Original row 6 is now row 1 in second page + { + const rac1 = second_page.getRowAndCell(0, 1); + try testing.expect(!rac1.row.wrap); + try testing.expect(rac1.row.wrap_continuation); + } + + // Original row 7 is now row 2 in second page + { + const rac2 = second_page.getRowAndCell(0, 2); + try testing.expect(rac2.row.wrap); + try testing.expect(rac2.row.wrap_continuation); + } +} + +test "PageList split preserves styled cells" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Create a style and apply it to cells in rows 5-7 (which will be in the second page) + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_id = try page.styles.add(page.memory, style); + + for (5..8) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'S' }, + .style_id = style_id, + }; + rac.row.styled = true; + page.styles.use(page.memory, style_id); + } + // Release the extra ref from add + page.styles.release(page.memory, style_id); + + // Split at row 5 + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; + try s.split(split_pin); + + const first_page = &s.pages.first.?.data; + const second_page = &s.pages.first.?.next.?.data; + + // First page should have no styles (all styled rows moved to second page) + try testing.expectEqual(@as(usize, 0), first_page.styles.count()); + + // Second page should have exactly 1 style (the bold style, used by 3 cells) + try testing.expectEqual(@as(usize, 1), second_page.styles.count()); + + // Verify styled cells are preserved in new page + for (0..3) |y| { + const rac = second_page.getRowAndCell(0, y); + try testing.expectEqual(@as(u21, 'S'), rac.cell.content.codepoint); + try testing.expect(rac.cell.style_id != 0); + + const got_style = second_page.styles.get(second_page.memory, rac.cell.style_id); + try testing.expect(got_style.flags.bold); + try testing.expect(rac.row.styled); + } +} + +test "PageList split preserves grapheme clusters" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5) + { + const rac = page.getRowAndCell(0, 6); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0x1F468 }, // Man emoji + }; + try page.setGraphemes(rac.row, rac.cell, &.{ + 0x200D, // ZWJ + 0x1F469, // Woman emoji + }); + } + + // Split at row 5 + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; + try s.split(split_pin); + + const first_page = &s.pages.first.?.data; + const second_page = &s.pages.first.?.next.?.data; + + // First page should have no graphemes (the grapheme row moved to second page) + try testing.expectEqual(@as(usize, 0), first_page.graphemeCount()); + + // Second page should have exactly 1 grapheme + try testing.expectEqual(@as(usize, 1), second_page.graphemeCount()); + + // Verify grapheme is preserved in new page (original row 6 is now row 1) + { + const rac = second_page.getRowAndCell(0, 1); + try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + + const cps = second_page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 2), cps.len); + try testing.expectEqual(@as(u21, 0x200D), cps[0]); + try testing.expectEqual(@as(u21, 0x1F469), cps[1]); + } +} + +test "PageList split preserves hyperlinks" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + const page = &s.pages.first.?.data; + + // Add a hyperlink to row 7 (will be row 2 in second page after split at 5) + const hyperlink_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 0 }, + .uri = "https://example.com", + }); + { + const rac = page.getRowAndCell(0, 7); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'L' }, + }; + try page.setHyperlink(rac.row, rac.cell, hyperlink_id); + } + + // Split at row 5 + const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; + try s.split(split_pin); + + const first_page = &s.pages.first.?.data; + const second_page = &s.pages.first.?.next.?.data; + + // First page should have no hyperlinks (the hyperlink row moved to second page) + try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count()); + + // Second page should have exactly 1 hyperlink + try testing.expectEqual(@as(usize, 1), second_page.hyperlink_set.count()); + + // Verify hyperlink is preserved in new page (original row 7 is now row 2) + { + const rac = second_page.getRowAndCell(0, 2); + try testing.expectEqual(@as(u21, 'L'), rac.cell.content.codepoint); + try testing.expect(rac.cell.hyperlink); + + const link_id = second_page.lookupHyperlink(rac.cell).?; + const link = second_page.hyperlink_set.get(second_page.memory, link_id); + try testing.expectEqualStrings("https://example.com", link.uri.slice(second_page.memory)); + } +} From 4e60a850995ae7cd89faf456208b6df38eab051a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Jan 2026 10:27:43 -0800 Subject: [PATCH 507/605] terminal: setAttribute failure reverts back to prior style --- src/terminal/Screen.zig | 66 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1538da5da..d09477eda 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1789,9 +1789,22 @@ fn resizeInternal( /// Set a style attribute for the current cursor. /// -/// This can cause a page split if the current page cannot fit this style. -/// This is the only scenario an error return is possible. +/// If the style can't be set due to any internal errors (memory-related), +/// then this will revert back to the existing style and return an error. pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { + // If we fail to set our style for any reason, we should revert + // back to the old style. If we fail to do that, we revert back to + // the default style. + const old_style = self.cursor.style; + errdefer { + self.cursor.style = old_style; + self.manualStyleUpdate() catch |err| { + log.warn("setAttribute error restoring old style after failure err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = style.default_id; + }; + } + switch (attr) { .unset => { self.cursor.style = .{}; @@ -1935,6 +1948,9 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { /// Call this whenever you manually change the cursor style. /// +/// If this returns an error, the style change did not take effect and +/// the cursor style is reverted back to the default. +/// /// Note that this can return any PageList capacity error, because it /// is possible for the internal pagelist to not accommodate the new style /// at all. This WILL attempt to resize our internal pages to fit the style @@ -9247,3 +9263,49 @@ test "Screen: cursorDown to page with insufficient capacity" { try testing.expect(false); } } + +test "Screen setAttribute returns OutOfSpace at max styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + + // Increase the page's style capacity to max by repeatedly calling increaseCapacity + const max_styles = std.math.maxInt(size.CellCountInt); + while (s.cursor.page_pin.node.data.capacity.styles < max_styles) { + _ = s.pages.increaseCapacity( + s.cursor.page_pin.node, + .styles, + ) catch break; + } + + // Get the page reference after increaseCapacity + const page = &s.cursor.page_pin.node.data; + try testing.expectEqual(max_styles, page.capacity.styles); + + // Fill up all style slots + page.pauseIntegrityChecks(true); + var n: u24 = 1; + while (page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(n) } }, + )) |_| n += 1 else |_| {} + page.pauseIntegrityChecks(false); + + // Set a style that we can track as "prior" + // Use a style that's already in the page by reusing one we added + s.cursor.style = .{ .bg_color = .{ .rgb = @bitCast(@as(u24, 1)) } }; + const prior_id = page.styles.add(page.memory, s.cursor.style) catch unreachable; + s.cursor.style_id = prior_id; + + // Now try to set a new unique attribute that would require a new style slot + // This should fail with OutOfSpace since we're at max capacity + const result = s.setAttribute(.bold); + + // Should return OutOfSpace error + try testing.expectError(error.OutOfSpace, result); + + // The cursor style_id should remain the prior one + try testing.expectEqual(prior_id, s.cursor.style_id); +} From c412b30cb5ffc1a208d4bb8b39c967cb00a5e6b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Jan 2026 10:08:51 -0800 Subject: [PATCH 508/605] terminal: splitForCapacity, manualStyleUpdate uses this --- src/terminal/PageList.zig | 2 +- src/terminal/Screen.zig | 189 ++++++++++++++++++++++++++++++++------ 2 files changed, 163 insertions(+), 28 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c639f15cd..41f8d6533 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2776,7 +2776,7 @@ pub fn split( // Ran into a bug that I can only explain via aliasing. If a tracked // pin is passed in, its possible Zig will alias the memory and then - // when we modify it later it updates our p here. Coyping the node + // when we modify it later it updates our p here. Copying the node // fixes this. const original_node = p.node; const page: *Page = &original_node.data; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d09477eda..5ea338b70 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1791,7 +1791,10 @@ fn resizeInternal( /// /// If the style can't be set due to any internal errors (memory-related), /// then this will revert back to the existing style and return an error. -pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { +pub fn setAttribute( + self: *Screen, + attr: sgr.Attribute, +) PageList.IncreaseCapacityError!void { // If we fail to set our style for any reason, we should revert // back to the old style. If we fail to do that, we revert back to // the default style. @@ -1995,13 +1998,22 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { ) catch |err| id: { // Our style map is full or needs to be rehashed, so we need to // increase style capacity (or rehash). - const node = try self.increaseCapacity( + const node = self.increaseCapacity( self.cursor.page_pin.node, switch (err) { error.OutOfMemory => .styles, error.NeedsRehash => null, }, - ); + ) catch |increase_err| switch (increase_err) { + error.OutOfMemory => return error.OutOfMemory, + error.OutOfSpace => space: { + // Out of space, we need to split the page. Split wherever + // is using less capacity and hope that works. If it doesn't + // work, we tried. + try self.splitForCapacity(self.cursor.page_pin.*); + break :space self.cursor.page_pin.node; + }, + }; page = &node.data; break :id page.styles.add( @@ -2031,6 +2043,62 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { self.cursor.style_id = id; } +/// Split at the given pin so that the pinned row moves to the page +/// with less used capacity after the split. +/// +/// The primary use case for this is to handle IncreaseCapacityError +/// OutOfSpace conditions where we need to split the page in order +/// to make room for more managed memory. +/// +/// If the caller cares about where the pin moves to, they should +/// setup a tracked pin before calling this and then check that. +/// In many calling cases, the input pin is tracked (e.g. the cursor +/// pin). +/// +/// If this returns OOM then its a system OOM. If this returns OutOfSpace +/// then it means the page can't be split further. +fn splitForCapacity( + self: *Screen, + pin: Pin, +) PageList.SplitError!void { + // Get our capacities. We include our target row because its + // capacity will be preserved. + const bytes_above = Page.layout(pin.node.data.exactRowCapacity( + 0, + pin.y + 1, + )).total_size; + const bytes_below = Page.layout(pin.node.data.exactRowCapacity( + pin.y, + pin.node.data.size.rows, + )).total_size; + + // We need to track the old cursor pin because if our split + // moves the cursor pin we need to update our accounting. + const old_cursor = self.cursor.page_pin.*; + + // If our bytes above are less than bytes below, we move the pin + // to split down one since splitting includes the pinned row in + // the new node. + try self.pages.split(if (bytes_above < bytes_below) + pin.down(1) orelse pin + else + pin); + + // Cursor didn't change nodes, we're done. + if (self.cursor.page_pin.node == old_cursor.node) return; + + // Cursor changed, we need to restore the old pin then use + // cursorChangePin to move to the new pin. The old node is guaranteed + // to still exist, just not the row. + // + // Note that page_row and all that will be invalid, it points to the + // new node, but at the time of writing this we don't need any of that + // to be right in cursorChangePin. + const new_cursor = self.cursor.page_pin.*; + self.cursor.page_pin.* = old_cursor; + self.cursorChangePin(new_cursor); +} + /// Append a grapheme to the given cell within the current cursor row. pub fn appendGrapheme( self: *Screen, @@ -9264,48 +9332,115 @@ test "Screen: cursorDown to page with insufficient capacity" { } } -test "Screen setAttribute returns OutOfSpace at max styles" { +test "Screen setAttribute increases capacity when style map is full" { + // Tests that setAttribute succeeds when the style map is full by + // increasing page capacity. When capacity is at max and increaseCapacity + // returns OutOfSpace, manualStyleUpdate will split the page instead. const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + // Use a small screen with multiple rows + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit(); + // Write content to multiple rows + try s.testWriteString("line1\nline2\nline3\nline4\nline5"); + + // Get the page and fill its style map to capacity + const page = &s.cursor.page_pin.node.data; + const original_styles_capacity = page.capacity.styles; + + // Fill the style map to capacity + { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + defer page.assertIntegrity(); + + var n: u24 = 1; + while (page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(n) } }, + )) |_| n += 1 else |_| {} + } + + // Now try to set a new unique attribute that would require a new style slot + // This should succeed by increasing capacity (or splitting if at max capacity) + try s.setAttribute(.bold); + + // The style should have been applied (bold flag set) + try testing.expect(s.cursor.style.flags.bold); + + // The cursor should have a valid non-default style_id + try testing.expect(s.cursor.style_id != style.default_id); + + // Either the capacity increased or the page was split/changed + const current_page = &s.cursor.page_pin.node.data; + const capacity_increased = current_page.capacity.styles > original_styles_capacity; + const page_changed = current_page != page; + try testing.expect(capacity_increased or page_changed); +} + +test "Screen setAttribute splits page on OutOfSpace at max styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 10, + .rows = 10, + .max_scrollback = 0, + }); + defer s.deinit(); + + // Write content to multiple rows so we have something to split + try s.testWriteString("line1\nline2\nline3\nline4\nline5"); + + // Remember the original node + const original_node = s.cursor.page_pin.node; + // Increase the page's style capacity to max by repeatedly calling increaseCapacity + // Use Screen.increaseCapacity to properly maintain cursor state const max_styles = std.math.maxInt(size.CellCountInt); while (s.cursor.page_pin.node.data.capacity.styles < max_styles) { - _ = s.pages.increaseCapacity( + _ = s.increaseCapacity( s.cursor.page_pin.node, .styles, ) catch break; } - // Get the page reference after increaseCapacity - const page = &s.cursor.page_pin.node.data; + // Get the page reference after increaseCapacity - cursor may have moved + var page = &s.cursor.page_pin.node.data; try testing.expectEqual(max_styles, page.capacity.styles); - // Fill up all style slots - page.pauseIntegrityChecks(true); - var n: u24 = 1; - while (page.styles.add( - page.memory, - .{ .bg_color = .{ .rgb = @bitCast(n) } }, - )) |_| n += 1 else |_| {} - page.pauseIntegrityChecks(false); + // Fill the style map to capacity + { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + defer page.assertIntegrity(); - // Set a style that we can track as "prior" - // Use a style that's already in the page by reusing one we added - s.cursor.style = .{ .bg_color = .{ .rgb = @bitCast(@as(u24, 1)) } }; - const prior_id = page.styles.add(page.memory, s.cursor.style) catch unreachable; - s.cursor.style_id = prior_id; + var n: u24 = 1; + while (page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(n) } }, + )) |_| n += 1 else |_| {} + } + + // Track the node before setAttribute + const node_before_set = s.cursor.page_pin.node; // Now try to set a new unique attribute that would require a new style slot - // This should fail with OutOfSpace since we're at max capacity - const result = s.setAttribute(.bold); + // At max capacity, increaseCapacity will return OutOfSpace, triggering page split + try s.setAttribute(.bold); - // Should return OutOfSpace error - try testing.expectError(error.OutOfSpace, result); + // The style should have been applied (bold flag set) + try testing.expect(s.cursor.style.flags.bold); - // The cursor style_id should remain the prior one - try testing.expectEqual(prior_id, s.cursor.style_id); + // The cursor should have a valid non-default style_id + try testing.expect(s.cursor.style_id != style.default_id); + + // The page should have been split + const page_was_split = s.cursor.page_pin.node != node_before_set or + node_before_set.next != null or + node_before_set.prev != null or + s.cursor.page_pin.node != original_node; + try testing.expect(page_was_split); } From a8b31ceb8489d8e26a9a62155752672dcb4dcd0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Jan 2026 12:04:34 -0800 Subject: [PATCH 509/605] terminal: restoreCursor is now longer fallible We need to have sane behavior in error handling because the running program that sends the restore cursor command has no way to realize it failed. So if our style fails to add (our only fail case) then we revert to no style. https://ampcode.com/threads/T-019bd7dc-cf0b-7439-ad2f-218b3406277a --- src/terminal/Screen.zig | 32 +++++++---- src/terminal/Terminal.zig | 99 +++++++++++++++++++++++++------- src/terminal/stream_readonly.zig | 4 +- src/termio/stream_handler.zig | 4 +- 4 files changed, 103 insertions(+), 36 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5ea338b70..00bf4078d 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -9350,17 +9350,21 @@ test "Screen setAttribute increases capacity when style map is full" { const page = &s.cursor.page_pin.node.data; const original_styles_capacity = page.capacity.styles; - // Fill the style map to capacity + // Fill the style map to capacity using the StyleSet's layout capacity + // which accounts for the load factor { page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); defer page.assertIntegrity(); - var n: u24 = 1; - while (page.styles.add( - page.memory, - .{ .bg_color = .{ .rgb = @bitCast(n) } }, - )) |_| n += 1 else |_| {} + const max_items = page.styles.layout.cap; + var n: usize = 1; + while (n < max_items) : (n += 1) { + _ = page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } }, + ) catch break; + } } // Now try to set a new unique attribute that would require a new style slot @@ -9411,17 +9415,21 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { var page = &s.cursor.page_pin.node.data; try testing.expectEqual(max_styles, page.capacity.styles); - // Fill the style map to capacity + // Fill the style map to capacity using the StyleSet's layout capacity + // which accounts for the load factor { page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); defer page.assertIntegrity(); - var n: u24 = 1; - while (page.styles.add( - page.memory, - .{ .bg_color = .{ .rgb = @bitCast(n) } }, - )) |_| n += 1 else |_| {} + const max_items = page.styles.layout.cap; + var n: usize = 1; + while (n < max_items) : (n += 1) { + _ = page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } }, + ) catch break; + } } // Track the node before setAttribute diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 3740397d3..73571874e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -996,7 +996,7 @@ pub fn saveCursor(self: *Terminal) void { /// /// The primary and alternate screen have distinct save state. /// If no save was done before values are reset to their initial values. -pub fn restoreCursor(self: *Terminal) !void { +pub fn restoreCursor(self: *Terminal) void { const saved: Screen.SavedCursor = self.screens.active.saved_cursor orelse .{ .x = 0, .y = 0, @@ -1008,10 +1008,17 @@ pub fn restoreCursor(self: *Terminal) !void { }; // Set the style first because it can fail - const old_style = self.screens.active.cursor.style; self.screens.active.cursor.style = saved.style; - errdefer self.screens.active.cursor.style = old_style; - try self.screens.active.manualStyleUpdate(); + self.screens.active.manualStyleUpdate() catch |err| { + // Regardless of the error here, we revert back to an unstyled + // cursor. It is more important that the restore succeeds in + // other attributes because terminals have no way to communicate + // failure back. + log.warn("restoreCursor error updating style err={}", .{err}); + const screen: *Screen = self.screens.active; + screen.cursor.style = .{}; + screen.cursor.style_id = style.default_id; + }; self.screens.active.charset = saved.charset; self.modes.set(.origin, saved.origin); @@ -2747,12 +2754,7 @@ pub fn switchScreenMode( } } else { assert(self.screens.active_key == .primary); - self.restoreCursor() catch |err| { - log.warn( - "restore cursor on switch screen failed to={} err={}", - .{ to, err }, - ); - }; + self.restoreCursor(); }, } } @@ -4807,7 +4809,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(5, 0); - try t.restoreCursor(); + t.restoreCursor(); try t.horizontalTabBack(); try t.print('X'); @@ -9873,7 +9875,7 @@ test "Terminal: saveCursor" { t.screens.active.charset.gr = .G0; try t.setAttribute(.{ .unset = {} }); t.modes.set(.origin, false); - try t.restoreCursor(); + t.restoreCursor(); try testing.expect(t.screens.active.cursor.style.flags.bold); try testing.expect(t.screens.active.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); @@ -9889,7 +9891,7 @@ test "Terminal: saveCursor position" { t.saveCursor(); t.setCursorPos(1, 1); try t.print('B'); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -9909,7 +9911,7 @@ test "Terminal: saveCursor pending wrap state" { t.saveCursor(); t.setCursorPos(1, 1); try t.print('B'); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -9929,7 +9931,7 @@ test "Terminal: saveCursor origin mode" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(3, 5); t.setTopAndBottomMargin(2, 4); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -9947,7 +9949,7 @@ test "Terminal: saveCursor resize" { t.setCursorPos(1, 10); t.saveCursor(); try t.resize(alloc, 5, 5); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -9968,7 +9970,7 @@ test "Terminal: saveCursor protected pen" { t.saveCursor(); t.setProtectedMode(.off); try testing.expect(!t.screens.active.cursor.protected); - try t.restoreCursor(); + t.restoreCursor(); try testing.expect(t.screens.active.cursor.protected); } @@ -9981,10 +9983,67 @@ test "Terminal: saveCursor doesn't modify hyperlink state" { const id = t.screens.active.cursor.hyperlink_id; t.saveCursor(); try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); - try t.restoreCursor(); + t.restoreCursor(); try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); } +test "Terminal: restoreCursor uses default style on OutOfSpace" { + // Tests that restoreCursor falls back to default style when + // manualStyleUpdate fails with OutOfSpace (can't split a 1-row page + // and styles are at max capacity). + const alloc = testing.allocator; + + // Use a single row so the page can't be split + var t = try init(alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + // Set a style and save the cursor + try t.setAttribute(.{ .bold = {} }); + t.saveCursor(); + + // Clear the style + try t.setAttribute(.{ .unset = {} }); + try testing.expect(!t.screens.active.cursor.style.flags.bold); + + // Fill the style map to max capacity + const max_styles = std.math.maxInt(size.CellCountInt); + while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) { + _ = t.screens.active.increaseCapacity( + t.screens.active.cursor.page_pin.node, + .styles, + ) catch break; + } + + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(max_styles, page.capacity.styles); + + // Fill all style slots using the StyleSet's layout capacity which accounts + // for the load factor. The capacity in the layout is the actual max number + // of items that can be stored. + { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + defer page.assertIntegrity(); + + const max_items = page.styles.layout.cap; + var n: usize = 1; + while (n < max_items) : (n += 1) { + _ = page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } }, + ) catch break; + } + } + + // Restore cursor - should fall back to default style since page + // can't be split (1 row) and styles are at max capacity + t.restoreCursor(); + + // The style should be reset to default because OutOfSpace occurred + try testing.expect(!t.screens.active.cursor.style.flags.bold); + try testing.expectEqual(style.default_id, t.screens.active.cursor.style_id); +} + test "Terminal: setProtectedMode" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 3, .rows = 3 }); @@ -11376,7 +11435,7 @@ test "Terminal: resize with reflow and saved cursor" { t.saveCursor(); try t.resize(alloc, 5, 3); - try t.restoreCursor(); + t.restoreCursor(); { const str = try t.plainString(testing.allocator); @@ -11417,7 +11476,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { t.saveCursor(); try t.resize(alloc, 5, 3); - try t.restoreCursor(); + t.restoreCursor(); { const str = try t.plainString(testing.allocator); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 1ee4f3f08..9b4999116 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -125,7 +125,7 @@ pub const Handler = struct { } }, .save_cursor => self.terminal.saveCursor(), - .restore_cursor => try self.terminal.restoreCursor(), + .restore_cursor => self.terminal.restoreCursor(), .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), .configure_charset => self.terminal.configureCharset(value.slot, value.charset), .set_attribute => switch (value) { @@ -240,7 +240,7 @@ pub const Handler = struct { .save_cursor => if (enabled) { self.terminal.saveCursor(); } else { - try self.terminal.restoreCursor(); + self.terminal.restoreCursor(); }, .enable_mode_3 => {}, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 082a0fa10..2b92c19e3 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -721,7 +721,7 @@ pub const StreamHandler = struct { if (enabled) { self.terminal.saveCursor(); } else { - try self.terminal.restoreCursor(); + self.terminal.restoreCursor(); } }, @@ -933,7 +933,7 @@ pub const StreamHandler = struct { } pub inline fn restoreCursor(self: *StreamHandler) !void { - try self.terminal.restoreCursor(); + self.terminal.restoreCursor(); } pub fn enquiry(self: *StreamHandler) !void { From ae8d2c7a3e135b96ad7d3827dc1b8f92b49c8cd4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 19 Jan 2026 12:17:07 -0800 Subject: [PATCH 510/605] terminal: fix up some of the manual handling, comments --- src/terminal/Screen.zig | 21 +++++++++++---------- src/terminal/Terminal.zig | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 00bf4078d..d36cdac2a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1804,7 +1804,7 @@ pub fn setAttribute( self.manualStyleUpdate() catch |err| { log.warn("setAttribute error restoring old style after failure err={}", .{err}); self.cursor.style = .{}; - self.cursor.style_id = style.default_id; + self.manualStyleUpdate() catch unreachable; }; } @@ -1951,17 +1951,18 @@ pub fn setAttribute( /// Call this whenever you manually change the cursor style. /// +/// This function can NOT fail if the cursor style is changing to the +/// default style. +/// /// If this returns an error, the style change did not take effect and -/// the cursor style is reverted back to the default. +/// the cursor style is reverted back to the default. The only scenario +/// this returns an error is if there is a physical memory allocation failure +/// or if there is no possible way to increase style capacity to store +/// the style. /// -/// Note that this can return any PageList capacity error, because it -/// is possible for the internal pagelist to not accommodate the new style -/// at all. This WILL attempt to resize our internal pages to fit the style -/// but it is possible that it cannot be done, in which case upstream callers -/// need to split the page or do something else. -/// -/// NOTE(mitchellh): I think in the future we'll do page splitting -/// automatically here and remove this failure scenario. +/// This function WILL split pages as necessary to accommodate the new style. +/// So if OutOfSpace is returned, it means that even after splitting the page +/// there was still no room for the new style. pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { defer self.assertIntegrity(); var page: *Page = &self.cursor.page_pin.node.data; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 73571874e..7e02e3a24 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1017,7 +1017,7 @@ pub fn restoreCursor(self: *Terminal) void { log.warn("restoreCursor error updating style err={}", .{err}); const screen: *Screen = self.screens.active; screen.cursor.style = .{}; - screen.cursor.style_id = style.default_id; + self.screens.active.manualStyleUpdate() catch unreachable; }; self.screens.active.charset = saved.charset; From 1e41d87709a3047cd05893c4f7aae4d01b04f425 Mon Sep 17 00:00:00 2001 From: Steven Lu Date: Tue, 20 Jan 2026 11:45:01 +0700 Subject: [PATCH 511/605] hope to fix --- src/apprt/gtk/class/application.zig | 4 ++-- src/apprt/gtk/class/split_tree.zig | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0a336fd79..6a07cab84 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2406,7 +2406,7 @@ const Action = struct { // If the tree has no splits (only one leaf), this action is not performable. // This allows the key event to pass through to the terminal. - if (!tree.isSplit()) return false; + if (!tree.getIsSplit()) return false; return tree.resize( switch (value.direction) { @@ -2564,7 +2564,7 @@ const Action = struct { // If the tree has no splits (only one leaf), this action is not performable. // This allows the key event to pass through to the terminal. - if (!tree.isSplit()) return false; + if (!tree.getIsSplit()) return false; return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0; }, diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index 8d859adca..0ff7e6044 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -561,7 +561,7 @@ pub const SplitTree = extern struct { )); } - fn getIsSplit(self: *Self) bool { + pub fn getIsSplit(self: *Self) bool { const tree: *const Surface.Tree = self.private().tree orelse &.empty; if (tree.isEmpty()) return false; From bff21222d438a7454b8d55c1e0afc72d85be599a Mon Sep 17 00:00:00 2001 From: evertonstz Date: Tue, 20 Jan 2026 12:12:04 -0300 Subject: [PATCH 512/605] Refactor gsettings keys to use string literals for GTK settings and update related tests --- src/apprt/gtk/class/surface.zig | 4 +-- src/apprt/gtk/gsettings.zig | 64 +++++++++++++++------------------ 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index ea5ca203f..56c39a86f 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1516,7 +1516,7 @@ pub const Surface = extern struct { const xft_dpi_scale = xft_scale: { // gtk-xft-dpi is font DPI multiplied by 1024. See // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const gtk_xft_dpi = gsettings.get(.gtk_xft_dpi) orelse { + const gtk_xft_dpi = gsettings.get(.@"gtk-xft-dpi") orelse { log.warn("gtk-xft-dpi was not set, using default value", .{}); break :xft_scale 1.0; }; @@ -1772,7 +1772,7 @@ pub const Surface = extern struct { // Read GNOME desktop interface settings for primary paste (middle-click) // This is only relevant on Linux systems with GNOME settings available - priv.gtk_enable_primary_paste = gsettings.get(.gtk_enable_primary_paste) orelse blk: { + priv.gtk_enable_primary_paste = gsettings.get(.@"gtk-enable-primary-paste") orelse blk: { log.warn("gtk-enable-primary-paste was not set, using default value", .{}); break :blk false; }; diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig index f96e9f01c..3fc3d01de 100644 --- a/src/apprt/gtk/gsettings.zig +++ b/src/apprt/gtk/gsettings.zig @@ -4,42 +4,34 @@ const gobject = @import("gobject"); /// GTK Settings keys with well-defined types. pub const Key = enum { - gtk_enable_primary_paste, - gtk_xft_dpi, - gtk_font_name, + @"gtk-enable-primary-paste", + @"gtk-xft-dpi", + @"gtk-font-name", fn Type(comptime self: Key) type { return switch (self) { - .gtk_enable_primary_paste => bool, - .gtk_xft_dpi => c_int, - .gtk_font_name => []const u8, + .@"gtk-enable-primary-paste" => bool, + .@"gtk-xft-dpi" => c_int, + .@"gtk-font-name" => []const u8, }; } fn GValueType(comptime self: Key) type { return switch (self) { // Booleans are stored as integers in GTK's internal representation - .gtk_enable_primary_paste, + .@"gtk-enable-primary-paste", => c_int, // Integer types - .gtk_xft_dpi, + .@"gtk-xft-dpi", => c_int, // String types (returned as null-terminated C strings from GTK) - .gtk_font_name, + .@"gtk-font-name", => ?[*:0]const u8, }; } - fn propertyName(comptime self: Key) [*:0]const u8 { - return switch (self) { - .gtk_enable_primary_paste => "gtk-enable-primary-paste", - .gtk_xft_dpi => "gtk-xft-dpi", - .gtk_font_name => "gtk-font-name", - }; - } - /// Returns true if this setting type requires memory allocation. /// this is defensive: types that do not need allocation need to be /// explicitly marked here @@ -58,8 +50,8 @@ pub const Key = enum { /// No allocator is required or used. Returns null if the setting is not available or cannot be read. /// /// Example usage: -/// const enabled = get(.gtk_enable_primary_paste); -/// const dpi = get(.gtk_xft_dpi); +/// const enabled = get(.@"gtk-enable-primary-paste"); +/// const dpi = get(.@"gtk-xft-dpi"); pub fn get(comptime key: Key) ?key.Type() { if (comptime key.requiresAllocation()) { @compileError("Allocating types require an allocator; use getAlloc() instead"); @@ -92,20 +84,20 @@ fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: var value = gobject.ext.Value.new(GValType); defer value.unset(); - settings.as(gobject.Object).getProperty(key.propertyName(), &value); + settings.as(gobject.Object).getProperty(@tagName(key).ptr, &value); return switch (key) { // Booleans are stored as integers in GTK, convert to bool - .gtk_enable_primary_paste, + .@"gtk-enable-primary-paste", => value.getInt() != 0, // Integer types are returned directly - .gtk_xft_dpi, + .@"gtk-xft-dpi", => value.getInt(), // Strings: GTK owns the GValue's pointer, so we must duplicate it // before the GValue is destroyed by defer value.unset() - .gtk_font_name, + .@"gtk-font-name", => blk: { // This is defensive: we have already checked at compile-time that // an allocator is provided for allocating types @@ -118,25 +110,25 @@ fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: } test "Key.Type returns correct types" { - try std.testing.expectEqual(bool, Key.gtk_enable_primary_paste.Type()); - try std.testing.expectEqual(c_int, Key.gtk_xft_dpi.Type()); - try std.testing.expectEqual([]const u8, Key.gtk_font_name.Type()); + try std.testing.expectEqual(bool, Key.@"gtk-enable-primary-paste".Type()); + try std.testing.expectEqual(c_int, Key.@"gtk-xft-dpi".Type()); + try std.testing.expectEqual([]const u8, Key.@"gtk-font-name".Type()); } test "Key.requiresAllocation identifies allocating types" { - try std.testing.expectEqual(false, Key.gtk_enable_primary_paste.requiresAllocation()); - try std.testing.expectEqual(false, Key.gtk_xft_dpi.requiresAllocation()); - try std.testing.expectEqual(true, Key.gtk_font_name.requiresAllocation()); + try std.testing.expectEqual(false, Key.@"gtk-enable-primary-paste".requiresAllocation()); + try std.testing.expectEqual(false, Key.@"gtk-xft-dpi".requiresAllocation()); + try std.testing.expectEqual(true, Key.@"gtk-font-name".requiresAllocation()); } test "Key.GValueType returns correct GObject types" { - try std.testing.expectEqual(c_int, Key.gtk_enable_primary_paste.GValueType()); - try std.testing.expectEqual(c_int, Key.gtk_xft_dpi.GValueType()); - try std.testing.expectEqual(?[*:0]const u8, Key.gtk_font_name.GValueType()); + try std.testing.expectEqual(c_int, Key.@"gtk-enable-primary-paste".GValueType()); + try std.testing.expectEqual(c_int, Key.@"gtk-xft-dpi".GValueType()); + try std.testing.expectEqual(?[*:0]const u8, Key.@"gtk-font-name".GValueType()); } -test "Key.propertyName returns correct GTK property names" { - try std.testing.expectEqualSlices(u8, "gtk-enable-primary-paste", std.mem.span(Key.gtk_enable_primary_paste.propertyName())); - try std.testing.expectEqualSlices(u8, "gtk-xft-dpi", std.mem.span(Key.gtk_xft_dpi.propertyName())); - try std.testing.expectEqualSlices(u8, "gtk-font-name", std.mem.span(Key.gtk_font_name.propertyName())); +test "@tagName returns correct GTK property names" { + try std.testing.expectEqualStrings("gtk-enable-primary-paste", @tagName(Key.@"gtk-enable-primary-paste")); + try std.testing.expectEqualStrings("gtk-xft-dpi", @tagName(Key.@"gtk-xft-dpi")); + try std.testing.expectEqualStrings("gtk-font-name", @tagName(Key.@"gtk-font-name")); } From f2b5a9192a85c78b94fff03869fce147856a9d41 Mon Sep 17 00:00:00 2001 From: Tommy Brunn Date: Tue, 20 Jan 2026 17:05:14 +0100 Subject: [PATCH 513/605] gtk: Clean up title sorting --- src/apprt/gtk/class/command_palette.zig | 27 ++++++++----------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index af1ef8e5e..ea214bf6d 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -250,12 +250,10 @@ pub const CommandPalette = extern struct { const b_title = b.propGetTitle() orelse return true; // Compare case-insensitively with colon normalization - var i: usize = 0; - var j: usize = 0; - while (i < a_title.len and j < b_title.len) { + for (0..@min(a_title.len, b_title.len)) |i| { // Get characters, replacing ':' with '\t' const a_char = if (a_title[i] == ':') '\t' else a_title[i]; - const b_char = if (b_title[j] == ':') '\t' else b_title[j]; + const b_char = if (b_title[i] == ':') '\t' else b_title[i]; const a_lower = std.ascii.toLower(a_char); const b_lower = std.ascii.toLower(b_char); @@ -263,9 +261,6 @@ pub const CommandPalette = extern struct { if (a_lower != b_lower) { return a_lower < b_lower; } - - i += 1; - j += 1; } // If one title is a prefix of the other, shorter one comes first @@ -273,23 +268,17 @@ pub const CommandPalette = extern struct { return a_title.len < b_title.len; } - // Titles are equal - use sort_key as tie-breaker if both have one - const a_priv = a.private(); - const b_priv = b.private(); - - const a_sort_key = switch (a_priv.data) { - .regular => 0, + // Titles are equal - use sort_key as tie-breaker if both are jump commands + const a_sort_key = switch (a.private().data) { + .regular => return false, .jump => |*ja| ja.sort_key, }; - const b_sort_key = switch (b_priv.data) { - .regular => 0, + const b_sort_key = switch (b.private().data) { + .regular => return false, .jump => |*jb| jb.sort_key, }; - if (a_sort_key != 0 and b_sort_key != 0) { - return a_sort_key < b_sort_key; - } - return false; + return a_sort_key < b_sort_key; } fn close(self: *CommandPalette) void { From 0c8b51c7ab95633a0c5c5f6e6397136736960d18 Mon Sep 17 00:00:00 2001 From: Tommy Brunn Date: Tue, 20 Jan 2026 17:09:15 +0100 Subject: [PATCH 514/605] gtk: Add todo for replacing jump sort key with surface id Using a pointer for this is a bit icky. Once Ghostty adds unique ids to surfaces, we can sort by that id instead. This can potentially also be used to navigate to the surface instead of having the command palette reference the surfaces directly. --- src/apprt/gtk/class/command_palette.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig index ea214bf6d..0d91c43b2 100644 --- a/src/apprt/gtk/class/command_palette.zig +++ b/src/apprt/gtk/class/command_palette.zig @@ -582,6 +582,7 @@ const Command = extern struct { const priv = self.private(); priv.data = .{ .jump = .{ + // TODO: Replace with surface id whenever Ghostty adds one .sort_key = @intFromPtr(surface), }, }; From 2d7305a16a9e5e5693ecd9afad3c36809e82676e Mon Sep 17 00:00:00 2001 From: Steven Lu Date: Wed, 21 Jan 2026 00:02:06 +0700 Subject: [PATCH 515/605] this appears to fix the crash. --- src/datastruct/split_tree.zig | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index e3be5b49f..b340cb608 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -773,9 +773,9 @@ pub fn SplitTree(comptime V: type) type { /// Resize the nearest split matching the layout by the given ratio. /// Positive is right and down. /// - /// The ratio is a value between 0 and 1 representing the percentage - /// to move the divider in the given direction. The percentage is - /// of the entire grid size, not just the specific split size. + /// The ratio is a signed delta representing the percentage to move + /// the divider. The percentage is of the entire grid size, not just + /// the specific split size. /// We use the entire grid size because that's what Ghostty's /// `resize_split` keybind does, because it maps to a general human /// understanding of moving a split relative to the entire window @@ -794,7 +794,6 @@ pub fn SplitTree(comptime V: type) type { layout: Split.Layout, ratio: f16, ) Allocator.Error!Self { - assert(ratio >= 0 and ratio <= 1); assert(!std.math.isNan(ratio)); assert(!std.math.isInf(ratio)); @@ -2050,6 +2049,32 @@ test "SplitTree: resize" { \\ ); } + + // Resize the other direction (negative ratio) + { + var resized = try split.resize( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .horizontal, // resize left + -0.25, + ); + defer resized.deinit(); + const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++-------------+ + \\| A || B | + \\+---++-------------+ + \\ + ); + } } test "SplitTree: clone empty tree" { From 9c5d4a5511c81fb3fe1e8b6b420af438e58685f0 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:27:14 +0200 Subject: [PATCH 516/605] ghosttyKit: add `ghostty_config_load_file` --- include/ghostty.h | 1 + src/config/CApi.zig | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 0133fac73..3d3973084 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1024,6 +1024,7 @@ ghostty_config_t ghostty_config_new(); void ghostty_config_free(ghostty_config_t); ghostty_config_t ghostty_config_clone(ghostty_config_t); void ghostty_config_load_cli_args(ghostty_config_t); +void ghostty_config_load_file(ghostty_config_t, const char*); void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t); void ghostty_config_finalize(ghostty_config_t); diff --git a/src/config/CApi.zig b/src/config/CApi.zig index a970a8d33..4ea9ea63f 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -65,6 +65,15 @@ export fn ghostty_config_load_default_files(self: *Config) void { }; } +/// Load the configuration from a specific file path. +/// The path must be null-terminated. +export fn ghostty_config_load_file(self: *Config, path: [*:0]const u8) void { + const path_slice = std.mem.span(path); + self.loadFile(state.alloc, path_slice) catch |err| { + log.err("error loading config from file path={s} err={}", .{ path_slice, err }); + }; +} + /// Load the configuration from the user-specified configuration /// file locations in the previously loaded configuration. This will /// recursively continue to load up to a built-in limit. From 4f667520faf7a45d7bdd37575195d4108ca4841e Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:27:49 +0200 Subject: [PATCH 517/605] macOS: GhosttyUITests --- macos/Ghostty.xcodeproj/project.pbxproj | 144 +++++++++++++++++- .../xcshareddata/xcschemes/Ghostty.xcscheme | 11 ++ .../GhosttyUITests/GhosttyTitleUITests.swift | 39 +++++ macos/Sources/App/macOS/AppDelegate.swift | 7 +- macos/Sources/Ghostty/Ghostty.App.swift | 37 ++++- macos/Sources/Ghostty/Ghostty.Config.swift | 14 +- 6 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 macos/GhosttyUITests/GhosttyTitleUITests.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0b3432362..d903c8815 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -28,6 +28,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A5B30529299BEAAA0047F10C /* Project object */; + proxyType = 1; + remoteGlobalIDString = A5B30530299BEAAA0047F10C; + remoteInfo = Ghostty; + }; A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A5B30529299BEAAA0047F10C /* Project object */; @@ -42,6 +49,7 @@ 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; + 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = ""; }; @@ -198,11 +206,19 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = ""; }; 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 810ACC9C2E9D3301004F8F92 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45F02E1F047A0046BD5C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -259,6 +275,7 @@ 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, 81F82BC72E82815D001EDFA7 /* Sources */, A54F45F42E1F047A0046BD5C /* Tests */, + 810ACCA02E9D3302004F8F92 /* GhosttyUITests */, A5D495A3299BECBA00DD1313 /* Frameworks */, A5A1F8862A489D7400D1E8BC /* Resources */, A5B30532299BEAAA0047F10C /* Products */, @@ -271,6 +288,7 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */, A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */, A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */, + 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */, ); name = Products; sourceTree = ""; @@ -287,6 +305,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 810ACC9E2E9D3301004F8F92 /* GhosttyUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */; + buildPhases = ( + 810ACC9B2E9D3301004F8F92 /* Sources */, + 810ACC9C2E9D3301004F8F92 /* Frameworks */, + 810ACC9D2E9D3301004F8F92 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 810ACCA62E9D3302004F8F92 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 810ACCA02E9D3302004F8F92 /* GhosttyUITests */, + ); + name = GhosttyUITests; + packageProductDependencies = ( + ); + productName = GhosttyUITests; + productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; A54F45F22E1F047A0046BD5C /* GhosttyTests */ = { isa = PBXNativeTarget; buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */; @@ -360,9 +401,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2600; + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 1610; TargetAttributes = { + 810ACC9E2E9D3301004F8F92 = { + CreatedOnToolsVersion = 26.1; + TestTargetID = A5B30530299BEAAA0047F10C; + }; A54F45F22E1F047A0046BD5C = { CreatedOnToolsVersion = 26.0; TestTargetID = A5B30530299BEAAA0047F10C; @@ -395,11 +440,19 @@ A5B30530299BEAAA0047F10C /* Ghostty */, A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */, A54F45F22E1F047A0046BD5C /* GhosttyTests */, + 810ACC9E2E9D3301004F8F92 /* GhosttyUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 810ACC9D2E9D3301004F8F92 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45F12E1F047A0046BD5C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -438,6 +491,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 810ACC9B2E9D3301004F8F92 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45EF2E1F047A0046BD5C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -462,6 +522,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 810ACCA62E9D3302004F8F92 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A5B30530299BEAAA0047F10C /* Ghostty */; + targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */; + }; A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A5B30530299BEAAA0047F10C /* Ghostty */; @@ -579,6 +644,73 @@ }; name = ReleaseLocal; }; + 810ACCA82E9D3302004F8F92 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Ghostty; + }; + name = Debug; + }; + 810ACCA92E9D3302004F8F92 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Ghostty; + }; + name = Release; + }; + 810ACCAA2E9D3302004F8F92 /* ReleaseLocal */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Ghostty; + }; + name = ReleaseLocal; + }; A54F45F92E1F047A0046BD5C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -995,6 +1127,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 810ACCA72E9D3302004F8F92 /* Build configuration list for PBXNativeTarget "GhosttyUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 810ACCA82E9D3302004F8F92 /* Debug */, + 810ACCA92E9D3302004F8F92 /* Release */, + 810ACCAA2E9D3302004F8F92 /* ReleaseLocal */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = ReleaseLocal; + }; A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index 0d8761c9e..2b4f815ea 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -40,6 +40,17 @@ ReferencedContainer = "container:Ghostty.xcodeproj"> + + + + Ghostty.Config? { + guard + let cfg = ghostty_config_new() + else { + return nil + } + if FileManager.default.fileExists(atPath: path) { + ghostty_config_load_file(cfg, path) + } + if !isRunningInXcode() { + ghostty_config_load_cli_args(cfg) + } + ghostty_config_load_recursive_files(cfg) + if finalize { + // Finalize will make our defaults available, + // and also will combine all the keys into one file, + // we might not need this in the future + ghostty_config_finalize(cfg) + } + return Ghostty.Config(config: cfg) + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b3a8700e9..fb435c014 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -33,14 +33,16 @@ extension Ghostty { return diags } - init() { - if let cfg = Self.loadConfig() { - self.config = cfg - } + init(config: ghostty_config_t?) { + self.config = config } - init(clone config: ghostty_config_t) { - self.config = ghostty_config_clone(config) + convenience init() { + self.init(config: Self.loadConfig()) + } + + convenience init(clone config: ghostty_config_t) { + self.init(config: ghostty_config_clone(config)) } deinit { From a94a6e4b36b7702a397b9dc47b31bc3bf212937b Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:15:23 +0200 Subject: [PATCH 518/605] build: fix Ghostty-iOS compiling --- macos/Sources/Ghostty/Ghostty.App.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 06606fae4..8e32b82af 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -2088,9 +2088,11 @@ extension Ghostty.App { if FileManager.default.fileExists(atPath: path) { ghostty_config_load_file(cfg, path) } +#if os(macOS) if !isRunningInXcode() { ghostty_config_load_cli_args(cfg) } +#endif ghostty_config_load_recursive_files(cfg) if finalize { // Finalize will make our defaults available, From bcf42dde6c6642eb5f9d53aa2676f642bb9ccb3a Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:18:18 +0200 Subject: [PATCH 519/605] build: change deployment target --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d903c8815..adcc107e1 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -653,7 +653,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.1; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -676,7 +676,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.1; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -698,7 +698,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.1; + MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; From b1290dc47b0521fd7c9085e4b7c7043048216903 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:07:05 +0200 Subject: [PATCH 520/605] macOS: add titlebar tabs tests --- .../GhosttyCustomConfigCase.swift | 43 +++++++ .../GhosttyUITests/GhosttyTitleUITests.swift | 24 +--- .../GhosttyTitlebarTabsUITests.swift | 121 ++++++++++++++++++ 3 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 macos/GhosttyUITests/GhosttyCustomConfigCase.swift create mode 100644 macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift new file mode 100644 index 000000000..a944e9f52 --- /dev/null +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -0,0 +1,43 @@ +// +// GhosttyCustomConfigCase.swift +// Ghostty +// +// Created by luca on 16.10.2025. +// + +import XCTest + +class GhosttyCustomConfigCase: XCTestCase { + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + var configFile: URL? + override func setUpWithError() throws { + continueAfterFailure = false + guard let customGhosttyConfig else { + return + } + let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("ghostty") + try customGhosttyConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) + configFile = temporaryConfig + } + + override func tearDown() async throws { + if let configFile { + try FileManager.default.removeItem(at: configFile) + } + } + + var customGhosttyConfig: String? { + nil + } + + func ghosttyApplication() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile?.path + app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) + return app + } +} diff --git a/macos/GhosttyUITests/GhosttyTitleUITests.swift b/macos/GhosttyUITests/GhosttyTitleUITests.swift index 2b212212f..5e9ade1c9 100644 --- a/macos/GhosttyUITests/GhosttyTitleUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitleUITests.swift @@ -7,31 +7,15 @@ import XCTest -final class GhosttyTitleUITests: XCTestCase { +final class GhosttyTitleUITests: GhosttyCustomConfigCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - var configFile: URL? - override func setUpWithError() throws { - continueAfterFailure = false - let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - .appendingPathExtension("ghostty") - try #"title = "GhosttyUITestsLaunchTests""#.write(to: temporaryConfig, atomically: true, encoding: .utf8) - configFile = temporaryConfig - } - - override func tearDown() async throws { - if let configFile { - try FileManager.default.removeItem(at: configFile) - } + override var customGhosttyConfig: String? { + #"title = "GhosttyUITestsLaunchTests""# } @MainActor func testTitle() throws { - let app = XCUIApplication() - app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile?.path + let app = ghosttyApplication() app.launch() XCTAssert(app.windows.firstMatch.title == "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!") diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift new file mode 100644 index 000000000..13319c07e --- /dev/null +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -0,0 +1,121 @@ +// +// GhosttyTitlebarTabsUITests.swift +// Ghostty +// +// Created by luca on 16.10.2025. +// + +import XCTest + +final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { + override var customGhosttyConfig: String? { + """ + macos-titlebar-style = tabs + title = "GhosttyTitlebarTabsUITests" + """ + } + + @MainActor + func testCustomTitlebar() throws { + let app = ghosttyApplication() + app.launch() + // create a split + app.groups["Terminal pane"].typeKey("d", modifierFlags: .command) + app.typeKey("\n", modifierFlags: [.command, .shift]) + let resetZoomButton = app.groups.buttons["ResetZoom"] + let windowTitle = app.windows.firstMatch.title + let titleView = app.staticTexts.element(matching: NSPredicate(format: "value == '\(windowTitle)'")) + + XCTAssertEqual(titleView.frame.midY, resetZoomButton.frame.midY, accuracy: 1, "Window title should be vertically centered with reset zoom button: \(titleView.frame.midY) != \(resetZoomButton.frame.midY)") + } + + @MainActor + func testTabsGeometryInNormalWindow() throws { + let app = ghosttyApplication() + app.launch() + app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) + XCTAssert(app.tabs.count == 2, "There should be 2 tabs") + checkTabsGeometry(app.windows.element(boundBy: 0)) + } + + @MainActor + func testTabsGeometryInFullscreen() throws { + let app = ghosttyApplication() + app.launch() + app.typeKey("f", modifierFlags: [.command, .control]) + // using app to type ⌘+t might not be able to create tabs + app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) + XCTAssert(app.tabs.count == 2, "There should be 2 tabs") + checkTabsGeometry(app.windows.element(boundBy: 0)) + } + + @MainActor + func testTabsGeometryAfterMovingTabs() throws { + let app = ghosttyApplication() + app.launch() + // create 3 tabs + app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) + app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) + + // move to the left + app.menuItems["_zoomLeft:"].firstMatch.click() + + // create another window with 2 tabs + app.windows.element(boundBy: 0).groups["Terminal pane"].typeKey("n", modifierFlags: .command) + XCTAssert(app.windows.count == 2, "There should be 2 windows") + + // move to the right + app.menuItems["_zoomRight:"].firstMatch.click() + + // now second window is the first/main one in the list + app.windows.element(boundBy: 0).groups["Terminal pane"].typeKey("t", modifierFlags: .command) + + app.windows.element(boundBy: 1).tabs.element(boundBy: 0).click() // focus first window + + // now the first window is the main one + let firstTabInFirstWindow = app.windows.element(boundBy: 0).tabs.element(boundBy: 0) + let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.element(boundBy: 0) + + // drag a tab from one window to another + firstTabInFirstWindow.press(forDuration: 1, thenDragTo: firstTabInSecondWindow) + + // check tabs in the first + checkTabsGeometry(app.windows.firstMatch) + // focus another window + app.windows.element(boundBy: 1).click() + checkTabsGeometry(app.windows.firstMatch) + } + + func checkTabsGeometry(_ window: XCUIElement) { + let closeTabButtons = window.buttons.matching(identifier: "_closeButton") + + XCTAssert(closeTabButtons.count == window.tabs.count, "Close tab buttons count should match tabs count") + + var previousTabHeight: CGFloat? + for idx in 0 ..< window.tabs.count { + let currentTab = window.tabs.element(boundBy: idx) + // focus + currentTab.click() + // switch to the tab + window.typeKey("\(idx + 1)", modifierFlags: .command) + // add a split + window.typeKey("d", modifierFlags: .command) + // zoom this split + // haven't found a way to locate our reset zoom button yet.. + window.typeKey("\n", modifierFlags: [.command, .shift]) + window.typeKey("\n", modifierFlags: [.command, .shift]) + + if let previousHeight = previousTabHeight { + XCTAssertEqual(currentTab.frame.height, previousHeight, accuracy: 1, "The tab's height should stay the same") + } + previousTabHeight = currentTab.frame.height + + let titleFrame = currentTab.frame + let shortcutLabelFrame = window.staticTexts.element(matching: NSPredicate(format: "value CONTAINS[c] '⌘\(idx + 1)'")).firstMatch.frame + let closeButtonFrame = closeTabButtons.element(boundBy: idx).frame + + XCTAssertEqual(titleFrame.midY, shortcutLabelFrame.midY, accuracy: 1, "Tab title should be vertically centered with its shortcut label: \(titleFrame.midY) != \(shortcutLabelFrame.midY)") + XCTAssertEqual(titleFrame.midY, closeButtonFrame.midY, accuracy: 1, "Tab title should be vertically centered with its close button: \(titleFrame.midY) != \(closeButtonFrame.midY)") + } + } +} From f7608d0b95360a546b1b25d33f8bfb05e0eb6e04 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 16 Oct 2025 21:11:51 +0200 Subject: [PATCH 521/605] macOS: reduce press duration --- macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index 13319c07e..f6d4563cc 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -77,7 +77,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.element(boundBy: 0) // drag a tab from one window to another - firstTabInFirstWindow.press(forDuration: 1, thenDragTo: firstTabInSecondWindow) + firstTabInFirstWindow.press(forDuration: 0.2, thenDragTo: firstTabInSecondWindow) // check tabs in the first checkTabsGeometry(app.windows.firstMatch) From f088ce38d9f736130ff3b8f1a11aa0d353a27f1c Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Fri, 17 Oct 2025 00:09:25 +0200 Subject: [PATCH 522/605] test: read config before launching XCUIApplication --- .../GhosttyCustomConfigCase.swift | 18 +++++++++--------- macos/GhosttyUITests/GhosttyTitleUITests.swift | 2 +- .../GhosttyTitlebarTabsUITests.swift | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index a944e9f52..112dc1da4 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -15,13 +15,6 @@ class GhosttyCustomConfigCase: XCTestCase { var configFile: URL? override func setUpWithError() throws { continueAfterFailure = false - guard let customGhosttyConfig else { - return - } - let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - .appendingPathExtension("ghostty") - try customGhosttyConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) - configFile = temporaryConfig } override func tearDown() async throws { @@ -34,10 +27,17 @@ class GhosttyCustomConfigCase: XCTestCase { nil } - func ghosttyApplication() -> XCUIApplication { + func ghosttyApplication() throws -> XCUIApplication { let app = XCUIApplication() - app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile?.path app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) + guard let customGhosttyConfig else { + return app + } + let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("ghostty") + try customGhosttyConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) + configFile = temporaryConfig + app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile?.path return app } } diff --git a/macos/GhosttyUITests/GhosttyTitleUITests.swift b/macos/GhosttyUITests/GhosttyTitleUITests.swift index 5e9ade1c9..904f54220 100644 --- a/macos/GhosttyUITests/GhosttyTitleUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitleUITests.swift @@ -15,7 +15,7 @@ final class GhosttyTitleUITests: GhosttyCustomConfigCase { @MainActor func testTitle() throws { - let app = ghosttyApplication() + let app = try ghosttyApplication() app.launch() XCTAssert(app.windows.firstMatch.title == "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!") diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index f6d4563cc..949abe7e1 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -17,7 +17,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { @MainActor func testCustomTitlebar() throws { - let app = ghosttyApplication() + let app = try ghosttyApplication() app.launch() // create a split app.groups["Terminal pane"].typeKey("d", modifierFlags: .command) @@ -31,7 +31,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { @MainActor func testTabsGeometryInNormalWindow() throws { - let app = ghosttyApplication() + let app = try ghosttyApplication() app.launch() app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) XCTAssert(app.tabs.count == 2, "There should be 2 tabs") @@ -40,7 +40,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { @MainActor func testTabsGeometryInFullscreen() throws { - let app = ghosttyApplication() + let app = try ghosttyApplication() app.launch() app.typeKey("f", modifierFlags: [.command, .control]) // using app to type ⌘+t might not be able to create tabs @@ -51,7 +51,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { @MainActor func testTabsGeometryAfterMovingTabs() throws { - let app = ghosttyApplication() + let app = try ghosttyApplication() app.launch() // create 3 tabs app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) From f52013787e9197334e9b1b2de4edb6094a7990c7 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Fri, 17 Oct 2025 00:27:18 +0200 Subject: [PATCH 523/605] test: using XCTAssertEqual to get more information --- .../GhosttyCustomConfigCase.swift | 3 +++ .../GhosttyUITests/GhosttyTitleUITests.swift | 2 +- .../GhosttyTitlebarTabsUITests.swift | 24 ++++++++++--------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index 112dc1da4..d4e2e5b89 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -33,6 +33,9 @@ class GhosttyCustomConfigCase: XCTestCase { guard let customGhosttyConfig else { return app } + if let configFile { + try FileManager.default.removeItem(at: configFile) + } let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) .appendingPathExtension("ghostty") try customGhosttyConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) diff --git a/macos/GhosttyUITests/GhosttyTitleUITests.swift b/macos/GhosttyUITests/GhosttyTitleUITests.swift index 904f54220..e6f6d67e6 100644 --- a/macos/GhosttyUITests/GhosttyTitleUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitleUITests.swift @@ -18,6 +18,6 @@ final class GhosttyTitleUITests: GhosttyCustomConfigCase { let app = try ghosttyApplication() app.launch() - XCTAssert(app.windows.firstMatch.title == "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!") + XCTAssertEqual(app.windows.firstMatch.title, "GhosttyUITestsLaunchTests", "Oops, `title=` doesn't work!") } } diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index 949abe7e1..7e7b5edec 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -34,8 +34,8 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { let app = try ghosttyApplication() app.launch() app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) - XCTAssert(app.tabs.count == 2, "There should be 2 tabs") - checkTabsGeometry(app.windows.element(boundBy: 0)) + XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs") + checkTabsGeometry(app.windows.firstMatch) } @MainActor @@ -45,14 +45,15 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { app.typeKey("f", modifierFlags: [.command, .control]) // using app to type ⌘+t might not be able to create tabs app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) - XCTAssert(app.tabs.count == 2, "There should be 2 tabs") - checkTabsGeometry(app.windows.element(boundBy: 0)) + XCTAssertEqual(app.tabs.count, 2, "There should be 2 tabs") + checkTabsGeometry(app.windows.firstMatch) } @MainActor func testTabsGeometryAfterMovingTabs() throws { let app = try ghosttyApplication() app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist") // create 3 tabs app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) @@ -61,20 +62,20 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { app.menuItems["_zoomLeft:"].firstMatch.click() // create another window with 2 tabs - app.windows.element(boundBy: 0).groups["Terminal pane"].typeKey("n", modifierFlags: .command) - XCTAssert(app.windows.count == 2, "There should be 2 windows") + app.windows.firstMatch.groups["Terminal pane"].typeKey("n", modifierFlags: .command) + XCTAssertEqual(app.windows.count, 2, "There should be 2 windows") // move to the right app.menuItems["_zoomRight:"].firstMatch.click() // now second window is the first/main one in the list - app.windows.element(boundBy: 0).groups["Terminal pane"].typeKey("t", modifierFlags: .command) + app.windows.firstMatch.groups["Terminal pane"].typeKey("t", modifierFlags: .command) - app.windows.element(boundBy: 1).tabs.element(boundBy: 0).click() // focus first window + app.windows.element(boundBy: 1).tabs.firstMatch.click() // focus first window // now the first window is the main one - let firstTabInFirstWindow = app.windows.element(boundBy: 0).tabs.element(boundBy: 0) - let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.element(boundBy: 0) + let firstTabInFirstWindow = app.windows.firstMatch.tabs.firstMatch + let firstTabInSecondWindow = app.windows.element(boundBy: 1).tabs.firstMatch // drag a tab from one window to another firstTabInFirstWindow.press(forDuration: 0.2, thenDragTo: firstTabInSecondWindow) @@ -89,11 +90,12 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { func checkTabsGeometry(_ window: XCUIElement) { let closeTabButtons = window.buttons.matching(identifier: "_closeButton") - XCTAssert(closeTabButtons.count == window.tabs.count, "Close tab buttons count should match tabs count") + XCTAssertEqual(closeTabButtons.count, window.tabs.count, "Close tab buttons count should match tabs count") var previousTabHeight: CGFloat? for idx in 0 ..< window.tabs.count { let currentTab = window.tabs.element(boundBy: idx) + XCTAssertTrue(currentTab.waitForExistence(timeout: 1)) // focus currentTab.click() // switch to the tab From 6ada9c784438c654d82f9557ad619731286cbe18 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:14:35 +0200 Subject: [PATCH 524/605] test: add test for mergeAllWindows --- .../GhosttyTitlebarTabsUITests.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index 7e7b5edec..29694a96b 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -54,7 +54,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { let app = try ghosttyApplication() app.launch() XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist") - // create 3 tabs + // create another 2 tabs app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) app.groups["Terminal pane"].typeKey("t", modifierFlags: .command) @@ -87,6 +87,23 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { checkTabsGeometry(app.windows.firstMatch) } + @MainActor + func testTabsGeometryAfterMergingAllWindows() throws { + let app = try ghosttyApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 1), "Main window should exist") + + // create another 2 windows + app.typeKey("n", modifierFlags: .command) + app.typeKey("n", modifierFlags: .command) + + // merge into one window, resulting 3 tabs + app.menuItems["mergeAllWindows:"].firstMatch.click() + + XCTAssertTrue(app.wait(for: \.tabs.count, toEqual: 3, timeout: 1), "There should be 3 tabs") + checkTabsGeometry(app.windows.firstMatch) + } + func checkTabsGeometry(_ window: XCUIElement) { let closeTabButtons = window.buttons.matching(identifier: "_closeButton") @@ -95,7 +112,6 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { var previousTabHeight: CGFloat? for idx in 0 ..< window.tabs.count { let currentTab = window.tabs.element(boundBy: idx) - XCTAssertTrue(currentTab.waitForExistence(timeout: 1)) // focus currentTab.click() // switch to the tab From d9ed325818fabcac2450f1a856a797d33cdd835f Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:43:59 +0100 Subject: [PATCH 525/605] update config explicitly --- .../GhosttyCustomConfigCase.swift | 23 +++++++++---------- .../GhosttyUITests/GhosttyTitleUITests.swift | 6 ++--- .../GhosttyTitlebarTabsUITests.swift | 15 +++++++----- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index d4e2e5b89..709533bcc 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -23,24 +23,23 @@ class GhosttyCustomConfigCase: XCTestCase { } } - var customGhosttyConfig: String? { - nil - } - - func ghosttyApplication() throws -> XCUIApplication { - let app = XCUIApplication() - app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) - guard let customGhosttyConfig else { - return app - } + func updateConfig(_ newConfig: String) throws { if let configFile { try FileManager.default.removeItem(at: configFile) } let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) .appendingPathExtension("ghostty") - try customGhosttyConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) + try newConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) configFile = temporaryConfig - app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile?.path + } + + func ghosttyApplication() throws -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) + guard let configFile else { + return app + } + app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path return app } } diff --git a/macos/GhosttyUITests/GhosttyTitleUITests.swift b/macos/GhosttyUITests/GhosttyTitleUITests.swift index e6f6d67e6..01bc64023 100644 --- a/macos/GhosttyUITests/GhosttyTitleUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitleUITests.swift @@ -8,9 +8,9 @@ import XCTest final class GhosttyTitleUITests: GhosttyCustomConfigCase { - - override var customGhosttyConfig: String? { - #"title = "GhosttyUITestsLaunchTests""# + override func setUp() async throws { + try await super.setUp() + try updateConfig(#"title = "GhosttyUITestsLaunchTests""#) } @MainActor diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index 29694a96b..7f92779e4 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -8,13 +8,16 @@ import XCTest final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { - override var customGhosttyConfig: String? { - """ - macos-titlebar-style = tabs - title = "GhosttyTitlebarTabsUITests" - """ - } + override func setUp() async throws { + try await super.setUp() + try updateConfig( + """ + macos-titlebar-style = tabs + title = "GhosttyTitlebarTabsUITests" + """ + ) + } @MainActor func testCustomTitlebar() throws { let app = try ghosttyApplication() From 4aabd947160b2786b9bb5921364a5589274d1823 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:33:23 +0100 Subject: [PATCH 526/605] add theme tests --- macos/GhosttyUITests/AppKitExtensions.swift | 34 +++++++++++++++ macos/GhosttyUITests/GhosttyThemeTests.swift | 46 ++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 macos/GhosttyUITests/AppKitExtensions.swift create mode 100644 macos/GhosttyUITests/GhosttyThemeTests.swift diff --git a/macos/GhosttyUITests/AppKitExtensions.swift b/macos/GhosttyUITests/AppKitExtensions.swift new file mode 100644 index 000000000..6bb0601a8 --- /dev/null +++ b/macos/GhosttyUITests/AppKitExtensions.swift @@ -0,0 +1,34 @@ +// +// AppKitExtensions.swift +// Ghostty +// +// Created by luca on 27.10.2025. +// + +import AppKit + +extension NSColor { + var isLightColor: Bool { + return self.luminance > 0.5 + } + + var luminance: Double { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + guard let rgb = self.usingColorSpace(.sRGB) else { return 0 } + rgb.getRed(&r, green: &g, blue: &b, alpha: &a) + return (0.299 * r) + (0.587 * g) + (0.114 * b) + } +} + +extension NSImage { + func colorAt(x: Int, y: Int) -> NSColor? { + guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + return NSBitmapImageRep(cgImage: cgImage).colorAt(x: x, y: y) + } +} diff --git a/macos/GhosttyUITests/GhosttyThemeTests.swift b/macos/GhosttyUITests/GhosttyThemeTests.swift new file mode 100644 index 000000000..fce4b061b --- /dev/null +++ b/macos/GhosttyUITests/GhosttyThemeTests.swift @@ -0,0 +1,46 @@ +// +// GhosttyThemeTests.swift +// Ghostty +// +// Created by luca on 27.10.2025. +// + +import XCTest +import AppKit + +final class GhosttyThemeTests: GhosttyCustomConfigCase { + + /// https://github.com/ghostty-org/ghostty/issues/8282 + func testIssue8282() throws { + try updateConfig("theme=light:3024 Day,dark:3024 Night\ntitle=GhosttyThemeTests") + XCUIDevice.shared.appearance = .dark + + let app = try ghosttyApplication() + app.launch() + let windowTitle = app.windows.firstMatch.title + let titleView = app.windows.firstMatch.staticTexts.element(matching: NSPredicate(format: "value == '\(windowTitle)'")) + + let image = titleView.screenshot().image + guard let imageColor = image.colorAt(x: 0, y: 0) else { + return + } + XCTAssertLessThanOrEqual(imageColor.luminance, 0.5, "Expected dark appearance for this test") + // create a split + app.groups["Terminal pane"].typeKey("d", modifierFlags: .command) + // reload config + app.typeKey(",", modifierFlags: [.command, .shift]) + // create a new window + app.typeKey("n", modifierFlags: [.command]) + + for i in 0.. Date: Mon, 27 Oct 2025 21:25:40 +0100 Subject: [PATCH 527/605] Add more theme test cases from #9360 --- .../GhosttyCustomConfigCase.swift | 11 +- macos/GhosttyUITests/GhosttyThemeTests.swift | 150 +++++++++++++++--- 2 files changed, 136 insertions(+), 25 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index 709533bcc..86e66fa75 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -24,13 +24,12 @@ class GhosttyCustomConfigCase: XCTestCase { } func updateConfig(_ newConfig: String) throws { - if let configFile { - try FileManager.default.removeItem(at: configFile) + if configFile == nil { + let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("ghostty") + configFile = temporaryConfig } - let temporaryConfig = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - .appendingPathExtension("ghostty") - try newConfig.write(to: temporaryConfig, atomically: true, encoding: .utf8) - configFile = temporaryConfig + try newConfig.write(to: configFile!, atomically: true, encoding: .utf8) } func ghosttyApplication() throws -> XCUIApplication { diff --git a/macos/GhosttyUITests/GhosttyThemeTests.swift b/macos/GhosttyUITests/GhosttyThemeTests.swift index fce4b061b..eabd2e535 100644 --- a/macos/GhosttyUITests/GhosttyThemeTests.swift +++ b/macos/GhosttyUITests/GhosttyThemeTests.swift @@ -5,42 +5,154 @@ // Created by luca on 27.10.2025. // -import XCTest import AppKit +import XCTest final class GhosttyThemeTests: GhosttyCustomConfigCase { + let windowTitle = "GhosttyThemeTests" + private func assertTitlebarAppearance( + _ appearance: XCUIDevice.Appearance, + for app: XCUIApplication, + title: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + for i in 0 ..< app.windows.count { + let titleView = app.windows.element(boundBy: i).staticTexts.element(matching: NSPredicate(format: "value == '\(title ?? windowTitle)'")) + + let image = titleView.screenshot().image + guard let imageColor = image.colorAt(x: 0, y: 0) else { + throw XCTSkip("failed to get pixel color", file: file, line: line) + } + + switch appearance { + case .dark: + XCTAssertLessThanOrEqual(imageColor.luminance, 0.5, "Expected dark appearance for this test", file: file, line: line) + default: + XCTAssertGreaterThanOrEqual(imageColor.luminance, 0.5, "Expected light appearance for this test", file: file, line: line) + } + } + } /// https://github.com/ghostty-org/ghostty/issues/8282 - func testIssue8282() throws { - try updateConfig("theme=light:3024 Day,dark:3024 Night\ntitle=GhosttyThemeTests") + @MainActor + func testIssue8282() async throws { + try updateConfig("title=\(windowTitle) \n theme=light:3024 Day,dark:3024 Night") XCUIDevice.shared.appearance = .dark let app = try ghosttyApplication() app.launch() - let windowTitle = app.windows.firstMatch.title - let titleView = app.windows.firstMatch.staticTexts.element(matching: NSPredicate(format: "value == '\(windowTitle)'")) - - let image = titleView.screenshot().image - guard let imageColor = image.colorAt(x: 0, y: 0) else { - return - } - XCTAssertLessThanOrEqual(imageColor.luminance, 0.5, "Expected dark appearance for this test") + try assertTitlebarAppearance(.dark, for: app) // create a split app.groups["Terminal pane"].typeKey("d", modifierFlags: .command) // reload config app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) // create a new window app.typeKey("n", modifierFlags: [.command]) + try assertTitlebarAppearance(.dark, for: app) + } - for i in 0.. Date: Tue, 28 Oct 2025 09:29:38 +0100 Subject: [PATCH 528/605] only run ui tests manually with xcode --- .../GhosttyUITests/GhosttyCustomConfigCase.swift | 15 +++++++++++++++ macos/GhosttyUITests/GhosttyThemeTests.swift | 2 +- .../GhosttyTitlebarTabsUITests.swift | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index 86e66fa75..41993247a 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -8,6 +8,21 @@ import XCTest class GhosttyCustomConfigCase: XCTestCase { + /// We only want run these UI tests + /// when testing manually with Xcode IDE + /// + /// So that we don't have to wait for each ci check + /// to run these tedious tests + override class var defaultTestSuite: XCTestSuite { + // https://lldb.llvm.org/cpp_reference/PlatformDarwin_8cpp_source.html#:~:text==%20%22-,IDE_DISABLED_OS_ACTIVITY_DT_MODE + + if ProcessInfo.processInfo.environment["IDE_DISABLED_OS_ACTIVITY_DT_MODE"] != nil { + return XCTestSuite(forTestCaseClass: Self.self) + } else { + return XCTestSuite(name: "Skipping \(className())") + } + } + override class var runsForEachTargetApplicationUIConfiguration: Bool { true } diff --git a/macos/GhosttyUITests/GhosttyThemeTests.swift b/macos/GhosttyUITests/GhosttyThemeTests.swift index eabd2e535..a667d751d 100644 --- a/macos/GhosttyUITests/GhosttyThemeTests.swift +++ b/macos/GhosttyUITests/GhosttyThemeTests.swift @@ -146,7 +146,7 @@ final class GhosttyThemeTests: GhosttyCustomConfigCase { app.launch() // close default window app.typeKey("w", modifierFlags: [.command]) - // open quick termial + // open quick termnial app.menuBarItems["View"].firstMatch.click() app.menuItems["Quick Terminal"].firstMatch.click() let title = "Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development." diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index 7f92779e4..6c32875eb 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -18,6 +18,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { """ ) } + @MainActor func testCustomTitlebar() throws { let app = try ghosttyApplication() From 4154a730ee8b4512c436929bd881ba9be3bb9b5a Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:13:56 +0100 Subject: [PATCH 529/605] Fix some edge cases --- macos/GhosttyUITests/GhosttyThemeTests.swift | 7 ++++--- macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/macos/GhosttyUITests/GhosttyThemeTests.swift b/macos/GhosttyUITests/GhosttyThemeTests.swift index a667d751d..d6f73d7f4 100644 --- a/macos/GhosttyUITests/GhosttyThemeTests.swift +++ b/macos/GhosttyUITests/GhosttyThemeTests.swift @@ -14,6 +14,7 @@ final class GhosttyThemeTests: GhosttyCustomConfigCase { _ appearance: XCUIDevice.Appearance, for app: XCUIApplication, title: String? = nil, + colorLocation: CGPoint? = nil, file: StaticString = #filePath, line: UInt = #line ) throws { @@ -21,7 +22,7 @@ final class GhosttyThemeTests: GhosttyCustomConfigCase { let titleView = app.windows.element(boundBy: i).staticTexts.element(matching: NSPredicate(format: "value == '\(title ?? windowTitle)'")) let image = titleView.screenshot().image - guard let imageColor = image.colorAt(x: 0, y: 0) else { + guard let imageColor = image.colorAt(x: Int(colorLocation?.x ?? 1), y: Int(colorLocation?.y ?? 1)) else { throw XCTSkip("failed to get pixel color", file: file, line: line) } @@ -150,9 +151,9 @@ final class GhosttyThemeTests: GhosttyCustomConfigCase { app.menuBarItems["View"].firstMatch.click() app.menuItems["Quick Terminal"].firstMatch.click() let title = "Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development." - try assertTitlebarAppearance(.light, for: app, title: title) + try assertTitlebarAppearance(.light, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5)) // to avoid dark edge XCUIDevice.shared.appearance = .dark try await Task.sleep(for: .seconds(0.5)) - try assertTitlebarAppearance(.dark, for: app, title: title) + try assertTitlebarAppearance(.dark, for: app, title: title, colorLocation: CGPoint(x: 5, y: 5)) } } diff --git a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift index 6c32875eb..bf8b6124e 100644 --- a/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift +++ b/macos/GhosttyUITests/GhosttyTitlebarTabsUITests.swift @@ -87,7 +87,7 @@ final class GhosttyTitlebarTabsUITests: GhosttyCustomConfigCase { // check tabs in the first checkTabsGeometry(app.windows.firstMatch) // focus another window - app.windows.element(boundBy: 1).click() + app.windows.element(boundBy: 1).tabs.firstMatch.click() checkTabsGeometry(app.windows.firstMatch) } From 35b2e820ce2698224a08c035cf702949c943adc5 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:35:07 +0100 Subject: [PATCH 530/605] typo fix --- macos/GhosttyUITests/GhosttyThemeTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/GhosttyUITests/GhosttyThemeTests.swift b/macos/GhosttyUITests/GhosttyThemeTests.swift index d6f73d7f4..f8f5286fb 100644 --- a/macos/GhosttyUITests/GhosttyThemeTests.swift +++ b/macos/GhosttyUITests/GhosttyThemeTests.swift @@ -147,7 +147,7 @@ final class GhosttyThemeTests: GhosttyCustomConfigCase { app.launch() // close default window app.typeKey("w", modifierFlags: [.command]) - // open quick termnial + // open quick terminal app.menuBarItems["View"].firstMatch.click() app.menuItems["Quick Terminal"].firstMatch.click() let title = "Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development." From 32562e0c983cdbde986f0c6aef487d61c1253ae4 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 28 Oct 2025 10:46:09 +0100 Subject: [PATCH 531/605] Update macos/Sources/Ghostty/Ghostty.App.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 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 8e32b82af..854427342 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -27,7 +27,7 @@ extension Ghostty { /// The global app configuration. This defines the app level configuration plus any behavior /// for new windows, tabs, etc. Note that when creating a new window, it may inherit some /// configuration (i.e. font size) from the previously focused window. This would override this. - @Published fileprivate(set) var config: Config + @Published private(set) var config: Config /// Preferred config file than the default ones private var configPath: String? From bfe5a4be8fa3ceba20310199b3df397809669c26 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:37:36 +0100 Subject: [PATCH 532/605] move config loading to Config --- macos/Sources/Ghostty/Ghostty.App.swift | 32 ++-------------------- macos/Sources/Ghostty/Ghostty.Config.swift | 22 ++++++++++----- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 854427342..5ec42158f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -49,7 +49,7 @@ extension Ghostty { init(configPath: String? = nil) { self.configPath = configPath // Initialize the global configuration. - self.config = configPath.flatMap({ Self.readConfig(at: $0, finalize: true) }) ?? Config() + self.config = Config(at: configPath) if self.config.config == nil { readiness = .error return @@ -146,7 +146,7 @@ extension Ghostty { } // Hard or full updates have to reload the full configuration - let newConfig = configPath.flatMap({ Self.readConfig(at: $0, finalize: true) }) ?? Config() + let newConfig = Config(at: configPath) guard newConfig.loaded else { Ghostty.logger.warning("failed to reload configuration") return @@ -166,7 +166,7 @@ extension Ghostty { // Hard or full updates have to reload the full configuration. // NOTE: We never set this on self.config because this is a surface-only // config. We free it after the call. - let newConfig = configPath.flatMap({ Self.readConfig(at: $0, finalize: true) }) ?? Config() + let newConfig = Config(at: configPath) guard newConfig.loaded else { Ghostty.logger.warning("failed to reload configuration") return @@ -2077,29 +2077,3 @@ extension Ghostty { #endif } } - -extension Ghostty.App { - static func readConfig(at path: String, finalize: Bool = true) -> Ghostty.Config? { - guard - let cfg = ghostty_config_new() - else { - return nil - } - if FileManager.default.fileExists(atPath: path) { - ghostty_config_load_file(cfg, path) - } -#if os(macOS) - if !isRunningInXcode() { - ghostty_config_load_cli_args(cfg) - } -#endif - ghostty_config_load_recursive_files(cfg) - if finalize { - // Finalize will make our defaults available, - // and also will combine all the keys into one file, - // we might not need this in the future - ghostty_config_finalize(cfg) - } - return Ghostty.Config(config: cfg) - } -} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index fb435c014..86b0ecdb9 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -37,8 +37,8 @@ extension Ghostty { self.config = config } - convenience init() { - self.init(config: Self.loadConfig()) + convenience init(at path: String? = nil, finalize: Bool = true) { + self.init(config: Self.loadConfig(at: path, finalize: finalize)) } convenience init(clone config: ghostty_config_t) { @@ -50,7 +50,10 @@ extension Ghostty { } /// Initializes a new configuration and loads all the values. - static private func loadConfig() -> ghostty_config_t? { + /// - Parameters: + /// - path: An optional preferred config file path. Pass `nil` to load the default configuration files. + /// - finalize: Whether to finalize the configuration to populate default values. + static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { logger.critical("ghostty_config_new failed") @@ -61,7 +64,11 @@ extension Ghostty { // We only do this on macOS because other Apple platforms do not have the // same filesystem concept. #if os(macOS) - ghostty_config_load_default_files(cfg); + if let path, FileManager.default.fileExists(atPath: path) { + ghostty_config_load_file(cfg, path) + } else { + ghostty_config_load_default_files(cfg) + } // We only load CLI args when not running in Xcode because in Xcode we // pass some special parameters to control the debugger. @@ -76,9 +83,10 @@ extension Ghostty { // have to do this synchronously. When we support config updating we can do // this async and update later. - // Finalize will make our defaults available. - ghostty_config_finalize(cfg) - + if finalize { + // Finalize will make our defaults available. + ghostty_config_finalize(cfg) + } // Log any configuration errors. These will be automatically shown in a // pop-up window too. let diagsCount = ghostty_config_diagnostics_count(cfg) From 3fda31a66aeb0617558e549f12bacb912e0497af Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Sun, 2 Nov 2025 16:03:54 +0100 Subject: [PATCH 533/605] skip checking config file --- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 86b0ecdb9..c64646e25 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -64,7 +64,7 @@ extension Ghostty { // We only do this on macOS because other Apple platforms do not have the // same filesystem concept. #if os(macOS) - if let path, FileManager.default.fileExists(atPath: path) { + if let path { ghostty_config_load_file(cfg, path) } else { ghostty_config_load_default_files(cfg) From 7b6147aa28280d4c761db42fe69fcca0a0ab2169 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Tue, 20 Jan 2026 14:23:10 -0300 Subject: [PATCH 534/605] Refactor GValueType and getImpl functions to use type-based switches for improved clarity and maintainability --- src/apprt/gtk/gsettings.zig | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig index 3fc3d01de..43a1467c6 100644 --- a/src/apprt/gtk/gsettings.zig +++ b/src/apprt/gtk/gsettings.zig @@ -17,18 +17,11 @@ pub const Key = enum { } fn GValueType(comptime self: Key) type { - return switch (self) { - // Booleans are stored as integers in GTK's internal representation - .@"gtk-enable-primary-paste", - => c_int, - - // Integer types - .@"gtk-xft-dpi", - => c_int, - - // String types (returned as null-terminated C strings from GTK) - .@"gtk-font-name", - => ?[*:0]const u8, + return switch (self.Type()) { + bool => c_int, // Booleans are stored as integers in GTK's internal representation + c_int => c_int, + []const u8 => ?[*:0]const u8, // Strings (returned as null-terminated C strings from GTK) + else => @compileError("Unsupported type for GTK settings"), }; } @@ -86,19 +79,12 @@ fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: settings.as(gobject.Object).getProperty(@tagName(key).ptr, &value); - return switch (key) { - // Booleans are stored as integers in GTK, convert to bool - .@"gtk-enable-primary-paste", - => value.getInt() != 0, - - // Integer types are returned directly - .@"gtk-xft-dpi", - => value.getInt(), - - // Strings: GTK owns the GValue's pointer, so we must duplicate it - // before the GValue is destroyed by defer value.unset() - .@"gtk-font-name", - => blk: { + return switch (key.Type()) { + bool => value.getInt() != 0, // Booleans are stored as integers in GTK, convert to bool + c_int => value.getInt(), // Integer types are returned directly + []const u8 => blk: { + // Strings: GTK owns the GValue's pointer, so we must duplicate it + // before the GValue is destroyed by defer value.unset() // This is defensive: we have already checked at compile-time that // an allocator is provided for allocating types const alloc = allocator.?; @@ -106,6 +92,7 @@ fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: const str = std.mem.span(ptr); break :blk try alloc.dupe(u8, str); }, + else => @compileError("Unsupported type for GTK settings"), }; } From bc067fc782ed32b5c20a25bbee061debf0ad1612 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Tue, 20 Jan 2026 14:23:22 -0300 Subject: [PATCH 535/605] Refactor gtk_enable_primary_paste to remove optional type and simplify condition checks --- src/apprt/gtk/class/surface.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 56c39a86f..c439a10c3 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -677,7 +677,7 @@ pub const Surface = extern struct { /// Whether primary paste (middle-click paste) is enabled via GNOME settings. /// If true, middle-click paste is enabled. If false, it's disabled. - gtk_enable_primary_paste: ?bool = true, + gtk_enable_primary_paste: bool = true, pub var offset: c_int = 0; }; @@ -2698,7 +2698,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and !(priv.gtk_enable_primary_paste)) { + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } @@ -2755,7 +2755,7 @@ pub const Surface = extern struct { // Check if middle button paste should be disabled based on GNOME settings // If gtk_enable_primary_paste is explicitly false, skip processing middle button - if (button == .middle and !(priv.gtk_enable_primary_paste)) { + if (button == .middle and !priv.gtk_enable_primary_paste) { return; } From 8c9891b5de515dfe8f9717441437123f205a2b63 Mon Sep 17 00:00:00 2001 From: evertonstz Date: Tue, 20 Jan 2026 14:32:47 -0300 Subject: [PATCH 536/605] Refactor primary paste settings handling and documentation for clarity --- src/apprt/gtk/class/surface.zig | 15 +++-------- src/apprt/gtk/gsettings.zig | 44 +++++++++------------------------ 2 files changed, 15 insertions(+), 44 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index c439a10c3..5c3bf18b6 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -675,8 +675,7 @@ pub const Surface = extern struct { /// The context for this surface (window, tab, or split) context: apprt.surface.NewSurfaceContext = .window, - /// Whether primary paste (middle-click paste) is enabled via GNOME settings. - /// If true, middle-click paste is enabled. If false, it's disabled. + /// Whether primary paste (middle-click paste) is enabled. gtk_enable_primary_paste: bool = true, pub var offset: c_int = 0; @@ -1770,12 +1769,8 @@ pub const Surface = extern struct { priv.im_composing = false; priv.im_len = 0; - // Read GNOME desktop interface settings for primary paste (middle-click) - // This is only relevant on Linux systems with GNOME settings available - priv.gtk_enable_primary_paste = gsettings.get(.@"gtk-enable-primary-paste") orelse blk: { - log.warn("gtk-enable-primary-paste was not set, using default value", .{}); - break :blk false; - }; + // Read GTK primary paste setting + priv.gtk_enable_primary_paste = gsettings.get(.@"gtk-enable-primary-paste") orelse true; // Set up to handle items being dropped on our surface. Files can be dropped // from Nautilus and strings can be dropped from many programs. The order @@ -2696,8 +2691,6 @@ pub const Surface = extern struct { // Report the event const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - // Check if middle button paste should be disabled based on GNOME settings - // If gtk_enable_primary_paste is explicitly false, skip processing middle button if (button == .middle and !priv.gtk_enable_primary_paste) { return; } @@ -2753,8 +2746,6 @@ pub const Surface = extern struct { const gtk_mods = event.getModifierState(); const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - // Check if middle button paste should be disabled based on GNOME settings - // If gtk_enable_primary_paste is explicitly false, skip processing middle button if (button == .middle and !priv.gtk_enable_primary_paste) { return; } diff --git a/src/apprt/gtk/gsettings.zig b/src/apprt/gtk/gsettings.zig index 43a1467c6..8cf7f12d2 100644 --- a/src/apprt/gtk/gsettings.zig +++ b/src/apprt/gtk/gsettings.zig @@ -18,16 +18,15 @@ pub const Key = enum { fn GValueType(comptime self: Key) type { return switch (self.Type()) { - bool => c_int, // Booleans are stored as integers in GTK's internal representation + bool => c_int, c_int => c_int, - []const u8 => ?[*:0]const u8, // Strings (returned as null-terminated C strings from GTK) + []const u8 => ?[*:0]const u8, else => @compileError("Unsupported type for GTK settings"), }; } /// Returns true if this setting type requires memory allocation. - /// this is defensive: types that do not need allocation need to be - /// explicitly marked here + /// Types that do not need allocation must be explicitly marked. fn requiresAllocation(comptime self: Key) bool { const T = self.Type(); return switch (T) { @@ -37,14 +36,9 @@ pub const Key = enum { } }; -/// Reads a GTK setting using the GTK Settings API for non-allocating types. -/// This automatically uses XDG Desktop Portal in Flatpak environments. -/// -/// No allocator is required or used. Returns null if the setting is not available or cannot be read. -/// -/// Example usage: -/// const enabled = get(.@"gtk-enable-primary-paste"); -/// const dpi = get(.@"gtk-xft-dpi"); +/// Reads a GTK setting for non-allocating types. +/// Automatically uses XDG Desktop Portal in Flatpak environments. +/// Returns null if the setting is unavailable. pub fn get(comptime key: Key) ?key.Type() { if (comptime key.requiresAllocation()) { @compileError("Allocating types require an allocator; use getAlloc() instead"); @@ -53,25 +47,15 @@ pub fn get(comptime key: Key) ?key.Type() { return getImpl(settings, null, key) catch unreachable; } -/// Reads a GTK setting using the GTK Settings API, allocating if necessary. -/// This automatically uses XDG Desktop Portal in Flatpak environments. -/// -/// The caller must free any returned allocated memory with the provided allocator. -/// Returns null if the setting is not available or cannot be read. -/// May return an allocation error if memory allocation fails. -/// -/// Example usage: -/// const theme = try getAlloc(allocator, .gtk_theme_name); -/// defer if (theme) |t| allocator.free(t); +/// Reads a GTK setting, allocating memory if necessary. +/// Automatically uses XDG Desktop Portal in Flatpak environments. +/// Caller must free returned memory with the provided allocator. +/// Returns null if the setting is unavailable. pub fn getAlloc(allocator: std.mem.Allocator, comptime key: Key) !?key.Type() { const settings = gtk.Settings.getDefault() orelse return null; return getImpl(settings, allocator, key); } -/// Shared implementation for reading GTK settings. -/// If allocator is null, only non-allocating types can be used. -/// Note: When adding a new type, research if it requires allocation (strings and boxed types do) -/// if allocation is NOT needed, list it inside the switch statement in the function requiresAllocation() fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: Key) !?key.Type() { const GValType = key.GValueType(); var value = gobject.ext.Value.new(GValType); @@ -80,13 +64,9 @@ fn getImpl(settings: *gtk.Settings, allocator: ?std.mem.Allocator, comptime key: settings.as(gobject.Object).getProperty(@tagName(key).ptr, &value); return switch (key.Type()) { - bool => value.getInt() != 0, // Booleans are stored as integers in GTK, convert to bool - c_int => value.getInt(), // Integer types are returned directly + bool => value.getInt() != 0, + c_int => value.getInt(), []const u8 => blk: { - // Strings: GTK owns the GValue's pointer, so we must duplicate it - // before the GValue is destroyed by defer value.unset() - // This is defensive: we have already checked at compile-time that - // an allocator is provided for allocating types const alloc = allocator.?; const ptr = value.getString() orelse break :blk null; const str = std.mem.span(ptr); From 811e3594eb7fd42c67157cccc2bf68e308b29337 Mon Sep 17 00:00:00 2001 From: mauroporras <167700+mauroporras@users.noreply.github.com> Date: Fri, 24 Oct 2025 15:37:21 -0500 Subject: [PATCH 537/605] feat: add configurable word boundary characters for text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new `selection-word-chars` config option to customize which characters mark word boundaries during text selection operations (double-click, word selection, etc.). Similar to zsh's WORDCHARS environment variable, but specifies boundary characters rather than word characters. Default boundaries: ` \t'"│`|:;,()[]{}<>$` Users can now customize word selection behavior, such as treating semicolons as part of words or excluding periods from boundaries: selection-word-chars = " \t'\"│`|:,()[]{}<>$" Changes: - Add selection-word-chars config field with comprehensive documentation - Modify selectWord() and selectWordBetween() to accept boundary_chars parameter - Parse UTF-8 boundary string to u32 codepoints at runtime - Update all call sites in Surface.zig and embedded.zig - Update all test cases to pass boundary characters --- src/Surface.zig | 18 +++++-- src/apprt/embedded.zig | 5 +- src/config/Config.zig | 25 ++++++++++ src/terminal/Screen.zig | 108 ++++++++++++++++++++++------------------ 4 files changed, 103 insertions(+), 53 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1f50cb681..65cb40091 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -316,6 +316,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, selection_clear_on_typing: bool, + selection_word_chars: []const u8, vt_kam_allowed: bool, wait_after_command: bool, window_padding_top: u32, @@ -392,6 +393,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_copy = config.@"selection-clear-on-copy", .selection_clear_on_typing = config.@"selection-clear-on-typing", + .selection_word_chars = config.@"selection-word-chars", .vt_kam_allowed = config.@"vt-kam-allowed", .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, @@ -4180,7 +4182,7 @@ pub fn mouseButtonCallback( // Ignore any errors, likely regex errors. } - break :sel self.io.terminal.screens.active.selectWord(pin.*); + break :sel self.io.terminal.screens.active.selectWord(pin.*, self.config.selection_word_chars); }; if (sel_) |sel| { try self.io.terminal.screens.active.select(sel); @@ -4262,7 +4264,7 @@ pub fn mouseButtonCallback( if (try self.linkAtPos(pos)) |link| { try self.setSelection(link.selection); } else { - const sel = screen.selectWord(pin) orelse break :sel; + const sel = screen.selectWord(pin, self.config.selection_word_chars) orelse break :sel; try self.setSelection(sel); } try self.queueRender(); @@ -4583,7 +4585,10 @@ pub fn mousePressureCallback( // This should always be set in this state but we don't want // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; - const sel = self.io.terminal.screens.active.selectWord(pin.*) orelse break :select; + const sel = self.io.terminal.screens.active.selectWord( + pin.*, + self.config.selection_word_chars, + ) orelse break :select; try self.io.terminal.screens.active.select(sel); try self.queueRender(); } @@ -4806,7 +4811,11 @@ fn dragLeftClickDouble( const click_pin = self.mouse.left_click_pin.?.*; // Get the word closest to our starting click. - const word_start = screen.selectWordBetween(click_pin, drag_pin) orelse { + const word_start = screen.selectWordBetween( + click_pin, + drag_pin, + self.config.selection_word_chars, + ) orelse { try self.setSelection(null); return; }; @@ -4815,6 +4824,7 @@ fn dragLeftClickDouble( const word_current = screen.selectWordBetween( drag_pin, click_pin, + self.config.selection_word_chars, ) orelse { try self.setSelection(null); return; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 364a1bec1..b4ad7f885 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -2165,7 +2165,10 @@ pub const CAPI = struct { if (comptime std.debug.runtime_safety) unreachable; return false; }; - break :sel surface.io.terminal.screens.active.selectWord(pin) orelse return false; + break :sel surface.io.terminal.screens.active.selectWord( + pin, + surface.config.selection_word_chars, + ) orelse return false; }; // Read the selection diff --git a/src/config/Config.zig b/src/config/Config.zig index 7689899de..6d941d493 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -712,6 +712,31 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// on the same selection. @"selection-clear-on-copy": bool = false, +/// Characters that mark word boundaries during text selection operations such +/// as double-clicking. When selecting a word, the selection will stop at any +/// of these characters. +/// +/// This is similar to the `WORDCHARS` environment variable in zsh, except this +/// specifies the boundary characters rather than the word characters. The +/// default includes common delimiters and punctuation that typically separate +/// words in code and prose. +/// +/// Each character in this string becomes a word boundary. Multi-byte UTF-8 +/// characters are supported. +/// +/// The null character (U+0000) is always treated as a boundary and does not +/// need to be included in this configuration. +/// +/// Default: ` \t'"│`|:;,()[]{}<>$` +/// +/// To add or remove specific characters, you can set this to a custom value. +/// For example, to treat semicolons as part of words: +/// +/// selection-word-chars = " \t'\"│`|:,()[]{}<>$" +/// +/// Available since: 1.2.0 +@"selection-word-chars": []const u8 = " \t'\"│`|:;,()[]{}<>$", + /// 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 diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index d36cdac2a..3f7a48a65 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2617,11 +2617,15 @@ pub fn selectAll(self: *Screen) ?Selection { /// end_pt (inclusive). Because it selects "nearest" to start point, start /// point can be before or after end point. /// +/// The boundary_chars parameter should be a UTF-8 string of characters that +/// mark word boundaries, passed through to selectWord. +/// /// TODO: test this pub fn selectWordBetween( self: *Screen, start: Pin, end: Pin, + boundary_chars: []const u8, ) ?Selection { const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; var it = start.cellIterator(dir, end); @@ -2633,7 +2637,7 @@ pub fn selectWordBetween( } // If we found a word, then return it - if (self.selectWord(pin)) |sel| return sel; + if (self.selectWord(pin, boundary_chars)) |sel| return sel; } return null; @@ -2645,32 +2649,37 @@ pub fn selectWordBetween( /// /// This will return null if a selection is impossible. The only scenario /// this happens is if the point pt is outside of the written screen space. -pub fn selectWord(self: *Screen, pin: Pin) ?Selection { +/// +/// The boundary_chars parameter should be a UTF-8 string of characters that +/// mark word boundaries. The null character (U+0000) is always included as +/// a boundary automatically. +pub fn selectWord( + self: *Screen, + pin: Pin, + boundary_chars: []const u8, +) ?Selection { _ = self; - // Boundary characters for selection purposes - const boundary = &[_]u32{ - 0, - ' ', - '\t', - '\'', - '"', - '│', - '`', - '|', - ':', - ';', - ',', - '(', - ')', - '[', - ']', - '{', - '}', - '<', - '>', - '$', - }; + // Parse boundary characters from UTF-8 string to u32 codepoints. + // We allocate a fixed-size array on the stack (64 boundary chars should be plenty). + var boundary_buf: [64]u32 = undefined; + var boundary_len: usize = 1; + boundary_buf[0] = 0; // Always include null character as a boundary + + // Parse the UTF-8 boundary string into codepoints + if (std.unicode.Utf8View.init(boundary_chars)) |utf8_view| { + var utf8_it = utf8_view.iterator(); + while (utf8_it.nextCodepoint()) |codepoint| { + if (boundary_len >= boundary_buf.len) break; // Safety limit + boundary_buf[boundary_len] = codepoint; + boundary_len += 1; + } + } else |_| { + // If invalid UTF-8, use just the null boundary + // This is a graceful fallback that still allows selection to work + } + + const boundary = boundary_buf[0..boundary_len]; // If our cell is empty we can't select a word, because we can't select // areas where the screen is not yet written. @@ -7699,6 +7708,9 @@ test "Screen: selectWord" { defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); + // Default boundary characters for word selection + const boundary_chars = " \t'\"│`|:;,()[]{}<>$"; + // Outside of active area // try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); // try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); @@ -7708,7 +7720,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7725,7 +7737,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7742,7 +7754,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7759,7 +7771,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, @@ -7776,7 +7788,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7793,7 +7805,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7825,7 +7837,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7842,7 +7854,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7859,7 +7871,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7885,7 +7897,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7902,7 +7914,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7919,7 +7931,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7966,7 +7978,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -7983,7 +7995,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 4, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -8000,7 +8012,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -8019,7 +8031,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8065,7 +8077,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8081,7 +8093,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8097,7 +8109,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 10, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8168,7 +8180,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 6, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8185,7 +8197,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 3, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8229,7 +8241,7 @@ test "Screen: selectPrompt prompt at start" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8273,7 +8285,7 @@ test "Screen: selectPrompt prompt at end" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?).?; + } }).?, boundary_chars).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, From 27602bb4b42f88c576d9385947c139cd67a73730 Mon Sep 17 00:00:00 2001 From: mauroporras <167700+mauroporras@users.noreply.github.com> Date: Sun, 26 Oct 2025 06:23:38 -0500 Subject: [PATCH 538/605] refactor: optimize selection-word-chars with pre-parsed codepoints Refactor the selection-word-chars implementation to parse UTF-8 boundary characters once during config initialization instead of on every selection operation. Changes: - Add SelectionWordChars type that stores pre-parsed []const u32 codepoints - Parse UTF-8 to codepoints in parseCLI() during config load - Remove UTF-8 parsing logic from selectWord() hot path (27 lines removed) - Remove arbitrary 64-character buffer limit - Update selectWord() and selectWordBetween() to accept []const u32 - Update DerivedConfig to store codepoints directly - Update all tests to use codepoint arrays Benefits: - No runtime UTF-8 parsing overhead on every selection - No arbitrary character limit (uses allocator instead) - Cleaner separation of concerns (config handles parsing, selection uses data) - Better performance in selection hot path --- src/Surface.zig | 4 +- src/config/Config.zig | 107 ++++++++++++++++++++++++++++++- src/terminal/Screen.zig | 135 +++++++++++++++++++++++----------------- 3 files changed, 187 insertions(+), 59 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 65cb40091..13cef9e3d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -316,7 +316,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, selection_clear_on_typing: bool, - selection_word_chars: []const u8, + selection_word_chars: []const u32, vt_kam_allowed: bool, wait_after_command: bool, window_padding_top: u32, @@ -393,7 +393,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_copy = config.@"selection-clear-on-copy", .selection_clear_on_typing = config.@"selection-clear-on-typing", - .selection_word_chars = config.@"selection-word-chars", + .selection_word_chars = config.@"selection-word-chars".codepoints, .vt_kam_allowed = config.@"vt-kam-allowed", .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, diff --git a/src/config/Config.zig b/src/config/Config.zig index 6d941d493..b959dedba 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -735,7 +735,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// selection-word-chars = " \t'\"│`|:,()[]{}<>$" /// /// Available since: 1.2.0 -@"selection-word-chars": []const u8 = " \t'\"│`|:;,()[]{}<>$", +@"selection-word-chars": SelectionWordChars = .{}, /// 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 @@ -5788,6 +5788,111 @@ pub const RepeatableString = struct { } }; +/// SelectionWordChars stores the parsed codepoints for word boundary +/// characters used during text selection. The string is parsed once +/// during configuration and stored as u32 codepoints for efficient +/// lookup during selection operations. +pub const SelectionWordChars = struct { + const Self = @This(); + + /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` + const default_codepoints = [_]u32{ + 0, // null + ' ', // space + '\t', // tab + '\'', // single quote + '"', // double quote + '│', // U+2502 box drawing + '`', // backtick + '|', // pipe + ':', // colon + ';', // semicolon + ',', // comma + '(', // left paren + ')', // right paren + '[', // left bracket + ']', // right bracket + '{', // left brace + '}', // right brace + '<', // less than + '>', // greater than + '$', // dollar + }; + + /// The parsed codepoints. Always includes null (U+0000) at index 0. + codepoints: []const u32 = &default_codepoints, + + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + + // Parse UTF-8 string into codepoints + var list = std.ArrayList(u32).init(alloc); + defer list.deinit(); + + // Always include null as first boundary + try list.append(0); + + // Parse the UTF-8 string + const utf8_view = std.unicode.Utf8View.init(value) catch { + // Invalid UTF-8, just use null boundary + self.codepoints = try list.toOwnedSlice(); + return; + }; + + var utf8_it = utf8_view.iterator(); + while (utf8_it.nextCodepoint()) |codepoint| { + try list.append(codepoint); + } + + self.codepoints = try list.toOwnedSlice(); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + const copy = try alloc.dupe(u32, self.codepoints); + return .{ .codepoints = copy }; + } + + /// Compare if two values are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + return std.mem.eql(u32, self.codepoints, other.codepoints); + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + // Convert codepoints back to UTF-8 string for display + var buf = std.ArrayList(u8).init(formatter.alloc); + defer buf.deinit(); + + // Skip the null character at index 0 + for (self.codepoints[1..]) |codepoint| { + var utf8_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(@intCast(codepoint), &utf8_buf) catch continue; + try buf.appendSlice(utf8_buf[0..len]); + } + + try formatter.formatEntry([]const u8, buf.items); + } + + test "parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var chars: Self = .{}; + try chars.parseCLI(alloc, " \t;,"); + + // Should have null + 4 characters + try testing.expectEqual(@as(usize, 5), chars.codepoints.len); + try testing.expectEqual(@as(u32, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u32, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u32, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u32, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u32, ','), chars.codepoints[4]); + } +}; + /// FontVariation is a repeatable configuration value that sets a single /// font variation value. Font variations are configurations for what /// are often called "variable fonts." The font files usually end in diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 3f7a48a65..46e0f4fdb 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2617,7 +2617,7 @@ pub fn selectAll(self: *Screen) ?Selection { /// end_pt (inclusive). Because it selects "nearest" to start point, start /// point can be before or after end point. /// -/// The boundary_chars parameter should be a UTF-8 string of characters that +/// The boundary_codepoints parameter should be a slice of u32 codepoints that /// mark word boundaries, passed through to selectWord. /// /// TODO: test this @@ -2625,7 +2625,7 @@ pub fn selectWordBetween( self: *Screen, start: Pin, end: Pin, - boundary_chars: []const u8, + boundary_codepoints: []const u32, ) ?Selection { const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; var it = start.cellIterator(dir, end); @@ -2637,7 +2637,7 @@ pub fn selectWordBetween( } // If we found a word, then return it - if (self.selectWord(pin, boundary_chars)) |sel| return sel; + if (self.selectWord(pin, boundary_codepoints)) |sel| return sel; } return null; @@ -2650,37 +2650,15 @@ pub fn selectWordBetween( /// This will return null if a selection is impossible. The only scenario /// this happens is if the point pt is outside of the written screen space. /// -/// The boundary_chars parameter should be a UTF-8 string of characters that -/// mark word boundaries. The null character (U+0000) is always included as -/// a boundary automatically. +/// The boundary_codepoints parameter should be a slice of u32 codepoints that +/// mark word boundaries. This is expected to be pre-parsed from the config. pub fn selectWord( self: *Screen, pin: Pin, - boundary_chars: []const u8, + boundary_codepoints: []const u32, ) ?Selection { _ = self; - // Parse boundary characters from UTF-8 string to u32 codepoints. - // We allocate a fixed-size array on the stack (64 boundary chars should be plenty). - var boundary_buf: [64]u32 = undefined; - var boundary_len: usize = 1; - boundary_buf[0] = 0; // Always include null character as a boundary - - // Parse the UTF-8 boundary string into codepoints - if (std.unicode.Utf8View.init(boundary_chars)) |utf8_view| { - var utf8_it = utf8_view.iterator(); - while (utf8_it.nextCodepoint()) |codepoint| { - if (boundary_len >= boundary_buf.len) break; // Safety limit - boundary_buf[boundary_len] = codepoint; - boundary_len += 1; - } - } else |_| { - // If invalid UTF-8, use just the null boundary - // This is a graceful fallback that still allows selection to work - } - - const boundary = boundary_buf[0..boundary_len]; - // If our cell is empty we can't select a word, because we can't select // areas where the screen is not yet written. const start_cell = pin.rowAndCell().cell; @@ -2689,7 +2667,7 @@ pub fn selectWord( // Determine if we are a boundary or not to determine what our boundary is. const expect_boundary = std.mem.indexOfAny( u32, - boundary, + boundary_codepoints, &[_]u32{start_cell.content.codepoint}, ) != null; @@ -2707,7 +2685,7 @@ pub fn selectWord( // If we do not match our expected set, we hit a boundary const this_boundary = std.mem.indexOfAny( u32, - boundary, + boundary_codepoints, &[_]u32{cell.content.codepoint}, ) != null; if (this_boundary != expect_boundary) break :end prev; @@ -2744,7 +2722,7 @@ pub fn selectWord( // If we do not match our expected set, we hit a boundary const this_boundary = std.mem.indexOfAny( u32, - boundary, + boundary_codepoints, &[_]u32{cell.content.codepoint}, ) != null; if (this_boundary != expect_boundary) break :start prev; @@ -7708,8 +7686,11 @@ test "Screen: selectWord" { defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); - // Default boundary characters for word selection - const boundary_chars = " \t'\"│`|:;,()[]{}<>$"; + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; // Outside of active area // try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); @@ -7720,7 +7701,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7737,7 +7718,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7754,7 +7735,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7771,7 +7752,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, @@ -7788,7 +7769,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 1, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7805,7 +7786,7 @@ test "Screen: selectWord" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -7826,6 +7807,12 @@ test "Screen: selectWord across soft-wrap" { defer s.deinit(); try s.testWriteString(" 1234012\n 123"); + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7837,7 +7824,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7854,7 +7841,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7871,7 +7858,7 @@ test "Screen: selectWord across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7892,12 +7879,18 @@ test "Screen: selectWord whitespace across soft-wrap" { defer s.deinit(); try s.testWriteString("1 1\n 123"); + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + // Going forward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7914,7 +7907,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7931,7 +7924,7 @@ test "Screen: selectWord whitespace across soft-wrap" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, @@ -7948,6 +7941,12 @@ test "Screen: selectWord with character boundary" { const testing = std.testing; const alloc = testing.allocator; + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + const cases = [_][]const u8{ " 'abc' \n123", " \"abc\" \n123", @@ -7978,7 +7977,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -7995,7 +7994,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 4, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -8012,7 +8011,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, @@ -8031,7 +8030,7 @@ test "Screen: selectWord with character boundary" { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8052,6 +8051,12 @@ test "Screen: selectOutput" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + // zig fmt: off { // line number: @@ -8077,7 +8082,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8093,7 +8098,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8109,7 +8114,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 10, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8142,6 +8147,12 @@ test "Screen: selectPrompt basics" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + // zig fmt: off { // line number: @@ -8180,7 +8191,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 6, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8197,7 +8208,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 3, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8217,6 +8228,12 @@ test "Screen: selectPrompt prompt at start" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + // zig fmt: off { // line number: @@ -8241,7 +8258,7 @@ test "Screen: selectPrompt prompt at start" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8261,6 +8278,12 @@ test "Screen: selectPrompt prompt at end" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); + // Default boundary codepoints for word selection + const boundary_codepoints = &[_]u32{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + }; + // zig fmt: off { // line number: @@ -8285,7 +8308,7 @@ test "Screen: selectPrompt prompt at end" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?, boundary_chars).?; + } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, From 6f662d70bcd1b25a1971b5a14dd0430906482024 Mon Sep 17 00:00:00 2001 From: mauroporras <167700+mauroporras@users.noreply.github.com> Date: Sun, 26 Oct 2025 06:43:26 -0500 Subject: [PATCH 539/605] refactor: clean up selection-word-chars documentation and formatting --- src/config/Config.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index b959dedba..931bc9db8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -733,8 +733,6 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// For example, to treat semicolons as part of words: /// /// selection-word-chars = " \t'\"│`|:,()[]{}<>$" -/// -/// Available since: 1.2.0 @"selection-word-chars": SelectionWordChars = .{}, /// The minimum contrast ratio between the foreground and background colors. @@ -5797,7 +5795,7 @@ pub const SelectionWordChars = struct { /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` const default_codepoints = [_]u32{ - 0, // null + 0, // null ' ', // space '\t', // tab '\'', // single quote From 9b7c20f500dcc2ef3015a78a90026b36dc06c642 Mon Sep 17 00:00:00 2001 From: mauroporras <167700+mauroporras@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:02:31 -0500 Subject: [PATCH 540/605] refactor: use u21 for Unicode codepoints and Zig 0.15 ArrayList - Change all codepoint types from u32 to u21 to align with Zig stdlib - Update ArrayList to use Zig 0.15 unmanaged pattern (.empty) - Remove unnecessary @intCast when encoding UTF-8 - Fix formatEntry to use stack-allocated buffer --- src/Surface.zig | 2 +- src/config/Config.zig | 44 +++++++++++----------- src/terminal/Screen.zig | 82 +++++++++++++++-------------------------- 3 files changed, 53 insertions(+), 75 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 13cef9e3d..91758a21a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -316,7 +316,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, selection_clear_on_typing: bool, - selection_word_chars: []const u32, + selection_word_chars: []const u21, vt_kam_allowed: bool, wait_after_command: bool, window_padding_top: u32, diff --git a/src/config/Config.zig b/src/config/Config.zig index 931bc9db8..9bbcb342b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5788,13 +5788,13 @@ pub const RepeatableString = struct { /// SelectionWordChars stores the parsed codepoints for word boundary /// characters used during text selection. The string is parsed once -/// during configuration and stored as u32 codepoints for efficient +/// during configuration and stored as u21 codepoints for efficient /// lookup during selection operations. pub const SelectionWordChars = struct { const Self = @This(); /// Default boundary characters: ` \t'"│`|:;,()[]{}<>$` - const default_codepoints = [_]u32{ + const default_codepoints = [_]u21{ 0, // null ' ', // space '\t', // tab @@ -5818,58 +5818,60 @@ pub const SelectionWordChars = struct { }; /// The parsed codepoints. Always includes null (U+0000) at index 0. - codepoints: []const u32 = &default_codepoints, + codepoints: []const u21 = &default_codepoints, pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; // Parse UTF-8 string into codepoints - var list = std.ArrayList(u32).init(alloc); - defer list.deinit(); + var list: std.ArrayList(u21) = .empty; + defer list.deinit(alloc); // Always include null as first boundary - try list.append(0); + try list.append(alloc, 0); // Parse the UTF-8 string const utf8_view = std.unicode.Utf8View.init(value) catch { // Invalid UTF-8, just use null boundary - self.codepoints = try list.toOwnedSlice(); + self.codepoints = try list.toOwnedSlice(alloc); return; }; var utf8_it = utf8_view.iterator(); while (utf8_it.nextCodepoint()) |codepoint| { - try list.append(codepoint); + try list.append(alloc, codepoint); } - self.codepoints = try list.toOwnedSlice(); + self.codepoints = try list.toOwnedSlice(alloc); } /// Deep copy of the struct. Required by Config. pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { - const copy = try alloc.dupe(u32, self.codepoints); + const copy = try alloc.dupe(u21, self.codepoints); return .{ .codepoints = copy }; } /// Compare if two values are equal. Required by Config. pub fn equal(self: Self, other: Self) bool { - return std.mem.eql(u32, self.codepoints, other.codepoints); + return std.mem.eql(u21, self.codepoints, other.codepoints); } /// Used by Formatter pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { // Convert codepoints back to UTF-8 string for display - var buf = std.ArrayList(u8).init(formatter.alloc); - defer buf.deinit(); + var buf: [4096]u8 = undefined; + var pos: usize = 0; // Skip the null character at index 0 for (self.codepoints[1..]) |codepoint| { var utf8_buf: [4]u8 = undefined; - const len = std.unicode.utf8Encode(@intCast(codepoint), &utf8_buf) catch continue; - try buf.appendSlice(utf8_buf[0..len]); + const len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch continue; + if (pos + len > buf.len) break; + @memcpy(buf[pos..][0..len], utf8_buf[0..len]); + pos += len; } - try formatter.formatEntry([]const u8, buf.items); + try formatter.formatEntry([]const u8, buf[0..pos]); } test "parseCLI" { @@ -5883,11 +5885,11 @@ pub const SelectionWordChars = struct { // Should have null + 4 characters try testing.expectEqual(@as(usize, 5), chars.codepoints.len); - try testing.expectEqual(@as(u32, 0), chars.codepoints[0]); - try testing.expectEqual(@as(u32, ' '), chars.codepoints[1]); - try testing.expectEqual(@as(u32, '\t'), chars.codepoints[2]); - try testing.expectEqual(@as(u32, ';'), chars.codepoints[3]); - try testing.expectEqual(@as(u32, ','), chars.codepoints[4]); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); } }; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 46e0f4fdb..c8db19903 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2617,7 +2617,7 @@ pub fn selectAll(self: *Screen) ?Selection { /// end_pt (inclusive). Because it selects "nearest" to start point, start /// point can be before or after end point. /// -/// The boundary_codepoints parameter should be a slice of u32 codepoints that +/// The boundary_codepoints parameter should be a slice of u21 codepoints that /// mark word boundaries, passed through to selectWord. /// /// TODO: test this @@ -2625,7 +2625,7 @@ pub fn selectWordBetween( self: *Screen, start: Pin, end: Pin, - boundary_codepoints: []const u32, + boundary_codepoints: []const u21, ) ?Selection { const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; var it = start.cellIterator(dir, end); @@ -2650,12 +2650,12 @@ pub fn selectWordBetween( /// This will return null if a selection is impossible. The only scenario /// this happens is if the point pt is outside of the written screen space. /// -/// The boundary_codepoints parameter should be a slice of u32 codepoints that +/// The boundary_codepoints parameter should be a slice of u21 codepoints that /// mark word boundaries. This is expected to be pre-parsed from the config. pub fn selectWord( self: *Screen, pin: Pin, - boundary_codepoints: []const u32, + boundary_codepoints: []const u21, ) ?Selection { _ = self; @@ -2666,9 +2666,9 @@ pub fn selectWord( // Determine if we are a boundary or not to determine what our boundary is. const expect_boundary = std.mem.indexOfAny( - u32, + u21, boundary_codepoints, - &[_]u32{start_cell.content.codepoint}, + &[_]u21{start_cell.content.codepoint}, ) != null; // Go forwards to find our end boundary @@ -2684,9 +2684,9 @@ pub fn selectWord( // If we do not match our expected set, we hit a boundary const this_boundary = std.mem.indexOfAny( - u32, + u21, boundary_codepoints, - &[_]u32{cell.content.codepoint}, + &[_]u21{cell.content.codepoint}, ) != null; if (this_boundary != expect_boundary) break :end prev; @@ -2721,9 +2721,9 @@ pub fn selectWord( // If we do not match our expected set, we hit a boundary const this_boundary = std.mem.indexOfAny( - u32, + u21, boundary_codepoints, - &[_]u32{cell.content.codepoint}, + &[_]u21{cell.content.codepoint}, ) != null; if (this_boundary != expect_boundary) break :start prev; @@ -7687,9 +7687,9 @@ test "Screen: selectWord" { try s.testWriteString("ABC DEF\n 123\n456"); // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + const boundary_codepoints = &[_]u21{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', }; // Outside of active area @@ -7808,9 +7808,9 @@ test "Screen: selectWord across soft-wrap" { try s.testWriteString(" 1234012\n 123"); // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + const boundary_codepoints = &[_]u21{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', }; { @@ -7880,9 +7880,9 @@ test "Screen: selectWord whitespace across soft-wrap" { try s.testWriteString("1 1\n 123"); // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + const boundary_codepoints = &[_]u21{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', }; // Going forward @@ -7942,9 +7942,9 @@ test "Screen: selectWord with character boundary" { const alloc = testing.allocator; // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + const boundary_codepoints = &[_]u21{ + 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', + ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', }; const cases = [_][]const u8{ @@ -8051,12 +8051,6 @@ test "Screen: selectOutput" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', - }; - // zig fmt: off { // line number: @@ -8082,7 +8076,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8098,7 +8092,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8114,7 +8108,7 @@ test "Screen: selectOutput" { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 10, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, @@ -8147,12 +8141,6 @@ test "Screen: selectPrompt basics" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', - }; - // zig fmt: off { // line number: @@ -8191,7 +8179,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 6, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8208,7 +8196,7 @@ test "Screen: selectPrompt basics" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 3, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8228,12 +8216,6 @@ test "Screen: selectPrompt prompt at start" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', - }; - // zig fmt: off { // line number: @@ -8258,7 +8240,7 @@ test "Screen: selectPrompt prompt at start" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, @@ -8278,12 +8260,6 @@ test "Screen: selectPrompt prompt at end" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // Default boundary codepoints for word selection - const boundary_codepoints = &[_]u32{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', - }; - // zig fmt: off { // line number: @@ -8308,7 +8284,7 @@ test "Screen: selectPrompt prompt at end" { var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, - } }).?, boundary_codepoints).?; + } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, From 2e0141fcdfe63cc99441161c65a15a8547594756 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 09:33:59 -0800 Subject: [PATCH 541/605] config: clarify selection-word-boundary docs --- src/Surface.zig | 7 +++++-- src/config/Config.zig | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 91758a21a..8d8a14f14 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -393,7 +393,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_copy = config.@"selection-clear-on-copy", .selection_clear_on_typing = config.@"selection-clear-on-typing", - .selection_word_chars = config.@"selection-word-chars".codepoints, + .selection_word_chars = try alloc.dupe(u21, config.@"selection-word-chars".codepoints), .vt_kam_allowed = config.@"vt-kam-allowed", .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, @@ -4264,7 +4264,10 @@ pub fn mouseButtonCallback( if (try self.linkAtPos(pos)) |link| { try self.setSelection(link.selection); } else { - const sel = screen.selectWord(pin, self.config.selection_word_chars) orelse break :sel; + const sel = screen.selectWord( + pin, + self.config.selection_word_chars, + ) orelse break :sel; try self.setSelection(sel); } try self.queueRender(); diff --git a/src/config/Config.zig b/src/config/Config.zig index 9bbcb342b..efe35604d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -722,7 +722,8 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// words in code and prose. /// /// Each character in this string becomes a word boundary. Multi-byte UTF-8 -/// characters are supported. +/// characters are supported, but only single codepoints can be specified. +/// Multi-codepoint sequences (e.g. emoji) are not supported. /// /// The null character (U+0000) is always treated as a boundary and does not /// need to be included in this configuration. @@ -733,6 +734,8 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// For example, to treat semicolons as part of words: /// /// selection-word-chars = " \t'\"│`|:,()[]{}<>$" +/// +/// Available since: 1.3.0 @"selection-word-chars": SelectionWordChars = .{}, /// The minimum contrast ratio between the foreground and background colors. From c2deda3231fa38e1bc1d1b06e8b50fc41164578c Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Mon, 3 Nov 2025 20:00:57 -0800 Subject: [PATCH 542/605] config: switch certain physical keybinds to unicode Switches several default keybindings from physical key codes `.physical = .equal // or .bracket_left or .bracket_right` to unicode characters `.unicode = '=' // or '[' or ']'` to support alternative keyboard layouts like Dvorak and keyboards with dedicated plus keys (like German layouts). I found in testing that all of these must be fixed at once otherwise the bracket physical keys overshadew the correct (for dvorak) plus key. With this fix, tab and pane navigation (cmd+[], cmd+shift+[]), as well as cmd+shift+equals and cmd+equals work as expected on dvoark layout on MacOS. --- src/config/Config.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index efe35604d..6a846b17f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6107,7 +6107,7 @@ pub const Keybinds = struct { // set the expected keybind for the menu. try self.set.put( alloc, - .{ .key = .{ .physical = .equal }, .mods = inputpkg.ctrlOrSuper(.{}) }, + .{ .key = .{ .unicode = '=' }, .mods = inputpkg.ctrlOrSuper(.{}) }, .{ .increase_font_size = 1 }, ); try self.set.put( @@ -6275,13 +6275,13 @@ pub const Keybinds = struct { ); try self.set.putFlags( alloc, - .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .unicode = '[' }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, .{ .performable = true }, ); try self.set.putFlags( alloc, - .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, + .{ .key = .{ .unicode = ']' }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, .{ .performable = true }, ); @@ -6607,12 +6607,12 @@ pub const Keybinds = struct { ); try self.set.put( alloc, - .{ .key = .{ .physical = .bracket_left }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = '[' }, .mods = .{ .super = true, .shift = true } }, .{ .previous_tab = {} }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .bracket_right }, .mods = .{ .super = true, .shift = true } }, + .{ .key = .{ .unicode = ']' }, .mods = .{ .super = true, .shift = true } }, .{ .next_tab = {} }, ); try self.set.put( @@ -6627,12 +6627,12 @@ pub const Keybinds = struct { ); try self.set.put( alloc, - .{ .key = .{ .physical = .bracket_left }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = '[' }, .mods = .{ .super = true } }, .{ .goto_split = .previous }, ); try self.set.put( alloc, - .{ .key = .{ .physical = .bracket_right }, .mods = .{ .super = true } }, + .{ .key = .{ .unicode = ']' }, .mods = .{ .super = true } }, .{ .goto_split = .next }, ); try self.set.put( @@ -6677,7 +6677,7 @@ pub const Keybinds = struct { ); try self.set.put( alloc, - .{ .key = .{ .physical = .equal }, .mods = .{ .super = true, .ctrl = true } }, + .{ .key = .{ .unicode = '=' }, .mods = .{ .super = true, .ctrl = true } }, .{ .equalize_splits = {} }, ); From cb25c0a8aeb40e940ca4ef87b24ee49db3a963a9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 10:17:24 -0800 Subject: [PATCH 543/605] build: libghostty-vt now depends on uucode directly This doesn't add any real weight to it, we only need it for a type definition. This fixes our example builds. --- src/build/GhosttyZig.zig | 3 +++ src/build/SharedDeps.zig | 26 ++++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index a8d2726bc..e63120e74 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -73,6 +73,9 @@ fn initVt( // We always need unicode tables deps.unicode_tables.addModuleImport(vt); + // We need uucode for grapheme break support + deps.addUucode(b, vt, cfg.target, cfg.optimize); + // If SIMD is enabled, add all our SIMD dependencies. if (cfg.simd) { try SharedDeps.addSimd(b, vt, null); diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index b1c084002..0ca43e78d 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -412,14 +412,7 @@ pub fn add( })) |dep| { step.root_module.addImport("z2d", dep.module("z2d")); } - if (b.lazyDependency("uucode", .{ - .target = target, - .optimize = optimize, - .tables_path = self.uucode_tables, - .build_config_path = b.path("src/build/uucode_config.zig"), - })) |dep| { - step.root_module.addImport("uucode", dep.module("uucode")); - } + self.addUucode(b, step.root_module, target, optimize); if (b.lazyDependency("zf", .{ .target = target, .optimize = optimize, @@ -878,6 +871,23 @@ pub fn gtkNgDistResources( }; } +pub fn addUucode( + self: *const SharedDeps, + b: *std.Build, + module: *std.Build.Module, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +) void { + if (b.lazyDependency("uucode", .{ + .target = target, + .optimize = optimize, + .tables_path = self.uucode_tables, + .build_config_path = b.path("src/build/uucode_config.zig"), + })) |dep| { + module.addImport("uucode", dep.module("uucode")); + } +} + // For dynamic linking, we prefer dynamic linking and to search by // mode first. Mode first will search all paths for a dynamic library // before falling back to static. From 8d2eb280dbad06790a207edf5ca8f5305aafe678 Mon Sep 17 00:00:00 2001 From: ClearAspect Date: Thu, 20 Nov 2025 15:47:56 -0500 Subject: [PATCH 544/605] custom shaders: add colorscheme information to shader uniforms Adds palette and color scheme uniforms to custom shaders, allowing custom shaders to access terminal color information: - iPalette[256]: Full 256-color terminal palette (RGB) - iBackgroundColor, iForegroundColor: Terminal colors (RGB) - iCursorColor, iCursorText: Cursor colors (RGB) - iSelectionBackgroundColor, iSelectionForegroundColor: Selection colors (RGB) Colors are normalized to [0.0, 1.0] range and update when the palette changes via OSC sequences or configuration changes. The palette_dirty flag tracks when colors need to be refreshed, initialized to true to ensure correct colors on new surfaces. --- src/config/Config.zig | 18 ++++ src/renderer/generic.zig | 100 ++++++++++++++++++++- src/renderer/shaders/shadertoy_prefix.glsl | 7 ++ src/renderer/shadertoy.zig | 7 ++ 4 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6a846b17f..8ca64efe9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2883,6 +2883,24 @@ keybind: Keybinds = .{}, /// (e.g., modifier key presses, link hover events in unfocused split panes). /// Check `iFocus > 0` to determine if the surface is currently focused. /// +/// * `vec3 iPalette[256]` - The 256-color terminal palette. +/// +/// RGB values for all 256 colors in the terminal palette, normalized +/// to [0.0, 1.0]. Index 0-15 are the ANSI colors, 16-231 are the 6x6x6 +/// color cube, and 232-255 are the grayscale colors. +/// +/// * `vec3 iBackgroundColor` - Terminal background color (RGB). +/// +/// * `vec3 iForegroundColor` - Terminal foreground color (RGB). +/// +/// * `vec3 iCursorColor` - Terminal cursor color (RGB). +/// +/// * `vec3 iCursorText` - Terminal cursor text color (RGB). +/// +/// * `vec3 iSelectionBackgroundColor` - Selection background color (RGB). +/// +/// * `vec3 iSelectionForegroundColor` - Selection foreground color (RGB). +/// /// If the shader fails to compile, the shader will be ignored. Any errors /// related to shader compilation will not show up as configuration errors /// and only show up in the log, since shader compilation happens after diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 4181badc3..b70dca364 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -227,11 +227,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + /// Captured dirty for reference after updateFrame() clears the flag + /// To be used for shader uniforms. + /// + /// Initialized to true because we need to get the correct palette even on + /// a new Surface. Otherwise we end up with it initialized to 0's + palette_dirty: bool = true, + const HighlightTag = enum(u8) { search_match, search_match_selected, }; - /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -752,6 +758,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .cursor_change_time = 0, .time_focus = 0, .focus = 1, // assume focused initially + .palette = @splat(@splat(0)), + .background_color = @splat(0), + .foreground_color = @splat(0), + .cursor_color = @splat(0), + .cursor_text = @splat(0), + .selection_background_color = @splat(0), + .selection_foreground_color = @splat(0), }, .bg_image_buffer = undefined, @@ -1209,6 +1222,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; + self.palette_dirty |= state.terminal.flags.dirty.palette; + break :critical .{ .links = links, .mouse = state.mouse, @@ -2300,6 +2315,89 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 0, }; + // Renderer state required for getting the colors + const state: *renderer.State = &self.surface_mailbox.surface.renderer_state; + + // (Updates on OSC sequence changes and configuration changes) + if (self.palette_dirty) { + + // 256-color palette + for (state.terminal.colors.palette.current, 0..) |color, i| { + self.custom_shader_uniforms.palette[i] = .{ + @as(f32, @floatFromInt(color.r)) / 255.0, + @as(f32, @floatFromInt(color.g)) / 255.0, + @as(f32, @floatFromInt(color.b)) / 255.0, + 1.0, + }; + } + + // Background color + if (state.terminal.colors.background.get()) |bg| { + self.custom_shader_uniforms.background_color = .{ + @as(f32, @floatFromInt(bg.r)) / 255.0, + @as(f32, @floatFromInt(bg.g)) / 255.0, + @as(f32, @floatFromInt(bg.b)) / 255.0, + 1.0, + }; + } + + // Foreground color + if (state.terminal.colors.foreground.get()) |fg| { + self.custom_shader_uniforms.foreground_color = .{ + @as(f32, @floatFromInt(fg.r)) / 255.0, + @as(f32, @floatFromInt(fg.g)) / 255.0, + @as(f32, @floatFromInt(fg.b)) / 255.0, + 1.0, + }; + } + + // Cursor color + if (state.terminal.colors.cursor.get()) |cursor_color| { + self.custom_shader_uniforms.cursor_color = .{ + @as(f32, @floatFromInt(cursor_color.r)) / 255.0, + @as(f32, @floatFromInt(cursor_color.g)) / 255.0, + @as(f32, @floatFromInt(cursor_color.b)) / 255.0, + 1.0, + }; + } + + // NOTE: the following could be optimized to follow a change in + // config for a slight optimization however this is only 12 bytes + // each being updated and likely isn't a cause for concern + + // Cursor text color + if (self.config.cursor_text) |cursor_text| { + self.custom_shader_uniforms.cursor_text = .{ + @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, + @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, + @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, + 1.0, + }; + } + + // Selection background color + if (self.config.selection_background) |seletion_bg| { + self.custom_shader_uniforms.selection_background_color = .{ + @as(f32, @floatFromInt(seletion_bg.color.r)) / 255.0, + @as(f32, @floatFromInt(seletion_bg.color.g)) / 255.0, + @as(f32, @floatFromInt(seletion_bg.color.b)) / 255.0, + 1.0, + }; + } + + // Selection foreground color + if (self.config.selection_foreground) |selection_fg| { + self.custom_shader_uniforms.selection_foreground_color = .{ + @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, + @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, + @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, + 1.0, + }; + } + + self.palette_dirty = false; + } + // Update custom cursor uniforms, if we have a cursor. if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl index c984334f4..661bd233d 100644 --- a/src/renderer/shaders/shadertoy_prefix.glsl +++ b/src/renderer/shaders/shadertoy_prefix.glsl @@ -18,6 +18,13 @@ layout(binding = 1, std140) uniform Globals { uniform float iTimeCursorChange; uniform float iTimeFocus; uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; }; layout(binding = 0) uniform sampler2D iChannel0; diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index f71200610..7d0ad4b0a 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -27,6 +27,13 @@ pub const Uniforms = extern struct { cursor_change_time: f32 align(4), time_focus: f32 align(4), focus: i32 align(4), + palette: [256][4]f32 align(16), + background_color: [4]f32 align(16), + foreground_color: [4]f32 align(16), + cursor_color: [4]f32 align(16), + cursor_text: [4]f32 align(16), + selection_background_color: [4]f32 align(16), + selection_foreground_color: [4]f32 align(16), }; /// The target to load shaders for. From 87867459690de41063ac4f065a600d1b800442f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 11:24:25 -0800 Subject: [PATCH 545/605] renderer: don't access shared state for custom shader color palettes --- src/renderer/generic.zig | 44 +++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index b70dca364..6f5a3d2ca 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2315,14 +2315,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 0, }; - // Renderer state required for getting the colors - const state: *renderer.State = &self.surface_mailbox.surface.renderer_state; - // (Updates on OSC sequence changes and configuration changes) if (self.palette_dirty) { + const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; // 256-color palette - for (state.terminal.colors.palette.current, 0..) |color, i| { + for (colors.palette, 0..) |color, i| { self.custom_shader_uniforms.palette[i] = .{ @as(f32, @floatFromInt(color.r)) / 255.0, @as(f32, @floatFromInt(color.g)) / 255.0, @@ -2332,27 +2330,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Background color - if (state.terminal.colors.background.get()) |bg| { - self.custom_shader_uniforms.background_color = .{ - @as(f32, @floatFromInt(bg.r)) / 255.0, - @as(f32, @floatFromInt(bg.g)) / 255.0, - @as(f32, @floatFromInt(bg.b)) / 255.0, - 1.0, - }; - } + self.custom_shader_uniforms.background_color = .{ + @as(f32, @floatFromInt(colors.background.r)) / 255.0, + @as(f32, @floatFromInt(colors.background.g)) / 255.0, + @as(f32, @floatFromInt(colors.background.b)) / 255.0, + 1.0, + }; // Foreground color - if (state.terminal.colors.foreground.get()) |fg| { - self.custom_shader_uniforms.foreground_color = .{ - @as(f32, @floatFromInt(fg.r)) / 255.0, - @as(f32, @floatFromInt(fg.g)) / 255.0, - @as(f32, @floatFromInt(fg.b)) / 255.0, - 1.0, - }; - } + self.custom_shader_uniforms.foreground_color = .{ + @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, + @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, + @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, + 1.0, + }; // Cursor color - if (state.terminal.colors.cursor.get()) |cursor_color| { + if (colors.cursor) |cursor_color| { self.custom_shader_uniforms.cursor_color = .{ @as(f32, @floatFromInt(cursor_color.r)) / 255.0, @as(f32, @floatFromInt(cursor_color.g)) / 255.0, @@ -2376,11 +2370,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Selection background color - if (self.config.selection_background) |seletion_bg| { + if (self.config.selection_background) |selection_bg| { self.custom_shader_uniforms.selection_background_color = .{ - @as(f32, @floatFromInt(seletion_bg.color.r)) / 255.0, - @as(f32, @floatFromInt(seletion_bg.color.g)) / 255.0, - @as(f32, @floatFromInt(seletion_bg.color.b)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, 1.0, }; } From 7204f7ef9e70d0a87aef95903399f60d8071579a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 11:31:43 -0800 Subject: [PATCH 546/605] renderer: remove palette_dirty, rely on terminal state --- src/renderer/generic.zig | 221 ++++++++++++++++++++------------------- 1 file changed, 112 insertions(+), 109 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 6f5a3d2ca..f3c0556f5 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -227,13 +227,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, - /// Captured dirty for reference after updateFrame() clears the flag - /// To be used for shader uniforms. - /// - /// Initialized to true because we need to get the correct palette even on - /// a new Surface. Otherwise we end up with it initialized to 0's - palette_dirty: bool = true, - const HighlightTag = enum(u8) { search_match, search_match_selected, @@ -1222,8 +1215,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; - self.palette_dirty |= state.terminal.flags.dirty.palette; - break :critical .{ .links = links, .mouse = state.mouse, @@ -1291,26 +1282,25 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - // Build our GPU cells - try self.rebuildCells( - critical.preedit, - renderer.cursorStyle(&self.terminal_state, .{ - .preedit = critical.preedit != null, - .focused = self.focused, - .blink_visible = cursor_blink_visible, - }), - &critical.links, - ); + // Reset our dirty state after updating. + defer self.terminal_state.dirty = .false; - // Notify our shaper we're done for the frame. For some shapers, - // such as CoreText, this triggers off-thread cleanup logic. - self.font_shaper.endFrame(); - - // Acquire the draw mutex because we're modifying state here. + // Acquire the draw mutex for all remaining state updates. { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); + // Build our GPU cells + try self.rebuildCells( + critical.preedit, + renderer.cursorStyle(&self.terminal_state, .{ + .preedit = critical.preedit != null, + .focused = self.focused, + .blink_visible = cursor_blink_visible, + }), + &critical.links, + ); + // The scrollbar is only emitted during draws so we also // check the scrollbar cache here and update if needed. // This is pretty fast. @@ -1337,7 +1327,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { else => {}, }; + + // Update custom shader uniforms that depend on terminal state. + self.updateCustomShaderUniformsFromState(); } + + // Notify our shaper we're done for the frame. For some shapers, + // such as CoreText, this triggers off-thread cleanup logic. + self.font_shaper.endFrame(); } /// Draw the frame to the screen. @@ -1459,8 +1456,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Upload the background image to the GPU as necessary. try self.uploadBackgroundImage(); - // Update custom shader uniforms if necessary. - try self.updateCustomShaderUniforms(); + // Update per-frame custom shader uniforms. + try self.updateCustomShaderUniformsForFrame(); // Setup our frame data try frame.uniforms.sync(&.{self.uniforms}); @@ -2274,10 +2271,93 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.bg_image_buffer_modified +%= 1; } - /// Update uniforms for the custom shaders, if necessary. + /// Update custom shader uniforms that depend on terminal state. + /// + /// This should be called in `updateFrame` when terminal state changes. + fn updateCustomShaderUniformsFromState(self: *Self) void { + // We only need to do this if we have custom shaders. + if (!self.has_custom_shaders) return; + + // Only update when terminal state is dirty. + if (self.terminal_state.dirty == .false) return; + + const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; + + // 256-color palette + for (colors.palette, 0..) |color, i| { + self.custom_shader_uniforms.palette[i] = .{ + @as(f32, @floatFromInt(color.r)) / 255.0, + @as(f32, @floatFromInt(color.g)) / 255.0, + @as(f32, @floatFromInt(color.b)) / 255.0, + 1.0, + }; + } + + // Background color + self.custom_shader_uniforms.background_color = .{ + @as(f32, @floatFromInt(colors.background.r)) / 255.0, + @as(f32, @floatFromInt(colors.background.g)) / 255.0, + @as(f32, @floatFromInt(colors.background.b)) / 255.0, + 1.0, + }; + + // Foreground color + self.custom_shader_uniforms.foreground_color = .{ + @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, + @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, + @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, + 1.0, + }; + + // Cursor color + if (colors.cursor) |cursor_color| { + self.custom_shader_uniforms.cursor_color = .{ + @as(f32, @floatFromInt(cursor_color.r)) / 255.0, + @as(f32, @floatFromInt(cursor_color.g)) / 255.0, + @as(f32, @floatFromInt(cursor_color.b)) / 255.0, + 1.0, + }; + } + + // NOTE: the following could be optimized to follow a change in + // config for a slight optimization however this is only 12 bytes + // each being updated and likely isn't a cause for concern + + // Cursor text color + if (self.config.cursor_text) |cursor_text| { + self.custom_shader_uniforms.cursor_text = .{ + @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, + @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, + @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, + 1.0, + }; + } + + // Selection background color + if (self.config.selection_background) |selection_bg| { + self.custom_shader_uniforms.selection_background_color = .{ + @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, + 1.0, + }; + } + + // Selection foreground color + if (self.config.selection_foreground) |selection_fg| { + self.custom_shader_uniforms.selection_foreground_color = .{ + @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, + @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, + @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, + 1.0, + }; + } + } + + /// Update per-frame custom shader uniforms. /// /// This should be called exactly once per frame, inside `drawFrame`. - fn updateCustomShaderUniforms(self: *Self) !void { + fn updateCustomShaderUniformsForFrame(self: *Self) !void { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; @@ -2315,83 +2395,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 0, }; - // (Updates on OSC sequence changes and configuration changes) - if (self.palette_dirty) { - const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; - - // 256-color palette - for (colors.palette, 0..) |color, i| { - self.custom_shader_uniforms.palette[i] = .{ - @as(f32, @floatFromInt(color.r)) / 255.0, - @as(f32, @floatFromInt(color.g)) / 255.0, - @as(f32, @floatFromInt(color.b)) / 255.0, - 1.0, - }; - } - - // Background color - self.custom_shader_uniforms.background_color = .{ - @as(f32, @floatFromInt(colors.background.r)) / 255.0, - @as(f32, @floatFromInt(colors.background.g)) / 255.0, - @as(f32, @floatFromInt(colors.background.b)) / 255.0, - 1.0, - }; - - // Foreground color - self.custom_shader_uniforms.foreground_color = .{ - @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, - @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, - @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, - 1.0, - }; - - // Cursor color - if (colors.cursor) |cursor_color| { - self.custom_shader_uniforms.cursor_color = .{ - @as(f32, @floatFromInt(cursor_color.r)) / 255.0, - @as(f32, @floatFromInt(cursor_color.g)) / 255.0, - @as(f32, @floatFromInt(cursor_color.b)) / 255.0, - 1.0, - }; - } - - // NOTE: the following could be optimized to follow a change in - // config for a slight optimization however this is only 12 bytes - // each being updated and likely isn't a cause for concern - - // Cursor text color - if (self.config.cursor_text) |cursor_text| { - self.custom_shader_uniforms.cursor_text = .{ - @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, - @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, - @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, - 1.0, - }; - } - - // Selection background color - if (self.config.selection_background) |selection_bg| { - self.custom_shader_uniforms.selection_background_color = .{ - @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, - @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, - @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, - 1.0, - }; - } - - // Selection foreground color - if (self.config.selection_foreground) |selection_fg| { - self.custom_shader_uniforms.selection_foreground_color = .{ - @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, - @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, - @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, - 1.0, - }; - } - - self.palette_dirty = false; - } - // Update custom cursor uniforms, if we have a cursor. if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); @@ -2480,6 +2483,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. + /// + /// This requires the draw mutex. + /// + /// Dirty state on terminal state won't be reset by this. fn rebuildCells( self: *Self, preedit: ?renderer.State.Preedit, @@ -2487,10 +2494,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { links: *const terminal.RenderState.CellSet, ) !void { const state: *terminal.RenderState = &self.terminal_state; - defer state.dirty = .false; - - self.draw_mutex.lock(); - defer self.draw_mutex.unlock(); // const start = try std.time.Instant.now(); // const start_micro = std.time.microTimestamp(); From e3c39ed502c62a907dff935089fe9e2747356da9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 11:46:24 -0800 Subject: [PATCH 547/605] renderer: cell.Contents.resize errdefer handling is now safe --- src/renderer/cell.zig | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 9e5802ea5..5ea5b7ab0 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -90,7 +90,6 @@ pub const Contents = struct { const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); errdefer alloc.free(bg_cells); - @memset(bg_cells, .{ 0, 0, 0, 0 }); // The foreground lists can hold 3 types of items: @@ -106,32 +105,28 @@ pub const Contents = struct { // We have size.rows + 2 lists because indexes 0 and size.rows - 1 are // used for special lists containing the cursor cell which need to // be first and last in the buffer, respectively. - var fg_rows = try ArrayListCollection(shaderpkg.CellText).init( + var fg_rows: ArrayListCollection(shaderpkg.CellText) = try .init( alloc, size.rows + 2, size.columns * 3, ); errdefer fg_rows.deinit(alloc); - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - - self.bg_cells = bg_cells; - self.fg_rows = fg_rows; - // We don't need 3*cols worth of cells for the cursor lists, so we can // replace them with smaller lists. This is technically a tiny bit of // extra work but resize is not a hot function so it's worth it to not // waste the memory. - self.fg_rows.lists[0].deinit(alloc); - self.fg_rows.lists[0] = try std.ArrayListUnmanaged( - shaderpkg.CellText, - ).initCapacity(alloc, 1); + fg_rows.lists[0].deinit(alloc); + fg_rows.lists[0] = try .initCapacity(alloc, 1); + fg_rows.lists[size.rows + 1].deinit(alloc); + fg_rows.lists[size.rows + 1] = try .initCapacity(alloc, 1); - self.fg_rows.lists[size.rows + 1].deinit(alloc); - self.fg_rows.lists[size.rows + 1] = try std.ArrayListUnmanaged( - shaderpkg.CellText, - ).initCapacity(alloc, 1); + // Perform the swap, no going back from here. + errdefer comptime unreachable; + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + self.bg_cells = bg_cells; + self.fg_rows = fg_rows; } /// Reset the cell contents to an empty state without resizing. From e875b453b7342767f7b8315ba1628853e6fb4cb6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 11:50:58 -0800 Subject: [PATCH 548/605] font/shaper: hook functions can't fail --- src/font/shaper/coretext.zig | 8 ++++---- src/font/shaper/harfbuzz.zig | 4 ++-- src/font/shaper/run.zig | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index cc05022c4..ab3c6aaab 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -98,7 +98,7 @@ pub const Shaper = struct { self.unichars.deinit(alloc); } - fn reset(self: *RunState) !void { + fn reset(self: *RunState) void { self.codepoints.clearRetainingCapacity(); self.unichars.clearRetainingCapacity(); } @@ -644,8 +644,8 @@ pub const Shaper = struct { pub const RunIteratorHook = struct { shaper: *Shaper, - pub fn prepare(self: *RunIteratorHook) !void { - try self.shaper.run_state.reset(); + pub fn prepare(self: *RunIteratorHook) void { + self.shaper.run_state.reset(); // log.warn("----------- run reset -------------", .{}); } @@ -681,7 +681,7 @@ pub const Shaper = struct { }); } - pub fn finalize(self: RunIteratorHook) !void { + pub fn finalize(self: RunIteratorHook) void { _ = self; } }; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index e4a9301e8..6dcc0f94e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -175,7 +175,7 @@ pub const Shaper = struct { pub const RunIteratorHook = struct { shaper: *Shaper, - pub fn prepare(self: RunIteratorHook) !void { + pub fn prepare(self: RunIteratorHook) void { // Reset the buffer for our current run self.shaper.hb_buf.reset(); self.shaper.hb_buf.setContentType(.unicode); @@ -191,7 +191,7 @@ pub const Shaper = struct { self.shaper.hb_buf.add(cp, cluster); } - pub fn finalize(self: RunIteratorHook) !void { + pub fn finalize(self: RunIteratorHook) void { self.shaper.hb_buf.guessSegmentProperties(); } }; diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 85c5c410b..45c5d38ca 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -73,7 +73,7 @@ pub const RunIterator = struct { var current_font: font.Collection.Index = .{}; // Allow the hook to prepare - try self.hooks.prepare(); + self.hooks.prepare(); // Initialize our hash for this run. var hasher = Hasher.init(0); @@ -283,7 +283,7 @@ pub const RunIterator = struct { } // Finalize our buffer - try self.hooks.finalize(); + self.hooks.finalize(); // Add our length to the hash as an additional mechanism to avoid collisions autoHash(&hasher, j - self.i); From 112363c4e174eb9335e8905f052c02c78dacc882 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 11:57:18 -0800 Subject: [PATCH 549/605] font: Collection.getEntry explicit error set --- src/font/Collection.zig | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 412098f10..5d7bfa519 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -196,8 +196,18 @@ pub fn getFace(self: *Collection, index: Index) !*Face { return try self.getFaceFromEntry(try self.getEntry(index)); } +pub const EntryError = error{ + /// Index represents a special font (built-in) and these don't + /// have an associated face. This should be caught upstream and use + /// alternate logic. + SpecialHasNoFace, + + /// Invalid index. + IndexOutOfBounds, +}; + /// Get the unaliased entry from an index -pub fn getEntry(self: *Collection, index: Index) !*Entry { +pub fn getEntry(self: *Collection, index: Index) EntryError!*Entry { if (index.special() != null) return error.SpecialHasNoFace; const list = self.faces.getPtr(index.style); if (index.idx >= list.len) return error.IndexOutOfBounds; From db092ac3cec33998a4e3741dbe47775ae49b6a58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 12:05:35 -0800 Subject: [PATCH 550/605] renderer: extract rebuildRow to its own function --- src/renderer/generic.zig | 905 ++++++++++++++++++++------------------- 1 file changed, 465 insertions(+), 440 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index f3c0556f5..bc81fa98e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2480,6 +2480,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + const PreeditRange = struct { + y: terminal.size.CellCountInt, + x: [2]terminal.size.CellCountInt, + cp_offset: usize, + }; + /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. @@ -2505,11 +2511,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. - const preedit_range: ?struct { - y: terminal.size.CellCountInt, - x: [2]terminal.size.CellCountInt, - cp_offset: usize, - } = if (preedit) |preedit_v| preedit: { + const preedit_range: ?PreeditRange = if (preedit) |preedit_v| preedit: { // We base the preedit on the position of the cursor in the // viewport. If the cursor isn't visible in the viewport we // don't show it. @@ -2587,7 +2589,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { row_dirty[0..row_len], row_selection[0..row_len], row_highlights[0..row_len], - ) |y_usize, row, *cells, *dirty, selection, highlights| { + ) |y_usize, row, *cells, *dirty, selection, *highlights| { const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { @@ -2601,440 +2603,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Unmark the dirty state in our render state. dirty.* = false; - // If our viewport is wider than our cell contents buffer, - // we still only process cells up to the width of the buffer. - const cells_slice = cells.slice(); - const cells_len = @min(cells_slice.len, self.cells.size.columns); - const cells_raw = cells_slice.items(.raw); - const cells_style = cells_slice.items(.style); - - // On primary screen, we still apply vertical padding - // extension under certain conditions we feel are safe. - // - // This helps make some scenarios look better while - // avoiding scenarios we know do NOT look good. - switch (self.config.padding_color) { - // These already have the correct values set above. - .background, .@"extend-always" => {}, - - // Apply heuristics for padding extension. - .extend => if (y == 0) { - self.uniforms.padding_extend.up = !rowNeverExtendBg( - row, - cells_raw, - cells_style, - &state.colors.palette, - state.colors.background, - ); - } else if (y == self.cells.size.rows - 1) { - self.uniforms.padding_extend.down = !rowNeverExtendBg( - row, - cells_raw, - cells_style, - &state.colors.palette, - state.colors.background, - ); - }, - } - - // Iterator of runs for shaping. - var run_iter_opts: font.shape.RunOptions = .{ - .grid = self.font_grid, - .cells = cells_slice, - .selection = if (selection) |s| s else null, - - // We want to do font shaping as long as the cursor is - // visible on this viewport. - .cursor_x = cursor_x: { - const vp = state.cursor.viewport orelse break :cursor_x null; - if (vp.y != y) break :cursor_x null; - break :cursor_x vp.x; - }, - }; - run_iter_opts.applyBreakConfig(self.config.font_shaping_break); - var run_iter = self.font_shaper.runIterator(run_iter_opts); - var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); - var shaper_cells: ?[]const font.shape.Cell = null; - var shaper_cells_i: usize = 0; - - for ( - 0.., - cells_raw[0..cells_len], - cells_style[0..cells_len], - ) |x, *cell, *managed_style| { - // If this cell falls within our preedit range then we - // skip this because preedits are setup separately. - if (preedit_range) |range| preedit: { - // We're not on the preedit line, no actions necessary. - if (range.y != y) break :preedit; - // We're before the preedit range, no actions necessary. - if (x < range.x[0]) break :preedit; - // We're in the preedit range, skip this cell. - if (x <= range.x[1]) continue; - // After exiting the preedit range we need to catch - // the run position up because of the missed cells. - // In all other cases, no action is necessary. - if (x != range.x[1] + 1) break :preedit; - - // Step the run iterator until we find a run that ends - // after the current cell, which will be the soonest run - // that might contain glyphs for our cell. - while (shaper_run) |run| { - if (run.offset + run.cells > x) break; - shaper_run = try run_iter.next(self.alloc); - shaper_cells = null; - shaper_cells_i = 0; - } - - const run = shaper_run orelse break :preedit; - - // If we haven't shaped this run, do so now. - shaper_cells = shaper_cells orelse - // Try to read the cells from the shaping cache if we can. - self.font_shaper_cache.get(run) orelse - cache: { - // Otherwise we have to shape them. - const new_cells = try self.font_shaper.shape(run); - - // Try to cache them. If caching fails for any reason we - // continue because it is just a performance optimization, - // not a correctness issue. - self.font_shaper_cache.put( - self.alloc, - run, - new_cells, - ) catch |err| { - log.warn( - "error caching font shaping results err={}", - .{err}, - ); - }; - - // The cells we get from direct shaping are always owned - // by the shaper and valid until the next shaping call so - // we can safely use them. - break :cache new_cells; - }; - - // Advance our index until we reach or pass - // our current x position in the shaper cells. - const shaper_cells_unwrapped = shaper_cells.?; - while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) { - shaper_cells_i += 1; - } - } - - const wide = cell.wide; - const style: terminal.Style = if (cell.hasStyling()) - managed_style.* - else - .{}; - - // True if this cell is selected - const selected: enum { - false, - selection, - search, - search_selected, - } = selected: { - // Order below matters for precedence. - - // Selection should take the highest precedence. - const x_compare = if (wide == .spacer_tail) - x -| 1 - else - x; - if (selection) |sel| { - if (x_compare >= sel[0] and - x_compare <= sel[1]) break :selected .selection; - } - - // If we're highlighted, then we're selected. In the - // future we want to use a different style for this - // but this to get started. - for (highlights.items) |hl| { - if (x_compare >= hl.range[0] and - x_compare <= hl.range[1]) - { - const tag: HighlightTag = @enumFromInt(hl.tag); - break :selected switch (tag) { - .search_match => .search, - .search_match_selected => .search_selected, - }; - } - } - - break :selected .false; - }; - - // The `_style` suffixed values are the colors based on - // the cell style (SGR), before applying any additional - // configuration, inversions, selections, etc. - const bg_style = style.bg( - cell, - &state.colors.palette, - ); - const fg_style = style.fg(.{ - .default = state.colors.foreground, - .palette = &state.colors.palette, - .bold = self.config.bold_color, - }); - - // The final background color for the cell. - const bg = switch (selected) { - // If we have an explicit selection background color - // specified in the config, use that. - // - // If no configuration, then our selection background - // is our foreground color. - .selection => if (self.config.selection_background) |v| switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, - } else state.colors.foreground, - - .search => switch (self.config.search_background) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, - }, - - .search_selected => switch (self.config.search_selected_background) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, - }, - - // Not selected - .false => if (style.flags.inverse != isCovering(cell.codepoint())) - // Two cases cause us to invert (use the fg color as the bg) - // - The "inverse" style flag. - // - A "covering" glyph; we use fg for bg in that - // case to help make sure that padding extension - // works correctly. - // - // If one of these is true (but not the other) - // then we use the fg style color for the bg. - fg_style - else - // Otherwise they cancel out. - bg_style, - }; - - const fg = fg: { - // Our happy-path non-selection background color - // is our style or our configured defaults. - const final_bg = bg_style orelse state.colors.background; - - // Whether we need to use the bg color as our fg color: - // - Cell is selected, inverted, and set to cell-foreground - // - Cell is selected, not inverted, and set to cell-background - // - Cell is inverted and not selected - break :fg switch (selected) { - .selection => if (self.config.selection_foreground) |v| switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, - } else state.colors.background, - - .search => switch (self.config.search_foreground) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, - }, - - .search_selected => switch (self.config.search_selected_foreground) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, - }, - - .false => if (style.flags.inverse) - final_bg - else - fg_style, - }; - }; - - // Foreground alpha for this cell. - const alpha: u8 = if (style.flags.faint) self.config.faint_opacity else 255; - - // Set the cell's background color. - { - const rgb = bg orelse state.colors.background; - - // Determine our background alpha. If we have transparency configured - // then this is dynamic depending on some situations. This is all - // in an attempt to make transparency look the best for various - // situations. See inline comments. - const bg_alpha: u8 = bg_alpha: { - const default: u8 = 255; - - // Cells that are selected should be fully opaque. - if (selected != .false) break :bg_alpha default; - - // Cells that are reversed should be fully opaque. - if (style.flags.inverse) break :bg_alpha default; - - // If the user requested to have opacity on all cells, apply it. - if (self.config.background_opacity_cells and bg_style != null) { - var opacity: f64 = @floatFromInt(default); - opacity *= self.config.background_opacity; - break :bg_alpha @intFromFloat(opacity); - } - - // Cells that have an explicit bg color should be fully opaque. - if (bg_style != null) break :bg_alpha default; - - // Otherwise, we won't draw the bg for this cell, - // we'll let the already-drawn background color - // show through. - break :bg_alpha 0; - }; - - self.cells.bgCell(y, x).* = .{ - rgb.r, rgb.g, rgb.b, bg_alpha, - }; - } - - // If the invisible flag is set on this cell then we - // don't need to render any foreground elements, so - // we just skip all glyphs with this x coordinate. - // - // NOTE: This behavior matches xterm. Some other terminal - // emulators, e.g. Alacritty, still render text decorations - // and only make the text itself invisible. The decision - // has been made here to match xterm's behavior for this. - if (style.flags.invisible) { - continue; - } - - // Give links a single underline, unless they already have - // an underline, in which case use a double underline to - // distinguish them. - const underline: terminal.Attribute.Underline = underline: { - if (links.contains(.{ - .x = @intCast(x), - .y = @intCast(y), - })) { - break :underline if (style.flags.underline == .single) - .double - else - .single; - } - break :underline style.flags.underline; - }; - - // We draw underlines first so that they layer underneath text. - // This improves readability when a colored underline is used - // which intersects parts of the text (descenders). - if (underline != .none) self.addUnderline( - @intCast(x), - @intCast(y), - underline, - style.underlineColor(&state.colors.palette) orelse fg, - alpha, - ) catch |err| { - log.warn( - "error adding underline to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - - if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| { - log.warn( - "error adding overline to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - - // If we're at or past the end of our shaper run then - // we need to get the next run from the run iterator. - if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) { - shaper_run = try run_iter.next(self.alloc); - shaper_cells = null; - shaper_cells_i = 0; - } - - if (shaper_run) |run| glyphs: { - // If we haven't shaped this run yet, do so. - shaper_cells = shaper_cells orelse - // Try to read the cells from the shaping cache if we can. - self.font_shaper_cache.get(run) orelse - cache: { - // Otherwise we have to shape them. - const new_cells = try self.font_shaper.shape(run); - - // Try to cache them. If caching fails for any reason we - // continue because it is just a performance optimization, - // not a correctness issue. - self.font_shaper_cache.put( - self.alloc, - run, - new_cells, - ) catch |err| { - log.warn( - "error caching font shaping results err={}", - .{err}, - ); - }; - - // The cells we get from direct shaping are always owned - // by the shaper and valid until the next shaping call so - // we can safely use them. - break :cache new_cells; - }; - - const shaped_cells = shaper_cells orelse break :glyphs; - - // If there are no shaper cells for this run, ignore it. - // This can occur for runs of empty cells, and is fine. - if (shaped_cells.len == 0) break :glyphs; - - // If we encounter a shaper cell to the left of the current - // cell then we have some problems. This logic relies on x - // position monotonically increasing. - assert(run.offset + shaped_cells[shaper_cells_i].x >= x); - - // NOTE: An assumption is made here that a single cell will never - // be present in more than one shaper run. If that assumption is - // violated, this logic breaks. - - while (shaper_cells_i < shaped_cells.len and - run.offset + shaped_cells[shaper_cells_i].x == x) : ({ - shaper_cells_i += 1; - }) { - self.addGlyph( - @intCast(x), - @intCast(y), - state.cols, - cells_raw, - shaped_cells[shaper_cells_i], - shaper_run.?, - fg, - alpha, - ) catch |err| { - log.warn( - "error adding glyph to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - } - } - - // Finally, draw a strikethrough if necessary. - if (style.flags.strikethrough) self.addStrikethrough( - @intCast(x), - @intCast(y), - fg, - alpha, - ) catch |err| { - log.warn( - "error adding strikethrough to cell, will be invalid x={} y={}, err={}", - .{ x, y, err }, - ); - }; - } + try self.rebuildRow( + y, + row, + cells, + preedit_range, + selection, + highlights, + links, + ); } // Setup our cursor rendering information. @@ -3203,6 +2780,454 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // }); } + fn rebuildRow( + self: *Self, + y: terminal.size.CellCountInt, + row: terminal.page.Row, + cells: *std.MultiArrayList(terminal.RenderState.Cell), + preedit_range: ?PreeditRange, + selection: ?[2]terminal.size.CellCountInt, + highlights: *const std.ArrayList(terminal.RenderState.Highlight), + links: *const terminal.RenderState.CellSet, + ) !void { + const state = &self.terminal_state; + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const cells_slice = cells.slice(); + const cells_len = @min(cells_slice.len, self.cells.size.columns); + const cells_raw = cells_slice.items(.raw); + const cells_style = cells_slice.items(.style); + + // On primary screen, we still apply vertical padding + // extension under certain conditions we feel are safe. + // + // This helps make some scenarios look better while + // avoiding scenarios we know do NOT look good. + switch (self.config.padding_color) { + // These already have the correct values set above. + .background, .@"extend-always" => {}, + + // Apply heuristics for padding extension. + .extend => if (y == 0) { + self.uniforms.padding_extend.up = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, + ); + } else if (y == self.cells.size.rows - 1) { + self.uniforms.padding_extend.down = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, + ); + }, + } + + // Iterator of runs for shaping. + var run_iter_opts: font.shape.RunOptions = .{ + .grid = self.font_grid, + .cells = cells_slice, + .selection = if (selection) |s| s else null, + + // We want to do font shaping as long as the cursor is + // visible on this viewport. + .cursor_x = cursor_x: { + const vp = state.cursor.viewport orelse break :cursor_x null; + if (vp.y != y) break :cursor_x null; + break :cursor_x vp.x; + }, + }; + run_iter_opts.applyBreakConfig(self.config.font_shaping_break); + var run_iter = self.font_shaper.runIterator(run_iter_opts); + var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); + var shaper_cells: ?[]const font.shape.Cell = null; + var shaper_cells_i: usize = 0; + + for ( + 0.., + cells_raw[0..cells_len], + cells_style[0..cells_len], + ) |x, *cell, *managed_style| { + // If this cell falls within our preedit range then we + // skip this because preedits are setup separately. + if (preedit_range) |range| preedit: { + // We're not on the preedit line, no actions necessary. + if (range.y != y) break :preedit; + // We're before the preedit range, no actions necessary. + if (x < range.x[0]) break :preedit; + // We're in the preedit range, skip this cell. + if (x <= range.x[1]) continue; + // After exiting the preedit range we need to catch + // the run position up because of the missed cells. + // In all other cases, no action is necessary. + if (x != range.x[1] + 1) break :preedit; + + // Step the run iterator until we find a run that ends + // after the current cell, which will be the soonest run + // that might contain glyphs for our cell. + while (shaper_run) |run| { + if (run.offset + run.cells > x) break; + shaper_run = try run_iter.next(self.alloc); + shaper_cells = null; + shaper_cells_i = 0; + } + + const run = shaper_run orelse break :preedit; + + // If we haven't shaped this run, do so now. + shaper_cells = shaper_cells orelse + // Try to read the cells from the shaping cache if we can. + self.font_shaper_cache.get(run) orelse + cache: { + // Otherwise we have to shape them. + const new_cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we + // continue because it is just a performance optimization, + // not a correctness issue. + self.font_shaper_cache.put( + self.alloc, + run, + new_cells, + ) catch |err| { + log.warn( + "error caching font shaping results err={}", + .{err}, + ); + }; + + // The cells we get from direct shaping are always owned + // by the shaper and valid until the next shaping call so + // we can safely use them. + break :cache new_cells; + }; + + // Advance our index until we reach or pass + // our current x position in the shaper cells. + const shaper_cells_unwrapped = shaper_cells.?; + while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) { + shaper_cells_i += 1; + } + } + + const wide = cell.wide; + const style: terminal.Style = if (cell.hasStyling()) + managed_style.* + else + .{}; + + // True if this cell is selected + const selected: enum { + false, + selection, + search, + search_selected, + } = selected: { + // Order below matters for precedence. + + // Selection should take the highest precedence. + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + if (selection) |sel| { + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + } + + // If we're highlighted, then we're selected. In the + // future we want to use a different style for this + // but this to get started. + for (highlights.items) |hl| { + if (x_compare >= hl.range[0] and + x_compare <= hl.range[1]) + { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; + } + } + + break :selected .false; + }; + + // The `_style` suffixed values are the colors based on + // the cell style (SGR), before applying any additional + // configuration, inversions, selections, etc. + const bg_style = style.bg( + cell, + &state.colors.palette, + ); + const fg_style = style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, + .bold = self.config.bold_color, + }); + + // The final background color for the cell. + const bg = switch (selected) { + // If we have an explicit selection background color + // specified in the config, use that. + // + // If no configuration, then our selection background + // is our foreground color. + .selection => if (self.config.selection_background) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + } else state.colors.foreground, + + .search => switch (self.config.search_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + + // Not selected + .false => if (style.flags.inverse != isCovering(cell.codepoint())) + // Two cases cause us to invert (use the fg color as the bg) + // - The "inverse" style flag. + // - A "covering" glyph; we use fg for bg in that + // case to help make sure that padding extension + // works correctly. + // + // If one of these is true (but not the other) + // then we use the fg style color for the bg. + fg_style + else + // Otherwise they cancel out. + bg_style, + }; + + const fg = fg: { + // Our happy-path non-selection background color + // is our style or our configured defaults. + const final_bg = bg_style orelse state.colors.background; + + // Whether we need to use the bg color as our fg color: + // - Cell is selected, inverted, and set to cell-foreground + // - Cell is selected, not inverted, and set to cell-background + // - Cell is inverted and not selected + break :fg switch (selected) { + .selection => if (self.config.selection_foreground) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + } else state.colors.background, + + .search => switch (self.config.search_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + + .false => if (style.flags.inverse) + final_bg + else + fg_style, + }; + }; + + // Foreground alpha for this cell. + const alpha: u8 = if (style.flags.faint) self.config.faint_opacity else 255; + + // Set the cell's background color. + { + const rgb = bg orelse state.colors.background; + + // Determine our background alpha. If we have transparency configured + // then this is dynamic depending on some situations. This is all + // in an attempt to make transparency look the best for various + // situations. See inline comments. + const bg_alpha: u8 = bg_alpha: { + const default: u8 = 255; + + // Cells that are selected should be fully opaque. + if (selected != .false) break :bg_alpha default; + + // Cells that are reversed should be fully opaque. + if (style.flags.inverse) break :bg_alpha default; + + // If the user requested to have opacity on all cells, apply it. + if (self.config.background_opacity_cells and bg_style != null) { + var opacity: f64 = @floatFromInt(default); + opacity *= self.config.background_opacity; + break :bg_alpha @intFromFloat(opacity); + } + + // Cells that have an explicit bg color should be fully opaque. + if (bg_style != null) break :bg_alpha default; + + // Otherwise, we won't draw the bg for this cell, + // we'll let the already-drawn background color + // show through. + break :bg_alpha 0; + }; + + self.cells.bgCell(y, x).* = .{ + rgb.r, rgb.g, rgb.b, bg_alpha, + }; + } + + // If the invisible flag is set on this cell then we + // don't need to render any foreground elements, so + // we just skip all glyphs with this x coordinate. + // + // NOTE: This behavior matches xterm. Some other terminal + // emulators, e.g. Alacritty, still render text decorations + // and only make the text itself invisible. The decision + // has been made here to match xterm's behavior for this. + if (style.flags.invisible) { + continue; + } + + // Give links a single underline, unless they already have + // an underline, in which case use a double underline to + // distinguish them. + const underline: terminal.Attribute.Underline = underline: { + if (links.contains(.{ + .x = @intCast(x), + .y = @intCast(y), + })) { + break :underline if (style.flags.underline == .single) + .double + else + .single; + } + break :underline style.flags.underline; + }; + + // We draw underlines first so that they layer underneath text. + // This improves readability when a colored underline is used + // which intersects parts of the text (descenders). + if (underline != .none) self.addUnderline( + @intCast(x), + @intCast(y), + underline, + style.underlineColor(&state.colors.palette) orelse fg, + alpha, + ) catch |err| { + log.warn( + "error adding underline to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + + if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| { + log.warn( + "error adding overline to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + + // If we're at or past the end of our shaper run then + // we need to get the next run from the run iterator. + if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) { + shaper_run = try run_iter.next(self.alloc); + shaper_cells = null; + shaper_cells_i = 0; + } + + if (shaper_run) |run| glyphs: { + // If we haven't shaped this run yet, do so. + shaper_cells = shaper_cells orelse + // Try to read the cells from the shaping cache if we can. + self.font_shaper_cache.get(run) orelse + cache: { + // Otherwise we have to shape them. + const new_cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we + // continue because it is just a performance optimization, + // not a correctness issue. + self.font_shaper_cache.put( + self.alloc, + run, + new_cells, + ) catch |err| { + log.warn( + "error caching font shaping results err={}", + .{err}, + ); + }; + + // The cells we get from direct shaping are always owned + // by the shaper and valid until the next shaping call so + // we can safely use them. + break :cache new_cells; + }; + + const shaped_cells = shaper_cells orelse break :glyphs; + + // If there are no shaper cells for this run, ignore it. + // This can occur for runs of empty cells, and is fine. + if (shaped_cells.len == 0) break :glyphs; + + // If we encounter a shaper cell to the left of the current + // cell then we have some problems. This logic relies on x + // position monotonically increasing. + assert(run.offset + shaped_cells[shaper_cells_i].x >= x); + + // NOTE: An assumption is made here that a single cell will never + // be present in more than one shaper run. If that assumption is + // violated, this logic breaks. + + while (shaper_cells_i < shaped_cells.len and + run.offset + shaped_cells[shaper_cells_i].x == x) : ({ + shaper_cells_i += 1; + }) { + self.addGlyph( + @intCast(x), + @intCast(y), + state.cols, + cells_raw, + shaped_cells[shaper_cells_i], + shaper_run.?, + fg, + alpha, + ) catch |err| { + log.warn( + "error adding glyph to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + } + } + + // Finally, draw a strikethrough if necessary. + if (style.flags.strikethrough) self.addStrikethrough( + @intCast(x), + @intCast(y), + fg, + alpha, + ) catch |err| { + log.warn( + "error adding strikethrough to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + } + } + /// Add an underline decoration to the specified cell fn addUnderline( self: *Self, From d235c490e9e68b6d90bb0016750b47c5e09b3be6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 12:10:05 -0800 Subject: [PATCH 551/605] renderer: handle rebuildCells failures gracefully --- src/renderer/generic.zig | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index bc81fa98e..17ca3f3cd 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1282,6 +1282,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + // From this point forward no more errors. + errdefer comptime unreachable; + // Reset our dirty state after updating. defer self.terminal_state.dirty = .false; @@ -1291,7 +1294,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { defer self.draw_mutex.unlock(); // Build our GPU cells - try self.rebuildCells( + self.rebuildCells( critical.preedit, renderer.cursorStyle(&self.terminal_state, .{ .preedit = critical.preedit != null, @@ -1299,7 +1302,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .blink_visible = cursor_blink_visible, }), &critical.links, - ); + ) catch |err| { + // This means we weren't able to allocate our buffer + // to update the cells. In this case, we continue with + // our old buffer (frozen contents) and log it. + comptime assert(@TypeOf(err) == error{OutOfMemory}); + log.warn("error rebuilding GPU cells err={}", .{err}); + }; // The scrollbar is only emitted during draws so we also // check the scrollbar cache here and update if needed. @@ -2498,7 +2507,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, links: *const terminal.RenderState.CellSet, - ) !void { + ) Allocator.Error!void { const state: *terminal.RenderState = &self.terminal_state; // const start = try std.time.Instant.now(); @@ -2566,6 +2575,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + // From this point on we never fail. We produce some kind of + // working terminal state, even if incorrect. + errdefer comptime unreachable; + // Get our row data from our state const row_data = state.row_data.slice(); const row_raws = row_data.items(.raw); @@ -2603,7 +2616,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Unmark the dirty state in our render state. dirty.* = false; - try self.rebuildRow( + self.rebuildRow( y, row, cells, @@ -2611,7 +2624,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { selection, highlights, links, - ); + ) catch |err| { + // This should never happen except under exceptional + // scenarios. In this case, we don't want to corrupt + // our render state so just clear this row and keep + // trying to finish it out. + log.warn("error building row y={} err={}", .{ y, err }); + self.cells.clear(y); + }; } // Setup our cursor rendering information. From 6ec2bfe2887ab9f81175b9faf0b05e19379896ef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 12:27:28 -0800 Subject: [PATCH 552/605] renderer: kitty graphics prep can't fail (skip failed conversions) --- src/renderer/generic.zig | 100 +++++++++++++++++++++++++++------------ src/renderer/image.zig | 5 +- 2 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 17ca3f3cd..e75171721 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1117,7 +1117,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, state: *renderer.State, cursor_blink_visible: bool, - ) !void { + ) Allocator.Error!void { // We fully deinit and reset the terminal state every so often // so that a particularly large terminal state doesn't cause // the renderer to hold on to retained memory. @@ -1193,7 +1193,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (state.terminal.screens.active.kitty_images.dirty or self.image_virtual) { - try self.prepKittyGraphics(state.terminal); + self.prepKittyGraphics(state.terminal); } // Get our OSC8 links we're hovering if we have a mouse. @@ -1712,7 +1712,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fn prepKittyGraphics( self: *Self, t: *terminal.Terminal, - ) !void { + ) void { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); @@ -1776,16 +1776,32 @@ pub fn Renderer(comptime GraphicsAPI: type) type { continue; }; - try self.prepKittyPlacement(t, top_y, bot_y, &image, p); + self.prepKittyPlacement( + t, + top_y, + bot_y, + &image, + p, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; } // If we have virtual placements then we need to scan for placeholders. if (self.image_virtual) { var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); - while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement( - t, - &virtual_p, - ); + while (v_it.next()) |virtual_p| { + self.prepKittyVirtualPlacement( + t, + &virtual_p, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } } // Sort the placements by their Z value. @@ -1833,7 +1849,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, t: *terminal.Terminal, p: *const terminal.kitty.graphics.unicode.Placement, - ) !void { + ) PrepKittyImageError!void { const storage = &t.screens.active.kitty_images; const image = storage.imageById(p.image_id) orelse { log.warn( @@ -1894,7 +1910,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, - ) !void { + ) PrepKittyImageError!void { // Get the rect for the placement. If this placement doesn't have // a rect then its virtual or something so skip it. const rect = p.rect(image.*, t) orelse return; @@ -1950,12 +1966,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + const PrepKittyImageError = error{ + OutOfMemory, + ImageConversionError, + }; + /// Prepare the provided image for upload to the GPU by copying its /// data with our allocator and setting it to the pending state. fn prepKittyImage( self: *Self, image: *const terminal.kitty.graphics.Image, - ) !void { + ) PrepKittyImageError!void { // If this image exists and its transmit time is the same we assume // it is the identical image so we don't need to send it to the GPU. const gop = try self.images.getOrPut(self.alloc, image.id); @@ -1966,39 +1987,60 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Copy the data into the pending state. - const data = try self.alloc.dupe(u8, image.data); - errdefer self.alloc.free(data); + const data = if (self.alloc.dupe( + u8, + image.data, + )) |v| v else |_| { + if (!gop.found_existing) { + // If this is a new entry we can just remove it since it + // was never sent to the GPU. + _ = self.images.remove(image.id); + } else { + // If this was an existing entry, it is invalid and + // we must unload it. + gop.value_ptr.image.markForUnload(); + } + + return error.OutOfMemory; + }; + // Note: we don't need to errdefer free the data because it is + // put into the map immediately below and our errdefer to + // handle our map state will fix this up. // Store it in the map - const pending: Image.Pending = .{ - .width = image.width, - .height = image.height, - .pixel_format = switch (image.format) { - .gray => .gray, - .gray_alpha => .gray_alpha, - .rgb => .rgb, - .rgba => .rgba, - .png => unreachable, // should be decoded by now + const new_image: Image = .{ + .pending = .{ + .width = image.width, + .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, + .data = data.ptr, }, - .data = data.ptr, }; - - const new_image: Image = .{ .pending = pending }; - if (!gop.found_existing) { gop.value_ptr.* = .{ .image = new_image, .transmit_time = undefined, }; } else { - try gop.value_ptr.image.markForReplace( + gop.value_ptr.image.markForReplace( self.alloc, new_image, ); } - try gop.value_ptr.image.prepForUpload(self.alloc); + // If any error happens, we unload the image and it is invalid. + errdefer gop.value_ptr.image.markForUnload(); + gop.value_ptr.image.prepForUpload(self.alloc) catch |err| { + log.warn("error preparing kitty image for upload err={}", .{err}); + return error.ImageConversionError; + }; gop.value_ptr.transmit_time = image.transmit_time; } @@ -2090,7 +2132,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have an existing background image, replace it. // Otherwise, set this as our background image directly. if (self.bg_image) |*img| { - try img.markForReplace(self.alloc, image); + img.markForReplace(self.alloc, image); } else { self.bg_image = image; } diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 7089f5a8b..bf0f7b736 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -146,7 +146,7 @@ pub const Image = union(enum) { /// Mark the current image to be replaced with a pending one. This will /// attempt to update the existing texture if we have one, otherwise it /// will act like a new upload. - pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { + pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) void { assert(img.isPending()); // If we have pending data right now, free it. @@ -216,9 +216,8 @@ pub const Image = union(enum) { /// Prepare the pending image data for upload to the GPU. /// This doesn't need GPU access so is safe to call any time. - pub fn prepForUpload(self: *Image, alloc: Allocator) !void { + pub fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void { assert(self.isPending()); - try self.convert(alloc); } From 8ef0842b01aaafb7493fbd74a37f66d137e0c6ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 13:07:06 -0800 Subject: [PATCH 553/605] ci: authenticate pinact to avoid GH rate limits When we're running a lot of CI we're hitting low unauthenticated rate limits. --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbac22a47..734b8d224 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -844,6 +844,8 @@ jobs: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 + permissions: + contents: read env: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache @@ -866,6 +868,8 @@ jobs: useDaemon: false # sometimes fails on short jobs - name: pinact check run: nix develop -c pinact run --check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} prettier: if: github.repository == 'ghostty-org/ghostty' From b87e8d81721ded4fcf2f5cdd3887480909022e7b Mon Sep 17 00:00:00 2001 From: David Matos Date: Wed, 21 Jan 2026 13:19:53 +0100 Subject: [PATCH 554/605] Update to new nu ssh ghostty integration --- .../nushell/ghostty-ssh-integration.nu | 108 ---------------- .../vendor/autoload/bootstrap-integration.nu | 29 ----- .../nushell/vendor/autoload/ghostty.nu | 120 ++++++++++++++++-- .../vendor/autoload/source-integration.nu | 11 -- 4 files changed, 111 insertions(+), 157 deletions(-) delete mode 100644 src/shell-integration/nushell/ghostty-ssh-integration.nu delete mode 100644 src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu delete mode 100644 src/shell-integration/nushell/vendor/autoload/source-integration.nu diff --git a/src/shell-integration/nushell/ghostty-ssh-integration.nu b/src/shell-integration/nushell/ghostty-ssh-integration.nu deleted file mode 100644 index 495b96c78..000000000 --- a/src/shell-integration/nushell/ghostty-ssh-integration.nu +++ /dev/null @@ -1,108 +0,0 @@ -# Enables SSH environment variable compatibility. -# Converts TERM from xterm-ghostty to xterm-256color -# and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION -# check your sshd_config on remote host to see if these variables are accepted -def set_ssh_env []: nothing -> record> { - return { - ssh_term: "xterm-256color", - ssh_opts: ["-o", "SetEnv COLORTERM=truecolor", "-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] - } -} - -# Enables automatic terminfo installation on remote hosts. -# Attempts to install Ghostty's terminfo entry using infocmp and tic when -# connecting to hosts that lack it. -# Requires infocmp to be available locally and tic to be available on remote hosts. -# Caches installations to avoid repeat installations. -def set_ssh_terminfo [ - ssh_opts: list, - ssh_args: list -]: [nothing -> record>] { - mut ssh_opts = $ssh_opts - let ssh_cfg = ^ssh -G ...($ssh_args) - | lines - | parse "{key} {value}" - | where key in ["user", "hostname"] - | select key value - | transpose -rd - | default { user: $env.USER, hostname: "localhost" } - - let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" - let ghostty_bin = $env.GHOSTTY_BIN_DIR | path join "ghostty" - - let is_cached = ( - ^$ghostty_bin ...(["+ssh-cache", $"--host=($ssh_id)"]) - | complete - | $in.exit_code == 0 - ) - - if not $is_cached { - let ssh_opts_copy = $ssh_opts - let terminfo_data = try {^infocmp -0 -x xterm-ghostty} catch { - print "Warning: Could not generate terminfo data." - return {ssh_term: "xterm-256color", ssh_opts: $ssh_opts_copy} - } - - print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." - - let ctrl_path = ( - try { - mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" - } catch { - $"/tmp/ghostty-ssh-($ssh_cfg.user).($nu.pid)" - } | path join "socket" - ) - - let master_parts = $ssh_opts ++ ["-o" "ControlMaster=yes" "-o" $"ControlPath=($ctrl_path)" "-o" "ControlPersist=60s"] ++ $ssh_args - - ($terminfo_data) | ^ssh ...( - $master_parts ++ - [ - ' - infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 - command -v tic >/dev/null 2>&1 || exit 1 - mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 - exit 1' - ] - ) - | complete - | if $in.exit_code != 0 { - print "Warning: Failed to install terminfo." - return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts} - } - - ^$ghostty_bin ...(["+ssh-cache", $"--add=($ssh_id)"]) o+e>| ignore - $ssh_opts ++= ["-o", $"ControlPath=($ctrl_path)"] - } - - return {ssh_term: "xterm-ghostty", ssh_opts: $ssh_opts} -} - -# SSH Integration -export def --wrapped ssh [...ssh_args: string]: any -> any { - if ($ssh_args | is-empty) { - return (^ssh) - } - mut session = {ssh_term: "", ssh_opts: []} - let shell_features = $env.GHOSTTY_SHELL_FEATURES | split row ',' - - if "ssh-env" in $shell_features { - $session = set_ssh_env - } - if "ssh-terminfo" in $shell_features { - $session = set_ssh_terminfo $session.ssh_opts $ssh_args - } - - let ssh_parts = $session.ssh_opts ++ $ssh_args - with-env {TERM: $session.ssh_term} { - ^ssh ...$ssh_parts - } -} - -# Removes Ghostty's data directory from XDG_DATA_DIRS -$env.XDG_DATA_DIRS = ( - $env.XDG_DATA_DIRS - | split row ':' - | where {|path| $path != $env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR } - | str join ':' -) diff --git a/src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu b/src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu deleted file mode 100644 index 317ba62d3..000000000 --- a/src/shell-integration/nushell/vendor/autoload/bootstrap-integration.nu +++ /dev/null @@ -1,29 +0,0 @@ -let enable_integration = $env.GHOSTTY_SHELL_FEATURES | split row ',' - | where ($it in ["ssh-env" "ssh-terminfo"]) - | is-not-empty - -let ghostty_ssh_file = $env.GHOSTTY_RESOURCES_DIR - | path join "shell-integration" "nushell" "ghostty-ssh-integration.nu" - -let ssh_integration_file = $nu.data-dir | path join "ghostty-ssh-integration.nu" -let ssh_file_exists = $ssh_integration_file | path exists - -# TOD0: In case of an update to the `ghostty-ssh-integration.nu` file -# the file wont be updated here, so we need to support -# saving the new file once there is an update - -match [$enable_integration $ssh_file_exists] { - [true false] => { - # $nu.data-dir is not created by default - # https://www.nushell.sh/book/configuration.html#startup-variables - $nu.data-dir | path exists | if (not $in) { mkdir $nu.data-dir } - open $ghostty_ssh_file | save $ssh_integration_file - } - [false true] => { - # We need to check if the user disabled `ssh-integration` and thus - # the integration file needs to be removed so it doesnt get sourced by - # the `source-integration.nu` file - rm $ssh_integration_file - } - _ => { } -} diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu index 467e3f529..6a6f83629 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -4,26 +4,128 @@ export module ghostty { $feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',') } + # Enables SSH environment variable compatibility. + # Converts TERM from xterm-ghostty to xterm-256color + # and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION + # check your sshd_config on remote host to see if these variables are accepted + def set_ssh_env []: nothing -> record> { + return { + ssh_term: "xterm-256color" + ssh_opts: ["-o" "SetEnv COLORTERM=truecolor" "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + } + } + + # Enables automatic terminfo installation on remote hosts. + # Attempts to install Ghostty's terminfo entry using infocmp and tic when + # connecting to hosts that lack it. + # Requires infocmp to be available locally and tic to be available on remote hosts. + # Caches installations to avoid repeat installations. + def set_ssh_terminfo [ + ssh_opts: list + ssh_args: list + ]: [nothing -> record>] { + mut ssh_opts = $ssh_opts + let ssh_cfg = ^ssh -G ...($ssh_args) + | lines + | parse "{key} {value}" + | where key in ["user" "hostname"] + | select key value + | transpose -rd + | default {user: $env.USER hostname: "localhost"} + + let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" + let ghostty_bin = $env.GHOSTTY_BIN_DIR | path join "ghostty" + + let is_cached = ( + ^$ghostty_bin ...(["+ssh-cache" $"--host=($ssh_id)"]) + | complete + | $in.exit_code == 0 + ) + + if not $is_cached { + let ssh_opts_copy = $ssh_opts + let terminfo_data = try { ^infocmp -0 -x xterm-ghostty } catch { + print "Warning: Could not generate terminfo data." + return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts_copy} + } + + print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." + + let ctrl_path = ( + try { + mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" + } catch { + $"/tmp/ghostty-ssh-($ssh_cfg.user).($nu.pid)" + } | path join "socket" + ) + + let master_parts = $ssh_opts ++ ["-o" "ControlMaster=yes" "-o" $"ControlPath=($ctrl_path)" "-o" "ControlPersist=60s"] ++ $ssh_args + + ($terminfo_data) | ^ssh ...( + $master_parts ++ + [ + ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1' + ] + ) + | complete + | if $in.exit_code != 0 { + print "Warning: Failed to install terminfo." + return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts} + } + + ^$ghostty_bin ...(["+ssh-cache" $"--add=($ssh_id)"]) o+e>| ignore + $ssh_opts ++= ["-o" $"ControlPath=($ctrl_path)"] + } + + return {ssh_term: "xterm-ghostty" ssh_opts: $ssh_opts} + } + # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable export def --wrapped sudo [ - ...args # Arguments to pass to `sudo` + ...args # Arguments to pass to `sudo` ] { mut sudo_args = $args if (has_feature "sudo") { - # Extract just the sudo options (before the command) - let sudo_options = ($args | take until {|arg| - not (($arg | str starts-with "-") or ($arg | str contains "=")) - }) - - # Prepend TERMINFO preservation flag if not using sudoedit - if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) { - $sudo_args = ($args | prepend "--preserve-env=TERMINFO") + # Extract just the sudo options (before the command) + let sudo_options = ( + $args | take until {|arg| + not (($arg | str starts-with "-") or ($arg | str contains "=")) } + ) + + # Prepend TERMINFO preservation flag if not using sudoedit + if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) { + $sudo_args = ($args | prepend "--preserve-env=TERMINFO") + } } ^sudo ...$sudo_args } + # Wrap `ssh` to provide ghostty `ssh-integration` + export def --wrapped ssh [...ssh_args: string]: any -> any { + if ($ssh_args | is-empty) { + return (^ssh) + } + mut session = {ssh_term: "" ssh_opts: []} + let shell_features = $env.GHOSTTY_SHELL_FEATURES | split row ',' + + if (has_feature "ssh-env") { + $session = set_ssh_env + } + if (has_feature "ssh-terminfo") { + $session = set_ssh_terminfo $session.ssh_opts $ssh_args + } + + let ssh_parts = $session.ssh_opts ++ $ssh_args + with-env {TERM: $session.ssh_term} { + ^ssh ...$ssh_parts + } + } } # Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR diff --git a/src/shell-integration/nushell/vendor/autoload/source-integration.nu b/src/shell-integration/nushell/vendor/autoload/source-integration.nu deleted file mode 100644 index 1c21833a4..000000000 --- a/src/shell-integration/nushell/vendor/autoload/source-integration.nu +++ /dev/null @@ -1,11 +0,0 @@ -# Sourcing the `ghostty-integration.nu` cant be on the -# `bootstrap-integration.nu` file because it tries to resolve the `sourced` -# file at parsing time, which would make it source nothing. - -# But here we rely on the fact that `boostrap-integration.nu` gets parsed -# and executed first, and then we can count on `ssh_integration_file` being available - -#https://www.nushell.sh/book/thinking_in_nu.html#example-dynamically-generating-source - -const ssh_integration_file = $nu.data-dir | path join "ghostty-ssh-integration.nu" -source (if ($ssh_integration_file | path exists) { $ssh_integration_file } else { null }) From 0a2b90ed64ae6a56e8f025e2af4d66610c670c63 Mon Sep 17 00:00:00 2001 From: David Matos Date: Wed, 21 Jan 2026 13:31:08 +0100 Subject: [PATCH 555/605] Expand Readme to reflect new changes --- src/shell-integration/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 8809134d2..f3961599c 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -84,7 +84,8 @@ Nushell's vendor autoload mechanism. Ghostty then automatically imports the module using the `-e "use ghostty *"` flag when starting Nushell. Nushell provides many shell features itself, such as `title` and `cursor`, -so our integration focuses on Ghostty-specific features like `sudo`. +so our integration focuses on Ghostty-specific features like `sudo`. Additionally, +we also provide `ssh-integration` via the `ssh-env` and `ssh-terminfo` features. The shell integration is automatically enabled when running Nushell in Ghostty, but you can also load it manually is shell integration is disabled: From ba16ce02493b52083ad74a482fd7bcf51b28dc3f Mon Sep 17 00:00:00 2001 From: Steven Lu Date: Wed, 21 Jan 2026 20:03:37 +0700 Subject: [PATCH 556/605] reintroduce assertion, with adjusted limit --- src/datastruct/split_tree.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index b340cb608..0a4c0bdbd 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -794,6 +794,7 @@ pub fn SplitTree(comptime V: type) type { layout: Split.Layout, ratio: f16, ) Allocator.Error!Self { + assert(ratio >= -1 and ratio <= 1); assert(!std.math.isNan(ratio)); assert(!std.math.isInf(ratio)); From 8deecac6fbe631d1af2cea71577ce78292aabf19 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 13 Dec 2025 19:07:43 +0800 Subject: [PATCH 557/605] nix: clean up flake - Don't expose package attributes that won't build (e.g. on macOS) - Make the flake generally easier to read since there's none of that `builtin.foldl' recursiveUpdate` nonsense anymore --- flake.nix | 168 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 88 insertions(+), 80 deletions(-) diff --git a/flake.nix b/flake.nix index a854f6ea3..c96004a09 100644 --- a/flake.nix +++ b/flake.nix @@ -49,92 +49,100 @@ zon2nix, home-manager, ... - }: - builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( - builtins.map ( - system: let - pkgs = nixpkgs.legacyPackages.${system}; - in { - devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.15.2"; - wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; - zon2nix = zon2nix; + }: let + inherit (nixpkgs) lib legacyPackages; - python3 = pkgs.python3.override { - self = pkgs.python3; - packageOverrides = pyfinal: pyprev: { - blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; - ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; - }; - }; + # Our supported systems are the same supported systems as the Zig binaries. + platforms = lib.attrNames zig.packages; + + # It's not always possible to build Ghostty with Nix for each system, + # one such example being macOS due to missing Swift 6 and xcodebuild + # support in the Nix ecosystem. Therefore for things like package outputs + # we need to limit the attributes we expose. + buildablePlatforms = lib.filter (p: !(lib.systems.elaborate p).isDarwin) platforms; + + forAllPlatforms = f: lib.genAttrs platforms (s: f legacyPackages.${s}); + forBuildablePlatforms = f: lib.genAttrs buildablePlatforms (s: f legacyPackages.${s}); + in { + devShell = forAllPlatforms (pkgs: + pkgs.callPackage ./nix/devShell.nix { + zig = zig.packages.${pkgs.stdenv.hostPlatform.system}."0.15.2"; + wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; + zon2nix = zon2nix; + + python3 = pkgs.python3.override { + self = pkgs.python3; + packageOverrides = pyfinal: pyprev: { + blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; + ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; }; - - packages.${system} = let - mkArgs = optimize: { - inherit optimize; - - revision = self.shortRev or self.dirtyShortRev or "dirty"; - }; - in rec { - 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.alejandra; - - checks.${system} = import ./nix/tests.nix { - inherit home-manager nixpkgs self system; - }; - - apps.${system} = let - runVM = ( - module: let - vm = import ./nix/vm/create.nix { - inherit system module nixpkgs; - overlay = self.overlays.debug; - }; - program = pkgs.writeShellScript "run-ghostty-vm" '' - SHARED_DIR=$(pwd) - export SHARED_DIR - - ${pkgs.lib.getExe vm.config.system.build.vm} "$@" - ''; - in { - type = "app"; - program = "${program}"; - meta = { - description = "start a vm from ${toString module}"; - }; - } - ); - in { - wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix; - wayland-gnome = runVM ./nix/vm/wayland-gnome.nix; - wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix; - x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix; - x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix; - x11-xfce = runVM ./nix/vm/x11-xfce.nix; - }; - } - # Our supported systems are the same supported systems as the Zig binaries. - ) (builtins.attrNames zig.packages) - ) - // { - overlays = { - default = self.overlays.releasefast; - releasefast = final: prev: { - ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast; }; - debug = final: prev: { - ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; + }); + + packages = + forAllPlatforms (pkgs: { + # Deps are needed for environmental setup on macOS + deps = pkgs.callPackage ./build.zig.zon.nix {}; + }) + // forBuildablePlatforms (pkgs: let + mkArgs = optimize: { + inherit optimize; + revision = self.shortRev or self.dirtyShortRev or "dirty"; }; + in rec { + 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 = forAllPlatforms (pkgs: pkgs.alejandra); + + apps = forBuildablePlatforms (pkgs: let + runVM = module: desc: let + vm = import ./nix/vm/create.nix { + inherit (pkgs.stdenv.hostPlatform) system; + inherit module nixpkgs; + overlay = self.overlays.debug; + }; + program = pkgs.writeShellScript "run-ghostty-vm" '' + SHARED_DIR=$(pwd) + export SHARED_DIR + + ${pkgs.lib.getExe vm.config.system.build.vm} "$@" + ''; + in { + type = "app"; + program = "${program}"; + meta.description = "start a vm from ${toString module}"; + }; + in { + wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix; + wayland-gnome = runVM ./nix/vm/wayland-gnome.nix; + wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix; + x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix; + x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix; + x11-xfce = runVM ./nix/vm/x11-xfce.nix; + }); + + checks = forAllPlatforms (pkgs: + import ./nix/tests.nix { + inherit home-manager nixpkgs self; + inherit (pkgs.stdenv.hostPlatform) system; + }); + + overlays = { + default = self.overlays.releasefast; + releasefast = final: prev: { + ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast; + }; + debug = final: prev: { + ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; + }; nixConfig = { extra-substituters = ["https://ghostty.cachix.org"]; From 179a9d4cfa9d86a82e3cd59d27b9fee044355ba2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 20 Jan 2026 14:36:48 -0800 Subject: [PATCH 558/605] tripwire: a module for injecting failures to test errdefer --- src/main_ghostty.zig | 1 + src/tripwire.zig | 309 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/tripwire.zig diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 72d602989..531a06461 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -190,6 +190,7 @@ test { _ = @import("surface_mouse.zig"); // Libraries + _ = @import("tripwire.zig"); _ = @import("benchmark/main.zig"); _ = @import("crash/main.zig"); _ = @import("datastruct/main.zig"); diff --git a/src/tripwire.zig b/src/tripwire.zig new file mode 100644 index 000000000..f8aaead14 --- /dev/null +++ b/src/tripwire.zig @@ -0,0 +1,309 @@ +//! A library for injecting failures into Zig code for the express +//! purpose of testing error handling paths. +//! +//! Improper `errdefer` is one of the highest sources of bugs in Zig code. +//! Many `errdefer` points are hard to exercise in unit tests and rare +//! to encounter in production, so they often hide bugs. Worse, error +//! scenarios are most likely to put your code in an unexpected state +//! that can result in future assertion failures or memory safety issues. +//! +//! This module aims to solve this problem by providing a way to inject +//! errors at specific points in your code during unit tests, allowing you +//! to test every possible error path. +//! +//! # Usage +//! +//! To use this package, create a `tripwire.module` for each failable +//! function you want to test. The enum must be hand-curated to be the +//! set of fail points, and the error set comes directly from the function +//! itself. +//! +//! Pepper your function with `try tw.check` calls wherever you want to +//! have a testable failure point. You don't need every "try" to have +//! an associated tripwire check, only the ones you care about testing. +//! Usually, this is going to be the points where you want to test +//! errdefer logic above it. +//! +//! In unit tests, add `try tw.errorAlways` or related calls to +//! configure expected failures. Then, call your function. Finally, always +//! call `try tw.end(.reset)` to verify your expectations were met and +//! to reset the tripwire module for future tests. +//! +//! ``` +//! const tw = tripwire.module(enum { alloc_buf, open_file }, myFunction); +//! +//! fn myFunction() tw.Error!void { +//! try tw.check(.alloc_buf); +//! const buf = try allocator.alloc(u8, 1024); +//! errdefer allocator.free(buf); +//! +//! try tw.check(.open_file); +//! const file = try std.fs.cwd().openFile("foo.txt", .{}); +//! // ... +//! } +//! +//! test "myFunction fails on alloc" { +//! try tw.errorAlways(.alloc_buf, error.OutOfMemory); +//! try std.testing.expectError(error.OutOfMemory, myFunction()); +//! try tw.end(.reset); +//! } +//! ``` +//! +//! ## Transitive Function Calls +//! +//! To test transitive calls, there are two schools of thought: +//! +//! 1. Put a failure point above the transitive call in the caller +//! and assume the child function error handling works correctly. +//! +//! 2. Create another tripwire module for the child function and +//! trigger failures there. This is recommended if the child function +//! can't really be called in isolation (e.g. its an auxiliary function +//! to a public API). +//! +//! Either works, its situationally dependent which is better. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const testing = std.testing; +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.tripwire); + +// Future ideas: +// +// - Assert that the errors are actually tripped. e.g. you set a +// errorAlways on a point, and want to verify it was tripped. +// - Assert that every point is covered by at least one test. We +// can probably use global state for this. +// - Error only after a certain number of calls to a point. +// - Error only on a range of calls (first 5 times, 2-7th time, etc.) +// +// I don't want to implement these until they're actually needed by +// some part of our codebase, but I want to list them here in case they +// do become useful. + +/// A tripwire module that can be used to inject failures at specific points. +/// +/// Outside of unit tests, this module is free and completely optimized away. +/// It takes up zero binary or runtime space and all function calls are +/// optimized out. +/// +/// To use this module, add `check` (or related) calls prior to every +/// `try` operation that you want to be able to fail arbitrarily. Then, +/// in your unit tests, call the `error` family of functions to configure +/// when errors should be injected. +/// +/// P is an enum type representing the failure points in the module. +/// E is the error set of possible errors that can be returned from the +/// failure points. You can use `anyerror` here but note you may have to +/// use `checkConstrained` to narrow down the error type when you call +/// it in your function (so your function can compile). +/// +/// E may also be an error union type, in which case the error set of that +/// union is used as the error set for the tripwire module. +/// If E is a function, then the error set of the return value of that +/// function is used as the error set for the tripwire module. +pub fn module( + comptime P: type, + comptime E: anytype, +) type { + return struct { + /// The points this module can fail at. + pub const FailPoint = P; + + /// The error set used for failures at the failure points. + pub const Error = err: { + const T = if (@TypeOf(E) == type) E else @TypeOf(E); + break :err switch (@typeInfo(T)) { + .error_set => E, + .error_union => |info| info.error_set, + .@"fn" => |info| @typeInfo(info.return_type.?).error_union.error_set, + else => @compileError("E must be an error set or function type"), + }; + }; + + /// Whether our module is enabled or not. In the future we may + /// want to make this a comptime parameter to the module. + pub const enabled = builtin.is_test; + + comptime { + assert(@typeInfo(FailPoint) == .@"enum"); + assert(@typeInfo(Error) == .error_set); + } + + /// The configured tripwires for this module. + var tripwires: TripwireMap = .empty; + const TripwireMap = std.AutoArrayHashMapUnmanaged(FailPoint, Tripwire); + const Tripwire = struct { + /// Error to return when tripped + err: Error, + + /// The amount of times this tripwire has been reached. This + /// is NOT the number of times it has tripped, since we may + /// have mins for that. + reached: usize = 0, + + /// The minimum number of times this must be reached before + /// tripping. After this point, it trips every time. This is + /// a "before" check so if this is "1" then it'll trip the + /// second time it's reached. + min: usize = 0, + + /// True if this has been tripped at least once. + tripped: bool = false, + }; + + /// For all allocations we use an allocator that can leak memory + /// without reporting it, since this is only used in tests. We don't + /// want to use a testing allocator here because that would report + /// leaks. Users are welcome to call `deinit` on the module to + /// free all memory. + const LeakyAllocator = std.heap.DebugAllocator(.{}); + var alloc_state: LeakyAllocator = .init; + + /// Check for a failure at the given failure point. These should + /// be placed directly before the `try` operation that may fail. + pub fn check(point: FailPoint) callconv(callingConvention()) Error!void { + if (comptime !enabled) return; + return checkConstrained(point, Error); + } + + /// Same as check but allows specifying a custom error type for the + /// return value. This must be a subset of the module's Error type + /// and will produce a runtime error if the configured tripwire + /// error can't be cast to the ConstrainedError type. + pub fn checkConstrained( + point: FailPoint, + comptime ConstrainedError: type, + ) callconv(callingConvention()) ConstrainedError!void { + if (comptime !enabled) return; + const tripwire = tripwires.getPtr(point) orelse return; + tripwire.reached += 1; + if (tripwire.reached <= tripwire.min) return; + tripwire.tripped = true; + return tripwire.err; + } + + /// Mark a failure point to always trip with the given error. + pub fn errorAlways( + point: FailPoint, + err: Error, + ) Allocator.Error!void { + try errorAfter(point, err, 0); + } + + /// Mark a failure point to trip with the given error after + /// the failure point is reached at least `min` times. A value of + /// zero is equivalent to `errorAlways`. + pub fn errorAfter( + point: FailPoint, + err: Error, + min: usize, + ) Allocator.Error!void { + try tripwires.put( + alloc_state.allocator(), + point, + .{ .err = err, .min = min }, + ); + } + + /// Ends the tripwire session. This will raise an error if there + /// were untripped error expectations. The reset mode specifies + /// whether memory is reset too. Memory is always reset, even if + /// this returns an error. + pub fn end(reset_mode: enum { reset, retain }) error{UntrippedError}!void { + var untripped: bool = false; + for (tripwires.keys(), tripwires.values()) |key, entry| { + if (!entry.tripped) { + log.warn("untripped point={t}", .{key}); + untripped = true; + } + } + + // We always reset memory before failing + switch (reset_mode) { + .reset => reset(), + .retain => {}, + } + + if (untripped) return error.UntrippedError; + } + + /// Unset all the tripwires and free all allocated memory. You + /// should usually call `end` instead. + pub fn reset() void { + tripwires.clearAndFree(alloc_state.allocator()); + } + + /// Our calling convention is inline if our tripwire module is + /// NOT enabled, so that all calls to `check` are optimized away. + fn callingConvention() std.builtin.CallingConvention { + return if (!enabled) .@"inline" else .auto; + } + }; +} + +test { + const io = module(enum { + read, + write, + }, anyerror); + + // Reset should work + try io.end(.reset); + + // By default, its pass-through + try io.check(.read); + + // Always trip + try io.errorAlways(.read, error.OutOfMemory); + try testing.expectError( + error.OutOfMemory, + io.check(.read), + ); + // Happens again + try testing.expectError( + error.OutOfMemory, + io.check(.read), + ); + try io.end(.reset); +} + +test "module as error set" { + const io = module(enum { read, write }, @TypeOf((struct { + fn func() error{ Foo, Bar }!void { + return error.Foo; + } + }).func)); + try io.end(.reset); +} + +test "errorAfter" { + const io = module(enum { read, write }, anyerror); + // Trip after 2 calls (on the 3rd call) + try io.errorAfter(.read, error.OutOfMemory, 2); + + // First two calls succeed + try io.check(.read); + try io.check(.read); + + // Third call and on trips + try testing.expectError(error.OutOfMemory, io.check(.read)); + try testing.expectError(error.OutOfMemory, io.check(.read)); + + try io.end(.reset); +} + +test "errorAfter untripped error if min not reached" { + const io = module(enum { read }, anyerror); + try io.errorAfter(.read, error.OutOfMemory, 2); + // Only call once, not enough to trip + try io.check(.read); + // end should fail because tripwire was set but never tripped + try testing.expectError( + error.UntrippedError, + io.end(.reset), + ); +} From baa9dd6b2a695e7d3bb2e38a8e9fdd4c17779c3f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 08:22:45 -0800 Subject: [PATCH 559/605] terminal: use tripwire with PageList init, fix an errdefer bug --- src/terminal/PageList.zig | 111 +++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 41f8d6533..b44db8412 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -9,6 +9,7 @@ const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; const fastmem = @import("../fastmem.zig"); +const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); const kitty = @import("kitty.zig"); @@ -84,7 +85,7 @@ pub const MemoryPool = struct { gen_alloc: Allocator, page_alloc: Allocator, preheat: usize, - ) !MemoryPool { + ) Allocator.Error!MemoryPool { var node_pool = try NodePool.initPreheated(gen_alloc, preheat); errdefer node_pool.deinit(); var page_pool = try PagePool.initPreheated(page_alloc, preheat); @@ -330,6 +331,13 @@ inline fn pageAllocator() Allocator { return mach.taggedPageAllocator(.application_specific_1); } +const init_tw = tripwire.module(enum { + init_memory_pool, + init_pages, + viewport_pin, + viewport_pin_track, +}, init); + /// Initialize the page. The top of the first page in the list is always the /// top of the active area of the screen (important knowledge for quickly /// setting up cursors in Screen). @@ -351,16 +359,21 @@ pub fn init( cols: size.CellCountInt, rows: size.CellCountInt, max_size: ?usize, -) !PageList { +) Allocator.Error!PageList { + const tw = init_tw; + // The screen starts with a single page that is the entire viewport, // and we'll split it thereafter if it gets too large and add more as // necessary. + try tw.check(.init_memory_pool); var pool = try MemoryPool.init( alloc, pageAllocator(), page_preheat, ); errdefer pool.deinit(); + + try tw.check(.init_pages); var page_serial: u64 = 0; const page_list, const page_size = try initPages( &pool, @@ -373,12 +386,16 @@ pub fn init( const min_max_size = minMaxSize(cols, rows); // We always track our viewport pin to ensure this is never an allocation + try tw.check(.viewport_pin); const viewport_pin = try pool.pins.create(); viewport_pin.* = .{ .node = page_list.first.? }; var tracked_pins: PinSet = .{}; errdefer tracked_pins.deinit(pool.alloc); + + try tw.check(.viewport_pin_track); try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); + errdefer comptime unreachable; const result: PageList = .{ .cols = cols, .rows = rows, @@ -399,12 +416,20 @@ pub fn init( return result; } +const initPages_tw = tripwire.module(enum { + page_node, + page_buf_std, + page_buf_non_std, +}, initPages); + fn initPages( pool: *MemoryPool, serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, ) Allocator.Error!struct { List, usize } { + const tw = initPages_tw; + var page_list: List = .{}; var page_size: usize = 0; @@ -418,17 +443,34 @@ fn initPages( // redundant here for safety. assert(layout.total_size <= size.max_page_size); + // If we have an error, we need to clean up our non-standard pages + // since they're not in the pool. + errdefer { + var it = page_list.first; + while (it) |node| : (it = node.next) { + if (node.data.memory.len > std_size) { + page_alloc.free(node.data.memory); + } + } + } + var rem = rows; while (rem > 0) { + try tw.check(.page_node); const node = try pool.nodes.create(); - const page_buf = if (pooled) - try pool.pages.create() - else - try page_alloc.alignedAlloc( + errdefer pool.nodes.destroy(node); + + const page_buf = if (pooled) buf: { + try tw.check(.page_buf_std); + break :buf try pool.pages.create(); + } else buf: { + try tw.check(.page_buf_non_std); + break :buf try page_alloc.alignedAlloc( u8, .fromByteUnits(std.heap.page_size_min), layout.total_size, ); + }; errdefer if (pooled) pool.pages.destroy(page_buf) else @@ -451,6 +493,7 @@ fn initPages( // Add the page to the list page_list.append(node); page_size += page_buf.len; + errdefer comptime unreachable; // Increment our serial serial.* += 1; @@ -5114,6 +5157,62 @@ test "PageList" { }, s.scrollbar()); } +test "PageList init error" { + // Test every failure point in `init` and ensure that we don't + // leak memory (testing.allocator verifies) since we're exiting early. + for (std.meta.tags(init_tw.FailPoint)) |tag| { + const tw = init_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAlways(tag, error.OutOfMemory); + try std.testing.expectError( + error.OutOfMemory, + init( + std.testing.allocator, + 80, + 24, + null, + ), + ); + } + + // init calls initPages transitively, so let's check that if + // any failures happen in initPages, we also don't leak memory. + for (std.meta.tags(initPages_tw.FailPoint)) |tag| { + const tw = initPages_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAlways(tag, error.OutOfMemory); + + const cols: size.CellCountInt = if (tag == .page_buf_std) 80 else std_capacity.maxCols().? + 1; + try std.testing.expectError( + error.OutOfMemory, + init( + std.testing.allocator, + cols, + 24, + null, + ), + ); + } + + // Try non-standard pages since they don't go in our pool. + for ([_]initPages_tw.FailPoint{ + .page_buf_non_std, + }) |tag| { + const tw = initPages_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAfter(tag, error.OutOfMemory, 1); + try std.testing.expectError( + error.OutOfMemory, + init( + std.testing.allocator, + std_capacity.maxCols().? + 1, + std_capacity.rows + 1, + null, + ), + ); + } +} + test "PageList init rows across two pages" { const testing = std.testing; const alloc = testing.allocator; From 3d2152f5e89b5ff518ecae8d80d20fe003b456b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 09:46:34 -0800 Subject: [PATCH 560/605] terminal: Tabstops fix state corruption on error in resize --- src/terminal/Tabstops.zig | 51 ++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index 13d6dc52e..5e784e6f2 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -10,6 +10,7 @@ const Tabstops = @This(); const std = @import("std"); +const tripwire = @import("../tripwire.zig"); const Allocator = std.mem.Allocator; const testing = std.testing; const assert = @import("../quirks.zig").inlineAssert; @@ -58,7 +59,11 @@ inline fn index(col: usize) usize { return @mod(col, unit_bits); } -pub fn init(alloc: Allocator, cols: usize, interval: usize) !Tabstops { +pub fn init( + alloc: Allocator, + cols: usize, + interval: usize, +) Allocator.Error!Tabstops { var res: Tabstops = .{}; try res.resize(alloc, cols); res.reset(interval); @@ -114,21 +119,36 @@ pub fn get(self: Tabstops, col: usize) bool { return unit & mask == mask; } +const resize_tw = tripwire.module(enum { + dynamic_alloc, +}, resize); + /// Resize this to support up to cols columns. // TODO: needs interval to set new tabstops -pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void { - // Set our new value - self.cols = cols; +pub fn resize( + self: *Tabstops, + alloc: Allocator, + cols: usize, +) Allocator.Error!void { + const tw = resize_tw; // Do nothing if it fits. - if (cols <= prealloc_columns) return; + if (cols <= prealloc_columns) { + self.cols = cols; + return; + } // What we need in the dynamic size const size = cols - prealloc_columns; - if (size < self.dynamic_stops.len) return; + if (size < self.dynamic_stops.len) { + self.cols = cols; + return; + } // Note: we can probably try to realloc here but I'm not sure it matters. + try tw.check(.dynamic_alloc); const new = try alloc.alloc(Unit, size); + errdefer comptime unreachable; @memset(new, 0); if (self.dynamic_stops.len > 0) { fastmem.copy(Unit, new, self.dynamic_stops); @@ -136,6 +156,7 @@ pub fn resize(self: *Tabstops, alloc: Allocator, cols: usize) !void { } self.dynamic_stops = new; + self.cols = cols; } /// Return the maximum number of columns this can support currently. @@ -230,3 +251,21 @@ test "Tabstops: count on 80" { try testing.expectEqual(@as(usize, 9), count); } + +test "Tabstops: resize alloc failure preserves state" { + // This test verifies that if resize() fails during allocation, + // the original cols value is preserved (not corrupted). + var t: Tabstops = try init(testing.allocator, 80, 8); + defer t.deinit(testing.allocator); + + const original_cols = t.cols; + + // Trigger allocation failure when resizing beyond prealloc + try resize_tw.errorAlways(.dynamic_alloc, error.OutOfMemory); + const result = t.resize(testing.allocator, prealloc_columns * 2); + try testing.expectError(error.OutOfMemory, result); + try resize_tw.end(.reset); + + // cols should be unchanged after failed resize + try testing.expectEqual(original_cols, t.cols); +} From 9ee27d2697cbe33c29cad008bb5aeb31563c0747 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 21 Jan 2026 13:32:03 -0600 Subject: [PATCH 561/605] OSC 9: Finish parsing all ConEmu OSCs Adds support for parsing OSC 9;7, 9;8, 9;9, 9;10, 9;11, 9;12 --- src/terminal/osc.zig | 30 +++ src/terminal/osc/parsers/osc9.zig | 416 ++++++++++++++++++++++++++++-- src/terminal/stream.zig | 4 + 3 files changed, 430 insertions(+), 20 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 14d501eaa..3fccb2812 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -193,6 +193,28 @@ pub const Command = union(Key) { /// ConEmu GUI macro (OSC 9;6) conemu_guimacro: [:0]const u8, + /// ConEmu run process (OSC 9;7) + conemu_run_process: [:0]const u8, + + /// ConEmu output environment variable (OSC 9;8) + conemu_output_environment_variable: [:0]const u8, + + /// ConEmu XTerm keyboard and output emulation (OSC 9;10) + /// https://conemu.github.io/en/TerminalModes.html + conemu_xterm_emulation: struct { + /// null => do not change + /// false => turn off + /// true => turn on + keyboard: ?bool, + /// null => do not change + /// false => turn off + /// true => turn on + output: ?bool, + }, + + /// ConEmu comment (OSC 9;11) + conemu_comment: [:0]const u8, + /// Kitty text sizing protocol (OSC 66) kitty_text_sizing: parsers.kitty_text_sizing.OSC, @@ -221,6 +243,10 @@ pub const Command = union(Key) { "conemu_progress_report", "conemu_wait_input", "conemu_guimacro", + "conemu_run_process", + "conemu_output_environment_variable", + "conemu_xterm_emulation", + "conemu_comment", "kitty_text_sizing", }, ); @@ -424,11 +450,15 @@ pub const Parser = struct { .clipboard_contents, .color_operation, .conemu_change_tab_title, + .conemu_comment, .conemu_guimacro, + .conemu_output_environment_variable, .conemu_progress_report, + .conemu_run_process, .conemu_show_message_box, .conemu_sleep, .conemu_wait_input, + .conemu_xterm_emulation, .end_of_command, .end_of_input, .hyperlink_end, diff --git a/src/terminal/osc/parsers/osc9.zig b/src/terminal/osc/parsers/osc9.zig index 1ca7ba5a0..aba6f294a 100644 --- a/src/terminal/osc/parsers/osc9.zig +++ b/src/terminal/osc/parsers/osc9.zig @@ -16,11 +16,11 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { var data = writer.buffered(); if (data.len == 0) break :conemu; switch (data[0]) { - // Check for OSC 9;1 9;10 9;12 + // Check for OSC 9;1 9;10 9;11 9;12 '1' => { if (data.len < 2) break :conemu; switch (data[1]) { - // OSC 9;1 + // OSC 9;1 sleep ';' => { parser.command = .{ .conemu_sleep = .{ @@ -29,12 +29,74 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { }; return &parser.command; }, - // OSC 9;10 + // OSC 9;10 xterm keyboard and output emulation '0' => { - parser.state = .invalid; - return null; + if (data.len == 2) { + parser.command = .{ + .conemu_xterm_emulation = .{ + .keyboard = true, + .output = true, + }, + }; + return &parser.command; + } + if (data.len < 4) break :conemu; + if (data[2] != ';') break :conemu; + switch (data[3]) { + '0' => { + parser.command = .{ + .conemu_xterm_emulation = .{ + .keyboard = false, + .output = false, + }, + }; + return &parser.command; + }, + '1' => { + parser.command = .{ + .conemu_xterm_emulation = .{ + .keyboard = true, + .output = true, + }, + }; + return &parser.command; + }, + '2' => { + parser.command = .{ + .conemu_xterm_emulation = .{ + .keyboard = null, + .output = false, + }, + }; + return &parser.command; + }, + '3' => { + parser.command = .{ + .conemu_xterm_emulation = .{ + .keyboard = null, + .output = true, + }, + }; + return &parser.command; + }, + else => break :conemu, + } }, - // OSC 9;12 + // OSC 9;11 comment + '1' => { + if (data.len < 3) break :conemu; + if (data[2] != ';') break :conemu; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .conemu_comment = data[3 .. data.len - 1 :0], + }; + return &parser.command; + }, + // OSC 9;12 mark prompt start '2' => { parser.command = .{ .prompt_start = .{}, @@ -44,7 +106,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { else => break :conemu, } }, - // OSC 9;2 + // OSC 9;2 show message box '2' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; @@ -58,7 +120,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { }; return &parser.command; }, - // OSC 9;3 + // OSC 9;3 change tab title '3' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; @@ -80,7 +142,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { }; return &parser.command; }, - // OSC 9;4 + // OSC 9;4 progress report '4' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; @@ -141,12 +203,12 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { } return &parser.command; }, - // OSC 9;5 + // OSC 9;5 wait for input '5' => { parser.command = .conemu_wait_input; return &parser.command; }, - // OSC 9;6 + // OSC 9;6 guimacro '6' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; @@ -160,26 +222,49 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { }; return &parser.command; }, - // OSC 9;7 + // OSC 9;7 run process '7' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; - parser.state = .invalid; - return null; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .conemu_run_process = data[2 .. data.len - 1 :0], + }; + return &parser.command; }, - // OSC 9;8 + // OSC 9;8 output environment variable '8' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; - parser.state = .invalid; - return null; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .conemu_output_environment_variable = data[2 .. data.len - 1 :0], + }; + return &parser.command; }, - // OSC 9;9 + // OSC 9;9 current working directory '9' => { if (data.len < 2) break :conemu; if (data[1] != ';') break :conemu; - parser.state = .invalid; - return null; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + data = writer.buffered(); + parser.command = .{ + .report_pwd = .{ + .value = data[2 .. data.len - 1 :0], + }, + }; + return &parser.command; }, else => break :conemu, } @@ -764,3 +849,294 @@ test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); } + +test "OSC: 9;7: ConEmu run process 1" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;7;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_run_process); + try testing.expectEqualStrings("ab", cmd.conemu_run_process); +} + +test "OSC: 9;7: ConEmu run process 2" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;7;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_run_process); + try testing.expectEqualStrings("", cmd.conemu_run_process); +} + +test "OSC: 9;7: ConEmu run process incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;7"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("7", cmd.show_desktop_notification.body); +} + +test "OSC: 9;8: ConEmu output environment variable 1" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;8;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_output_environment_variable); + try testing.expectEqualStrings("ab", cmd.conemu_output_environment_variable); +} + +test "OSC: 9;8: ConEmu output environment variable 2" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;8;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_output_environment_variable); + try testing.expectEqualStrings("", cmd.conemu_output_environment_variable); +} + +test "OSC: 9;8: ConEmu output environment variable incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;8"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("8", cmd.show_desktop_notification.body); +} + +test "OSC: 9;9: ConEmu set current working directory" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;9;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .report_pwd); + try testing.expectEqualStrings("ab", cmd.report_pwd.value); +} + +test "OSC: 9;9: ConEmu set current working directory incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;9"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("9", cmd.show_desktop_notification.body); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 1" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_xterm_emulation); + try testing.expect(cmd.conemu_xterm_emulation.keyboard != null); + try testing.expect(cmd.conemu_xterm_emulation.keyboard.? == true); + try testing.expect(cmd.conemu_xterm_emulation.output != null); + try testing.expect(cmd.conemu_xterm_emulation.output.? == true); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 2" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;0"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_xterm_emulation); + try testing.expect(cmd.conemu_xterm_emulation.keyboard != null); + try testing.expect(cmd.conemu_xterm_emulation.keyboard.? == false); + try testing.expect(cmd.conemu_xterm_emulation.output != null); + try testing.expect(cmd.conemu_xterm_emulation.output.? == false); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 3" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;1"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_xterm_emulation); + try testing.expect(cmd.conemu_xterm_emulation.keyboard != null); + try testing.expect(cmd.conemu_xterm_emulation.keyboard.? == true); + try testing.expect(cmd.conemu_xterm_emulation.output != null); + try testing.expect(cmd.conemu_xterm_emulation.output.? == true); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 4" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_xterm_emulation); + try testing.expect(cmd.conemu_xterm_emulation.keyboard == null); + try testing.expect(cmd.conemu_xterm_emulation.output != null); + try testing.expect(cmd.conemu_xterm_emulation.output.? == false); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 5" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;3"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_xterm_emulation); + try testing.expect(cmd.conemu_xterm_emulation.keyboard == null); + try testing.expect(cmd.conemu_xterm_emulation.output != null); + try testing.expect(cmd.conemu_xterm_emulation.output.? == true); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 6" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("10;4", cmd.show_desktop_notification.body); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 7" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("10;", cmd.show_desktop_notification.body); +} + +test "OSC: 9;10: ConEmu xterm keyboard and output emulation 8" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;10;abc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("10;abc", cmd.show_desktop_notification.body); +} + +test "OSC: 9;11: ConEmu comment" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;11;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_comment); + try testing.expectEqualStrings("ab", cmd.conemu_comment); +} + +test "OSC: 9;11: ConEmu comment incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;11"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("11", cmd.show_desktop_notification.body); +} + +test "OSC: 9;12: ConEmu mark prompt start 1" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;12"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .prompt_start); +} + +test "OSC: 9;12: ConEmu mark prompt start 2" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "9;12;abc"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .prompt_start); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 74a01e8a6..4e1398d8d 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -2107,6 +2107,10 @@ pub fn Stream(comptime Handler: type) type { .conemu_change_tab_title, .conemu_wait_input, .conemu_guimacro, + .conemu_comment, + .conemu_xterm_emulation, + .conemu_output_environment_variable, + .conemu_run_process, .kitty_text_sizing, => { log.debug("unimplemented OSC callback: {}", .{cmd}); From 82b10ae7af9534606f09b4ead6d564552a2c0f3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 09:52:31 -0800 Subject: [PATCH 562/605] terminal: explicit error sets in Screen and ScreenSet --- src/terminal/PageList.zig | 15 +++++--- src/terminal/Screen.zig | 6 ++-- src/terminal/ScreenSet.zig | 4 +-- src/terminal/Selection.zig | 3 +- src/terminal/Terminal.zig | 36 +++++++++---------- src/terminal/color.zig | 8 ++--- .../osc/parsers/kitty_text_sizing.zig | 17 ++++++--- src/terminal/stream_readonly.zig | 12 +++---- src/terminal/x11_color.zig | 9 +++-- src/termio/stream_handler.zig | 12 +++---- 10 files changed, 70 insertions(+), 52 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b44db8412..7fe515818 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -781,7 +781,11 @@ pub fn clone( alloc: Allocator, opts: Clone, ) !PageList { - var it = self.pageIterator(.right_down, opts.top, opts.bot); + var it = self.pageIterator( + .right_down, + opts.top, + opts.bot, + ); // First, count our pages so our preheat is exactly what we need. var it_copy = it; @@ -2698,7 +2702,7 @@ fn scrollPrompt(self: *PageList, delta: isize) void { /// Clear the screen by scrolling written contents up into the scrollback. /// This will not update the viewport. -pub fn scrollClear(self: *PageList) !void { +pub fn scrollClear(self: *PageList) Allocator.Error!void { defer self.assertIntegrity(); // Go through the active area backwards to find the first non-empty @@ -4010,7 +4014,10 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { /// 1 | etc.| | 4 /// +-----+ : /// +--------+ -pub fn diagram(self: *const PageList, writer: *std.Io.Writer) !void { +pub fn diagram( + self: *const PageList, + writer: *std.Io.Writer, +) std.Io.Writer.Error!void { const active_pin = self.getTopLeft(.active); var active = false; @@ -4647,7 +4654,7 @@ pub fn totalPages(self: *const PageList) usize { /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized in any way. -fn growRows(self: *PageList, n: usize) !void { +fn growRows(self: *PageList, n: usize) Allocator.Error!void { for (0..n) |_| _ = try self.grow(); } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c8db19903..f93447651 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -241,7 +241,7 @@ pub const Options = struct { pub fn init( alloc: Allocator, opts: Options, -) !Screen { +) Allocator.Error!Screen { // Initialize our backing pages. var pages = try PageList.init( alloc, @@ -2324,7 +2324,7 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { } /// Set the selection to the given selection. If this is a tracked selection -/// then the screen will take overnship of the selection. If this is untracked +/// then the screen will take ownership of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically /// untrack the prior selection (if any). /// @@ -2333,7 +2333,7 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { /// This is always recommended over setting `selection` directly. Beyond /// managing memory for you, it also performs safety checks that the selection /// is always tracked. -pub fn select(self: *Screen, sel_: ?Selection) !void { +pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void { const sel = sel_ orelse { self.clearSelection(); return; diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index 418888694..cbaa03f47 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -32,7 +32,7 @@ all: std.EnumMap(Key, *Screen), pub fn init( alloc: Allocator, opts: Screen.Options, -) !ScreenSet { +) Allocator.Error!ScreenSet { // We need to initialize our initial primary screen const screen = try alloc.create(Screen); errdefer alloc.destroy(screen); @@ -64,7 +64,7 @@ pub fn getInit( alloc: Allocator, key: Key, opts: Screen.Options, -) !*Screen { +) Allocator.Error!*Screen { if (self.get(key)) |screen| return screen; const screen = try alloc.create(Screen); errdefer alloc.destroy(screen); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index bc597fc2e..8cb52816c 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -3,6 +3,7 @@ const Selection = @This(); const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; const page = @import("page.zig"); const point = @import("point.zig"); const PageList = @import("PageList.zig"); @@ -126,7 +127,7 @@ pub fn tracked(self: *const Selection) bool { /// Convert this selection a tracked selection. It is asserted this is /// an untracked selection. The tracked selection is returned. -pub fn track(self: *const Selection, s: *Screen) !Selection { +pub fn track(self: *const Selection, s: *Screen) Allocator.Error!Selection { assert(!self.tracked()); // Track our pins diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 45d19fa06..a955cbcae 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1118,7 +1118,7 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { /// Horizontal tab moves the cursor to the next tabstop, clearing /// the screen to the left the tabstop. -pub fn horizontalTab(self: *Terminal) !void { +pub fn horizontalTab(self: *Terminal) void { while (self.screens.active.cursor.x < self.scrolling_region.right) { // Move the cursor right self.screens.active.cursorRight(1); @@ -1131,7 +1131,7 @@ pub fn horizontalTab(self: *Terminal) !void { } // Same as horizontalTab but moves to the previous tabstop instead of the next. -pub fn horizontalTabBack(self: *Terminal) !void { +pub fn horizontalTabBack(self: *Terminal) void { // With origin mode enabled, our leftmost limit is the left margin. const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; @@ -4736,17 +4736,17 @@ test "Terminal: horizontal tabs" { // HT try t.print('1'); - try t.horizontalTab(); + t.horizontalTab(); try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); // HT - try t.horizontalTab(); + t.horizontalTab(); try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); // HT at the end - try t.horizontalTab(); + t.horizontalTab(); try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); - try t.horizontalTab(); + t.horizontalTab(); try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); } @@ -4758,7 +4758,7 @@ test "Terminal: horizontal tabs starting on tabstop" { t.setCursorPos(t.screens.active.cursor.y, 9); try t.print('X'); t.setCursorPos(t.screens.active.cursor.y, 9); - try t.horizontalTab(); + t.horizontalTab(); try t.print('A'); { @@ -4777,7 +4777,7 @@ test "Terminal: horizontal tabs with right margin" { t.scrolling_region.right = 5; t.setCursorPos(t.screens.active.cursor.y, 1); try t.print('X'); - try t.horizontalTab(); + t.horizontalTab(); try t.print('A'); { @@ -4796,17 +4796,17 @@ test "Terminal: horizontal tabs back" { t.setCursorPos(t.screens.active.cursor.y, 20); // HT - try t.horizontalTabBack(); + t.horizontalTabBack(); try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); // HT - try t.horizontalTabBack(); + t.horizontalTabBack(); try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); // HT - try t.horizontalTabBack(); + t.horizontalTabBack(); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); - try t.horizontalTabBack(); + t.horizontalTabBack(); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); } @@ -4818,7 +4818,7 @@ test "Terminal: horizontal tabs back starting on tabstop" { t.setCursorPos(t.screens.active.cursor.y, 9); try t.print('X'); t.setCursorPos(t.screens.active.cursor.y, 9); - try t.horizontalTabBack(); + t.horizontalTabBack(); try t.print('A'); { @@ -4838,7 +4838,7 @@ test "Terminal: horizontal tabs with left margin in origin mode" { t.scrolling_region.right = 5; t.setCursorPos(1, 2); try t.print('X'); - try t.horizontalTabBack(); + t.horizontalTabBack(); try t.print('A'); { @@ -4858,7 +4858,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(5, 0); t.restoreCursor(); - try t.horizontalTabBack(); + t.horizontalTabBack(); try t.print('X'); { @@ -10593,11 +10593,11 @@ test "Terminal: tabClear single" { var t = try init(alloc, .{ .cols = 30, .rows = 5 }); defer t.deinit(alloc); - try t.horizontalTab(); + t.horizontalTab(); t.tabClear(.current); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); - try t.horizontalTab(); + t.horizontalTab(); try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); } @@ -10609,7 +10609,7 @@ test "Terminal: tabClear all" { t.tabClear(.all); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); - try t.horizontalTab(); + t.horizontalTab(); try testing.expectEqual(@as(usize, 29), t.screens.active.cursor.x); } diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 07c3e72f5..1e9e4b642 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -168,7 +168,7 @@ pub const Name = enum(u8) { } /// Default colors for tagged values. - pub fn default(self: Name) !RGB { + pub fn default(self: Name) error{NoDefaultValue}!RGB { return switch (self) { .black => RGB{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, .red => RGB{ .r = 0xCC, .g = 0x66, .b = 0x66 }, @@ -355,7 +355,7 @@ pub const RGB = packed struct(u24) { /// Parse a color from a floating point intensity value. /// /// The value should be between 0.0 and 1.0, inclusive. - fn fromIntensity(value: []const u8) !u8 { + fn fromIntensity(value: []const u8) error{InvalidFormat}!u8 { const i = std.fmt.parseFloat(f64, value) catch { @branchHint(.cold); return error.InvalidFormat; @@ -372,7 +372,7 @@ pub const RGB = packed struct(u24) { /// /// The string can contain 1, 2, 3, or 4 characters and represents the color /// value scaled in 4, 8, 12, or 16 bits, respectively. - fn fromHex(value: []const u8) !u8 { + fn fromHex(value: []const u8) error{InvalidFormat}!u8 { if (value.len == 0 or value.len > 4) { @branchHint(.cold); return error.InvalidFormat; @@ -414,7 +414,7 @@ pub const RGB = packed struct(u24) { /// where `r`, `g`, and `b` are a single hexadecimal digit. /// These specify a color with 4, 8, 12, and 16 bits of precision /// per color channel. - pub fn parse(value: []const u8) !RGB { + pub fn parse(value: []const u8) error{InvalidFormat}!RGB { if (value.len == 0) { @branchHint(.cold); return error.InvalidFormat; diff --git a/src/terminal/osc/parsers/kitty_text_sizing.zig b/src/terminal/osc/parsers/kitty_text_sizing.zig index 2c2d1b8fd..f0180cc8f 100644 --- a/src/terminal/osc/parsers/kitty_text_sizing.zig +++ b/src/terminal/osc/parsers/kitty_text_sizing.zig @@ -44,16 +44,23 @@ pub const OSC = struct { return {}; } - fn update(self: *OSC, key: u8, value: []const u8) !void { + fn update(self: *OSC, key: u8, value: []const u8) error{ + UnknownKey, + InvalidValue, + }!void { // All values are numeric, so we can do a small hack here - const v = try std.fmt.parseInt(u4, value, 10); + const v = std.fmt.parseInt( + u4, + value, + 10, + ) catch return error.InvalidValue; switch (key) { 's' => { if (v == 0) return error.InvalidValue; - self.scale = std.math.cast(u3, v) orelse return error.Overflow; + self.scale = std.math.cast(u3, v) orelse return error.InvalidValue; }, - 'w' => self.width = std.math.cast(u3, v) orelse return error.Overflow, + 'w' => self.width = std.math.cast(u3, v) orelse return error.InvalidValue, 'n' => self.numerator = v, 'd' => self.denominator = v, 'v' => self.valign = std.enums.fromInt(VAlign, v) orelse return error.InvalidValue, @@ -130,7 +137,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { cmd.update(k[0], value) catch |err| { switch (err) { error.UnknownKey => log.warn("unknown key: '{c}'", .{k[0]}), - else => log.warn("invalid value for key '{c}': {}", .{ k[0], err }), + error.InvalidValue => log.warn("invalid value for key '{c}': {}", .{ k[0], err }), } continue; }; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 9b4999116..90fcead93 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -102,8 +102,8 @@ pub const Handler = struct { .delete_lines => self.terminal.deleteLines(value), .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), - .horizontal_tab => try self.horizontalTab(value), - .horizontal_tab_back => try self.horizontalTabBack(value), + .horizontal_tab => self.horizontalTab(value), + .horizontal_tab_back => self.horizontalTabBack(value), .tab_clear_current => self.terminal.tabClear(.current), .tab_clear_all => self.terminal.tabClear(.all), .tab_set => self.terminal.tabSet(), @@ -200,18 +200,18 @@ pub const Handler = struct { } } - inline fn horizontalTab(self: *Handler, count: u16) !void { + inline fn horizontalTab(self: *Handler, count: u16) void { for (0..count) |_| { const x = self.terminal.screens.active.cursor.x; - try self.terminal.horizontalTab(); + self.terminal.horizontalTab(); if (x == self.terminal.screens.active.cursor.x) break; } } - inline fn horizontalTabBack(self: *Handler, count: u16) !void { + inline fn horizontalTabBack(self: *Handler, count: u16) void { for (0..count) |_| { const x = self.terminal.screens.active.cursor.x; - try self.terminal.horizontalTabBack(); + self.terminal.horizontalTabBack(); if (x == self.terminal.screens.active.cursor.x) break; } } diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig index 477218d6f..95dcb76db 100644 --- a/src/terminal/x11_color.zig +++ b/src/terminal/x11_color.zig @@ -3,11 +3,14 @@ const assert = @import("../quirks.zig").inlineAssert; const RGB = @import("color.zig").RGB; /// The map of all available X11 colors. -pub const map = colorMap() catch @compileError("failed to parse rgb.txt"); +pub const map = colorMap(); -pub const ColorMap = std.StaticStringMapWithEql(RGB, std.static_string_map.eqlAsciiIgnoreCase); +pub const ColorMap = std.StaticStringMapWithEql( + RGB, + std.static_string_map.eqlAsciiIgnoreCase, +); -fn colorMap() !ColorMap { +fn colorMap() ColorMap { @setEvalBranchQuota(500_000); const KV = struct { []const u8, RGB }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e027a5d21..2a2b338a4 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -198,8 +198,8 @@ pub const StreamHandler = struct { .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), - .horizontal_tab => try self.horizontalTab(value), - .horizontal_tab_back => try self.horizontalTabBack(value), + .horizontal_tab => self.horizontalTab(value), + .horizontal_tab_back => self.horizontalTabBack(value), .linefeed => { @branchHint(.likely); try self.linefeed(); @@ -560,18 +560,18 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.ring_bell); } - inline fn horizontalTab(self: *StreamHandler, count: u16) !void { + inline fn horizontalTab(self: *StreamHandler, count: u16) void { for (0..count) |_| { const x = self.terminal.screens.active.cursor.x; - try self.terminal.horizontalTab(); + self.terminal.horizontalTab(); if (x == self.terminal.screens.active.cursor.x) break; } } - inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + inline fn horizontalTabBack(self: *StreamHandler, count: u16) void { for (0..count) |_| { const x = self.terminal.screens.active.cursor.x; - try self.terminal.horizontalTabBack(); + self.terminal.horizontalTabBack(); if (x == self.terminal.screens.active.cursor.x) break; } } From a83bd6a111ce732fcdd439bc505c29da66028e6b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 11:27:53 -0800 Subject: [PATCH 563/605] font: add tripwire tests to Atlas --- src/font/Atlas.zig | 94 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 7dcff8416..4af9cb439 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -20,6 +20,7 @@ const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const testing = std.testing; const fastmem = @import("../fastmem.zig"); +const tripwire = @import("../tripwire.zig"); const log = std.log.scoped(.atlas); @@ -91,7 +92,15 @@ pub const Region = extern struct { /// TODO: figure out optimal prealloc based on real world usage const node_prealloc: usize = 64; +pub const init_tw = tripwire.module(enum { + alloc_data, + alloc_nodes, +}, init); + pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas { + const tw = init_tw; + + try tw.check(.alloc_data); var result = Atlas{ .data = try alloc.alloc(u8, size * size * format.depth()), .size = size, @@ -101,6 +110,7 @@ pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas { errdefer result.deinit(alloc); // Prealloc some nodes. + try tw.check(.alloc_nodes); result.nodes = try .initCapacity(alloc, node_prealloc); // This sets up our initial state @@ -115,6 +125,10 @@ pub fn deinit(self: *Atlas, alloc: Allocator) void { self.* = undefined; } +pub const reserve_tw = tripwire.module(enum { + insert_node, +}, reserve); + /// Reserve a region within the atlas with the given width and height. /// /// May allocate to add a new rectangle into the internal list of rectangles. @@ -125,6 +139,8 @@ pub fn reserve( width: u32, height: u32, ) (Allocator.Error || Error)!Region { + const tw = reserve_tw; + // x, y are populated within :best_idx below var region: Region = .{ .x = 0, .y = 0, .width = width, .height = height }; @@ -162,11 +178,13 @@ pub fn reserve( }; // Insert our new node for this rectangle at the exact best index + try tw.check(.insert_node); try self.nodes.insert(alloc, best_idx, .{ .x = region.x, .y = region.y + height, .width = width, }); + errdefer comptime unreachable; // Optimize our rectangles var i: usize = best_idx + 1; @@ -287,15 +305,24 @@ pub fn setFromLarger( _ = self.modified.fetchAdd(1, .monotonic); } +pub const grow_tw = tripwire.module(enum { + ensure_node_capacity, + alloc_data, +}, grow); + // Grow the texture to the new size, preserving all previously written data. pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void { + const tw = grow_tw; + assert(size_new >= self.size); if (size_new == self.size) return; // We reserve space ahead of time for the new node, so that we // won't have to handle any errors after allocating our new data. + try tw.check(.ensure_node_capacity); try self.nodes.ensureUnusedCapacity(alloc, 1); + try tw.check(.alloc_data); const data_new = try alloc.alloc( u8, size_new * size_new * self.format.depth(), @@ -355,7 +382,7 @@ pub fn clear(self: *Atlas) void { /// swapped because PPM expects RGB. This would be /// easy enough to fix so next time someone needs /// to debug a color atlas they should fix it. -pub fn dump(self: Atlas, writer: *std.Io.Writer) !void { +pub fn dump(self: Atlas, writer: *std.Io.Writer) std.Io.Writer.Error!void { try writer.print( \\P{c} \\{d} {d} @@ -795,3 +822,68 @@ test "grow OOM" { try testing.expectEqual(@as(u8, 3), atlas.data[9]); try testing.expectEqual(@as(u8, 4), atlas.data[10]); } + +test "init error" { + // Test every failure point in `init` and ensure that we don't + // leak memory (testing.allocator verifies) since we're exiting early. + for (std.meta.tags(init_tw.FailPoint)) |tag| { + const tw = init_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAlways(tag, error.OutOfMemory); + try testing.expectError( + error.OutOfMemory, + init(testing.allocator, 32, .grayscale), + ); + } +} + +test "reserve error" { + // Test every failure point in `reserve` and ensure that we don't + // leak memory (testing.allocator verifies) since we're exiting early. + for (std.meta.tags(reserve_tw.FailPoint)) |tag| { + const tw = reserve_tw; + defer tw.end(.reset) catch unreachable; + + var atlas = try init(testing.allocator, 32, .grayscale); + defer atlas.deinit(testing.allocator); + + try tw.errorAlways(tag, error.OutOfMemory); + try testing.expectError( + error.OutOfMemory, + atlas.reserve(testing.allocator, 2, 2), + ); + } +} + +test "grow error" { + // Test every failure point in `grow` and ensure that we don't + // leak memory (testing.allocator verifies) since we're exiting early. + for (std.meta.tags(grow_tw.FailPoint)) |tag| { + const tw = grow_tw; + defer tw.end(.reset) catch unreachable; + + var atlas = try init(testing.allocator, 4, .grayscale); + defer atlas.deinit(testing.allocator); + + // Write some data to verify it's preserved after failed grow + const reg = try atlas.reserve(testing.allocator, 2, 2); + atlas.set(reg, &[_]u8{ 1, 2, 3, 4 }); + + const old_modified = atlas.modified.load(.monotonic); + const old_resized = atlas.resized.load(.monotonic); + + try tw.errorAlways(tag, error.OutOfMemory); + try testing.expectError( + error.OutOfMemory, + atlas.grow(testing.allocator, atlas.size + 1), + ); + + // Verify atlas state is unchanged after failed grow + try testing.expectEqual(old_modified, atlas.modified.load(.monotonic)); + try testing.expectEqual(old_resized, atlas.resized.load(.monotonic)); + try testing.expectEqual(@as(u8, 1), atlas.data[5]); + try testing.expectEqual(@as(u8, 2), atlas.data[6]); + try testing.expectEqual(@as(u8, 3), atlas.data[9]); + try testing.expectEqual(@as(u8, 4), atlas.data[10]); + } +} From c1b22a8041b5b0e2e4131adbac1903c5e9aa9af4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 11:49:24 -0800 Subject: [PATCH 564/605] terminal: fix leak on error in selectionString --- src/terminal/Screen.zig | 52 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f93447651..fe158c0a3 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -9,6 +9,7 @@ const charsets = @import("charsets.zig"); const fastmem = @import("../fastmem.zig"); const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); +const tripwire = @import("../tripwire.zig"); const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); @@ -2371,6 +2372,10 @@ pub const SelectionString = struct { map: ?*StringMap = null, }; +const selectionString_tw = tripwire.module(enum { + copy_map, +}, selectionString); + /// Returns the raw text associated with a selection. This will unwrap /// soft-wrapped edges. The returned slice is owned by the caller and allocated /// using alloc, not the allocator associated with the screen (unless they match). @@ -2380,7 +2385,7 @@ pub fn selectionString( self: *Screen, alloc: Allocator, opts: SelectionString, -) ![:0]const u8 { +) Allocator.Error![:0]const u8 { // We'll use this as our buffer to build our string. var aw: std.Io.Writer.Allocating = .init(alloc); defer aw.deinit(); @@ -2404,19 +2409,23 @@ pub fn selectionString( .map = &pins, }; - // Emit - try formatter.format(&aw.writer); + // Emit. Since this is an allocating writer, a failed write + // just becomes an OOM. + formatter.format(&aw.writer) catch return error.OutOfMemory; // Build our final text and if we have a string map set that up. const text = try aw.toOwnedSliceSentinel(0); errdefer alloc.free(text); if (opts.map) |map| { + const map_string = try alloc.dupeZ(u8, text); + errdefer alloc.free(map_string); + try selectionString_tw.check(.copy_map); + const map_pins = try pins.toOwnedSlice(alloc); map.* = .{ - .string = try alloc.dupeZ(u8, text), - .map = try pins.toOwnedSlice(alloc), + .string = map_string, + .map = map_pins, }; } - errdefer if (opts.map) |m| m.deinit(alloc); return text; } @@ -9464,3 +9473,34 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { s.cursor.page_pin.node != original_node; try testing.expect(page_was_split); } + +test "selectionString map allocation failure cleanup" { + // This test verifies that if toOwnedSlice fails when building + // the StringMap, we don't leak the already-allocated map.string. + const testing = std.testing; + const alloc = testing.allocator; + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + try s.testWriteString("hello"); + + // Get a selection + const sel = Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?, + false, + ); + + // Trigger allocation failure on toOwnedSlice + var map: StringMap = undefined; + try selectionString_tw.errorAlways(.copy_map, error.OutOfMemory); + const result = s.selectionString(alloc, .{ + .sel = sel, + .map = &map, + }); + try testing.expectError(error.OutOfMemory, result); + try selectionString_tw.end(.reset); + + // If this test passes without memory leaks (when run with testing.allocator), + // it means the errdefer properly cleaned up map.string when toOwnedSlice failed. +} From 64ccad3a75e0aa7d5dc34aec6ff7d34f30d5b4dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 11:56:56 -0800 Subject: [PATCH 565/605] terminal: fix memory leak on error handling in screen search --- src/terminal/search/screen.zig | 108 ++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 179e7da87..c3f48b422 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; +const tripwire = @import("../../tripwire.zig"); const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const highlight = @import("../highlight.zig"); @@ -17,6 +18,11 @@ const SlidingWindow = @import("sliding_window.zig").SlidingWindow; const log = std.log.scoped(.search_screen); +const reloadActive_tw = tripwire.module(enum { + history_append_new, + history_append_existing, +}, ScreenSearch.reloadActive); + /// Searches for a needle within a Screen, handling active area updates, /// pages being pruned from the screen (e.g. scrollback limits), and more. /// @@ -386,6 +392,8 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + const tw = reloadActive_tw; + // If our selection pin became garbage it means we scrolled off // the end. Clear our selection and on exit of this function, // try to select the last match. @@ -485,12 +493,16 @@ pub const ScreenSearch = struct { alloc, self.history_results.items.len, ); - errdefer results.deinit(alloc); + errdefer { + for (results.items) |*hl| hl.deinit(alloc); + results.deinit(alloc); + } while (window.next()) |hl| { if (hl.chunks.items(.node)[0] == history_node) continue; var hl_cloned = try hl.clone(alloc); errdefer hl_cloned.deinit(alloc); + try tw.check(.history_append_new); try results.append(alloc, hl_cloned); } @@ -505,6 +517,7 @@ pub const ScreenSearch = struct { // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. std.mem.reverse(FlattenedHighlight, results.items); + try tw.check(.history_append_existing); try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; @@ -1408,3 +1421,96 @@ test "screen search no scrollback has no history" { defer alloc.free(matches); try testing.expectEqual(0, matches.len); } + +test "reloadActive partial history cleanup on appendSlice error" { + // This test verifies that when reloadActive fails at appendSlice (after + // the loop), all FlattenedHighlight items are properly cleaned up. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + // Write multiple "Fizz" matches that will end up in history. + // We need enough content to push "Fizz" entries into scrollback. + try s.nextSlice("Fizz\r\nFizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz."); + + // Complete initial search + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Now trigger reloadActive by adding more content that changes the + // active/history boundary. First add more "Fizz" entries to history. + try s.nextSlice("\r\nFizz\r\nFizz\r\n"); + while (list.totalPages() < 4) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + + // Arm the tripwire to fail at appendSlice (after the loop completes). + // At this point, there are FlattenedHighlight items in the results list + // that need cleanup. + const tw = reloadActive_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAlways(.history_append_existing, error.OutOfMemory); + + // reloadActive is called by select(), which should trigger the error path. + // If the bug exists, testing.allocator will report a memory leak + // because FlattenedHighlight items weren't cleaned up. + try testing.expectError(error.OutOfMemory, search.select(.next)); +} + +test "reloadActive partial history cleanup on loop append error" { + // This test verifies that when reloadActive fails inside the loop + // (after some items have been appended), all FlattenedHighlight items + // are properly cleaned up. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + // Write multiple "Fizz" matches that will end up in history. + // We need enough content to push "Fizz" entries into scrollback. + try s.nextSlice("Fizz\r\nFizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz."); + + // Complete initial search + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Now trigger reloadActive by adding more content that changes the + // active/history boundary. First add more "Fizz" entries to history. + try s.nextSlice("\r\nFizz\r\nFizz\r\n"); + while (list.totalPages() < 4) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + + // Arm the tripwire to fail after the first loop append succeeds. + // This leaves at least one FlattenedHighlight in the results list + // that needs cleanup. + const tw = reloadActive_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAfter(.history_append_new, error.OutOfMemory, 1); + + // reloadActive is called by select(), which should trigger the error path. + // If the bug exists, testing.allocator will report a memory leak + // because FlattenedHighlight items weren't cleaned up. + try testing.expectError(error.OutOfMemory, search.select(.next)); +} From b606b71cda3413b93f2e470edd1ba6041f2613a1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 12:20:25 -0800 Subject: [PATCH 566/605] font: fix missing errdefer rollback in SharedGrid.renderGlyph Add errdefer to remove cache entry after getOrPut if subsequent operations fail (getPresentation, atlas.grow, renderGlyph). Without this, failed renders would leave uninitialized/garbage entries in the glyph cache, potentially causing crashes or incorrect rendering. Add tripwire test to verify the rollback behavior. --- src/font/SharedGrid.zig | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 52aedefc6..4079fc801 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -20,6 +20,7 @@ const SharedGrid = @This(); const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const tripwire = @import("../tripwire.zig"); const Allocator = std.mem.Allocator; const renderer = @import("../renderer.zig"); const font = @import("main.zig"); @@ -232,6 +233,10 @@ pub fn renderCodepoint( return try self.renderGlyph(alloc, index, glyph_index, opts); } +pub const renderGlyph_tw = tripwire.module(enum { + get_presentation, +}, renderGlyph); + /// Render a glyph index. This automatically determines the correct texture /// atlas to use and caches the result. pub fn renderGlyph( @@ -241,6 +246,8 @@ pub fn renderGlyph( glyph_index: u32, opts: RenderOptions, ) !Render { + const tw = renderGlyph_tw; + const key: GlyphKey = .{ .index = index, .glyph = glyph_index, .opts = opts }; // Fast path: the cache has the value. This is almost always true and @@ -257,8 +264,10 @@ pub fn renderGlyph( const gop = try self.glyphs.getOrPut(alloc, key); if (gop.found_existing) return gop.value_ptr.*; + errdefer self.glyphs.removeByPtr(gop.key_ptr); // Get the presentation to determine what atlas to use + try tw.check(.get_presentation); const p = try self.resolver.getPresentation(index, glyph_index); const atlas: *font.Atlas = switch (p) { .text => &self.atlas_grayscale, @@ -426,3 +435,51 @@ test getIndex { try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx); } } + +test "renderGlyph error after cache insert rolls back cache entry" { + // This test verifies that when renderGlyph fails after inserting a cache + // entry (via getOrPut), the errdefer properly removes the entry, preventing + // corrupted/uninitialized data from remaining in the cache. + + const testing = std.testing; + const alloc = testing.allocator; + + var lib = try Library.init(alloc); + defer lib.deinit(); + + var grid = try testGrid(.normal, alloc, lib); + defer grid.deinit(alloc); + + // Get the font index for 'A' + const idx = (try grid.getIndex(alloc, 'A', .regular, null)).?; + + // Get the glyph index for 'A' + const glyph_index = glyph_index: { + grid.lock.lockShared(); + defer grid.lock.unlockShared(); + const face = try grid.resolver.collection.getFace(idx); + break :glyph_index face.glyphIndex('A').?; + }; + + const render_opts: RenderOptions = .{ .grid_metrics = grid.metrics }; + const key: GlyphKey = .{ .index = idx, .glyph = glyph_index, .opts = render_opts }; + + // Verify the cache is empty for this glyph + try testing.expect(grid.glyphs.get(key) == null); + + // Set up tripwire to fail after cache insert. + // We use OutOfMemory as it's a valid error in the renderGlyph error set. + const tw = renderGlyph_tw; + defer tw.end(.reset) catch {}; + try tw.errorAlways(.get_presentation, error.OutOfMemory); + + // This should fail due to the tripwire + try testing.expectError( + error.OutOfMemory, + grid.renderGlyph(alloc, idx, glyph_index, render_opts), + ); + + // The errdefer should have removed the cache entry, leaving the cache clean. + // Without the errdefer fix, this would contain garbage/uninitialized data. + try testing.expect(grid.glyphs.get(key) == null); +} From 3fdff49a821d81c71f12f979075e4c094a928202 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 12:35:47 -0800 Subject: [PATCH 567/605] font: fix memory leak in SharedGrid.init on late failure Add errdefer cleanup for codepoints and glyphs hash maps in init(). Previously, if ensureTotalCapacity or reloadMetrics() failed after allocating these maps, they would leak. Add tripwire test to verify all failure points in init(). --- src/font/SharedGrid.zig | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 4079fc801..df98398f2 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -62,6 +62,12 @@ metrics: Metrics, /// to review call sites to ensure they are using the lock correctly. lock: std.Thread.RwLock, +pub const init_tw = tripwire.module(enum { + codepoints_capacity, + glyphs_capacity, + reload_metrics, +}, init); + /// Initialize the grid. /// /// The resolver must have a collection that supports deferred loading @@ -75,6 +81,8 @@ pub fn init( alloc: Allocator, resolver: CodepointResolver, ) !SharedGrid { + const tw = init_tw; + // We need to support loading options since we use the size data assert(resolver.collection.load_options != null); @@ -93,10 +101,15 @@ pub fn init( // We set an initial capacity that can fit a good number of characters. // This number was picked empirically based on my own terminal usage. + try tw.check(.codepoints_capacity); try result.codepoints.ensureTotalCapacity(alloc, 128); + errdefer result.codepoints.deinit(alloc); + try tw.check(.glyphs_capacity); try result.glyphs.ensureTotalCapacity(alloc, 128); + errdefer result.glyphs.deinit(alloc); // Initialize our metrics. + try tw.check(.reload_metrics); try result.reloadMetrics(); return result; @@ -483,3 +496,45 @@ test "renderGlyph error after cache insert rolls back cache entry" { // Without the errdefer fix, this would contain garbage/uninitialized data. try testing.expect(grid.glyphs.get(key) == null); } + +test "init error" { + // Test every failure point in `init` and ensure that we don't + // leak memory (testing.allocator verifies) since we're exiting early. + // + // BUG: Currently this test will fail because init() is missing errdefer + // cleanup for codepoints and glyphs when late operations fail + // (ensureTotalCapacity, reloadMetrics). + const testing = std.testing; + const alloc = testing.allocator; + + for (std.meta.tags(init_tw.FailPoint)) |tag| { + const tw = init_tw; + defer tw.end(.reset) catch unreachable; + try tw.errorAlways(tag, error.OutOfMemory); + + // Create a resolver for testing - we need to set up a minimal one. + // The caller is responsible for cleaning up the resolver if init fails. + var lib = try Library.init(alloc); + defer lib.deinit(); + + var c = Collection.init(); + c.load_options = .{ .library = lib }; + _ = try c.add(alloc, try .init( + lib, + font.embedded.regular, + .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, + ), .{ + .style = .regular, + .fallback = false, + .size_adjustment = .none, + }); + + var resolver: CodepointResolver = .{ .collection = c }; + defer resolver.deinit(alloc); // Caller cleans up on init failure + + try testing.expectError( + error.OutOfMemory, + init(alloc, resolver), + ); + } +} From 01f1611c9ffe6883c19cf091c1c2e6d58c391144 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 21 Jan 2026 15:30:22 -0800 Subject: [PATCH 568/605] tripwire: change backing store from ArrayHashMap to EnumMap This eliminates all allocation from Tripwire. --- src/font/Atlas.zig | 6 ++-- src/font/SharedGrid.zig | 4 +-- src/terminal/PageList.zig | 6 ++-- src/terminal/Screen.zig | 2 +- src/terminal/Tabstops.zig | 2 +- src/terminal/search/screen.zig | 4 +-- src/tripwire.zig | 57 +++++++++++----------------------- 7 files changed, 30 insertions(+), 51 deletions(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 4af9cb439..d12064576 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -829,7 +829,7 @@ test "init error" { for (std.meta.tags(init_tw.FailPoint)) |tag| { const tw = init_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAlways(tag, error.OutOfMemory); + tw.errorAlways(tag, error.OutOfMemory); try testing.expectError( error.OutOfMemory, init(testing.allocator, 32, .grayscale), @@ -847,7 +847,7 @@ test "reserve error" { var atlas = try init(testing.allocator, 32, .grayscale); defer atlas.deinit(testing.allocator); - try tw.errorAlways(tag, error.OutOfMemory); + tw.errorAlways(tag, error.OutOfMemory); try testing.expectError( error.OutOfMemory, atlas.reserve(testing.allocator, 2, 2), @@ -872,7 +872,7 @@ test "grow error" { const old_modified = atlas.modified.load(.monotonic); const old_resized = atlas.resized.load(.monotonic); - try tw.errorAlways(tag, error.OutOfMemory); + tw.errorAlways(tag, error.OutOfMemory); try testing.expectError( error.OutOfMemory, atlas.grow(testing.allocator, atlas.size + 1), diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index df98398f2..5fd729b30 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -484,7 +484,7 @@ test "renderGlyph error after cache insert rolls back cache entry" { // We use OutOfMemory as it's a valid error in the renderGlyph error set. const tw = renderGlyph_tw; defer tw.end(.reset) catch {}; - try tw.errorAlways(.get_presentation, error.OutOfMemory); + tw.errorAlways(.get_presentation, error.OutOfMemory); // This should fail due to the tripwire try testing.expectError( @@ -510,7 +510,7 @@ test "init error" { for (std.meta.tags(init_tw.FailPoint)) |tag| { const tw = init_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAlways(tag, error.OutOfMemory); + tw.errorAlways(tag, error.OutOfMemory); // Create a resolver for testing - we need to set up a minimal one. // The caller is responsible for cleaning up the resolver if init fails. diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 7fe515818..f7d3c735f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -5170,7 +5170,7 @@ test "PageList init error" { for (std.meta.tags(init_tw.FailPoint)) |tag| { const tw = init_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAlways(tag, error.OutOfMemory); + tw.errorAlways(tag, error.OutOfMemory); try std.testing.expectError( error.OutOfMemory, init( @@ -5187,7 +5187,7 @@ test "PageList init error" { for (std.meta.tags(initPages_tw.FailPoint)) |tag| { const tw = initPages_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAlways(tag, error.OutOfMemory); + tw.errorAlways(tag, error.OutOfMemory); const cols: size.CellCountInt = if (tag == .page_buf_std) 80 else std_capacity.maxCols().? + 1; try std.testing.expectError( @@ -5207,7 +5207,7 @@ test "PageList init error" { }) |tag| { const tw = initPages_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAfter(tag, error.OutOfMemory, 1); + tw.errorAfter(tag, error.OutOfMemory, 1); try std.testing.expectError( error.OutOfMemory, init( diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index fe158c0a3..45fe9dfc6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -9493,7 +9493,7 @@ test "selectionString map allocation failure cleanup" { // Trigger allocation failure on toOwnedSlice var map: StringMap = undefined; - try selectionString_tw.errorAlways(.copy_map, error.OutOfMemory); + selectionString_tw.errorAlways(.copy_map, error.OutOfMemory); const result = s.selectionString(alloc, .{ .sel = sel, .map = &map, diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig index 5e784e6f2..68138cbf8 100644 --- a/src/terminal/Tabstops.zig +++ b/src/terminal/Tabstops.zig @@ -261,7 +261,7 @@ test "Tabstops: resize alloc failure preserves state" { const original_cols = t.cols; // Trigger allocation failure when resizing beyond prealloc - try resize_tw.errorAlways(.dynamic_alloc, error.OutOfMemory); + resize_tw.errorAlways(.dynamic_alloc, error.OutOfMemory); const result = t.resize(testing.allocator, prealloc_columns * 2); try testing.expectError(error.OutOfMemory, result); try resize_tw.end(.reset); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index c3f48b422..74828d879 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1460,7 +1460,7 @@ test "reloadActive partial history cleanup on appendSlice error" { // that need cleanup. const tw = reloadActive_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAlways(.history_append_existing, error.OutOfMemory); + tw.errorAlways(.history_append_existing, error.OutOfMemory); // reloadActive is called by select(), which should trigger the error path. // If the bug exists, testing.allocator will report a memory leak @@ -1507,7 +1507,7 @@ test "reloadActive partial history cleanup on loop append error" { // that needs cleanup. const tw = reloadActive_tw; defer tw.end(.reset) catch unreachable; - try tw.errorAfter(.history_append_new, error.OutOfMemory, 1); + tw.errorAfter(.history_append_new, error.OutOfMemory, 1); // reloadActive is called by select(), which should trigger the error path. // If the bug exists, testing.allocator will report a memory leak diff --git a/src/tripwire.zig b/src/tripwire.zig index f8aaead14..225674b33 100644 --- a/src/tripwire.zig +++ b/src/tripwire.zig @@ -43,7 +43,7 @@ //! } //! //! test "myFunction fails on alloc" { -//! try tw.errorAlways(.alloc_buf, error.OutOfMemory); +//! tw.errorAlways(.alloc_buf, error.OutOfMemory); //! try std.testing.expectError(error.OutOfMemory, myFunction()); //! try tw.end(.reset); //! } @@ -67,7 +67,6 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const testing = std.testing; -const Allocator = std.mem.Allocator; const log = std.log.scoped(.tripwire); @@ -134,8 +133,8 @@ pub fn module( } /// The configured tripwires for this module. - var tripwires: TripwireMap = .empty; - const TripwireMap = std.AutoArrayHashMapUnmanaged(FailPoint, Tripwire); + var tripwires: TripwireMap = .{}; + const TripwireMap = std.EnumMap(FailPoint, Tripwire); const Tripwire = struct { /// Error to return when tripped err: Error, @@ -155,14 +154,6 @@ pub fn module( tripped: bool = false, }; - /// For all allocations we use an allocator that can leak memory - /// without reporting it, since this is only used in tests. We don't - /// want to use a testing allocator here because that would report - /// leaks. Users are welcome to call `deinit` on the module to - /// free all memory. - const LeakyAllocator = std.heap.DebugAllocator(.{}); - var alloc_state: LeakyAllocator = .init; - /// Check for a failure at the given failure point. These should /// be placed directly before the `try` operation that may fail. pub fn check(point: FailPoint) callconv(callingConvention()) Error!void { @@ -187,42 +178,31 @@ pub fn module( } /// Mark a failure point to always trip with the given error. - pub fn errorAlways( - point: FailPoint, - err: Error, - ) Allocator.Error!void { - try errorAfter(point, err, 0); + pub fn errorAlways(point: FailPoint, err: Error) void { + errorAfter(point, err, 0); } /// Mark a failure point to trip with the given error after /// the failure point is reached at least `min` times. A value of /// zero is equivalent to `errorAlways`. - pub fn errorAfter( - point: FailPoint, - err: Error, - min: usize, - ) Allocator.Error!void { - try tripwires.put( - alloc_state.allocator(), - point, - .{ .err = err, .min = min }, - ); + pub fn errorAfter(point: FailPoint, err: Error, min: usize) void { + tripwires.put(point, .{ .err = err, .min = min }); } /// Ends the tripwire session. This will raise an error if there /// were untripped error expectations. The reset mode specifies - /// whether memory is reset too. Memory is always reset, even if - /// this returns an error. + /// whether expectations are reset too. Expectations are always reset, + /// even if this returns an error. pub fn end(reset_mode: enum { reset, retain }) error{UntrippedError}!void { var untripped: bool = false; - for (tripwires.keys(), tripwires.values()) |key, entry| { - if (!entry.tripped) { - log.warn("untripped point={t}", .{key}); + var iter = tripwires.iterator(); + while (iter.next()) |entry| { + if (!entry.value.tripped) { + log.warn("untripped point={s}", .{@tagName(entry.key)}); untripped = true; } } - // We always reset memory before failing switch (reset_mode) { .reset => reset(), .retain => {}, @@ -231,10 +211,9 @@ pub fn module( if (untripped) return error.UntrippedError; } - /// Unset all the tripwires and free all allocated memory. You - /// should usually call `end` instead. + /// Unset all the tripwires. You should usually call `end` instead. pub fn reset() void { - tripwires.clearAndFree(alloc_state.allocator()); + tripwires = .{}; } /// Our calling convention is inline if our tripwire module is @@ -258,7 +237,7 @@ test { try io.check(.read); // Always trip - try io.errorAlways(.read, error.OutOfMemory); + io.errorAlways(.read, error.OutOfMemory); try testing.expectError( error.OutOfMemory, io.check(.read), @@ -283,7 +262,7 @@ test "module as error set" { test "errorAfter" { const io = module(enum { read, write }, anyerror); // Trip after 2 calls (on the 3rd call) - try io.errorAfter(.read, error.OutOfMemory, 2); + io.errorAfter(.read, error.OutOfMemory, 2); // First two calls succeed try io.check(.read); @@ -298,7 +277,7 @@ test "errorAfter" { test "errorAfter untripped error if min not reached" { const io = module(enum { read }, anyerror); - try io.errorAfter(.read, error.OutOfMemory, 2); + io.errorAfter(.read, error.OutOfMemory, 2); // Only call once, not enough to trip try io.check(.read); // end should fail because tripwire was set but never tripped From 3570c2b28fef31fb78c8c21df789809d07239cb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:16:06 +0000 Subject: [PATCH 569/605] build(deps): bump peter-evans/create-pull-request from 8.0.0 to 8.1.0 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 8.0.0 to 8.1.0. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/98357b18bf14b5342f975ff684046ec3b2a07725...c0f553fe549906ede9cf27b5156039d195d2ece0) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 8.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/update-colorschemes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 4ca4d2901..db57ca8b9 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -79,7 +79,7 @@ jobs: run: nix build .#ghostty - name: Create pull request - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: title: Update iTerm2 colorschemes base: main From 02d6dc06728b9e0738937a1fa432fdb2cf84b6ea Mon Sep 17 00:00:00 2001 From: MrConnorKenway Date: Wed, 21 Jan 2026 20:35:51 +0800 Subject: [PATCH 570/605] feat(macos): focus surface view if search box is manually closed --- macos/Sources/Ghostty/Surface View/SurfaceView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index a0e735715..c5c2ee97c 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -190,7 +190,12 @@ extension Ghostty { SurfaceSearchOverlay( surfaceView: surfaceView, searchState: searchState, - onClose: { surfaceView.searchState = nil } + onClose: { +#if canImport(AppKit) + Ghostty.moveFocus(to: surfaceView) +#endif + surfaceView.searchState = nil + } ) } @@ -432,7 +437,6 @@ extension Ghostty { #if canImport(AppKit) .onExitCommand { if searchState.needle.isEmpty { - Ghostty.moveFocus(to: surfaceView) onClose() } else { Ghostty.moveFocus(to: surfaceView) From 16a98f58314cbbcb4594e6579e708fe8aa6b7ecf Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 22 Jan 2026 11:08:29 -0800 Subject: [PATCH 571/605] editorconfig: 2-space indent for Nushell scripts This aligns with the Topiary format, which appears to be the most prominent community standard. https://github.com/blindFS/topiary-nushell --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 4e9bec6ce..b59747923 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ root = true -[*.{sh,bash,elv}] +[*.{sh,bash,elv,nu}] indent_size = 2 indent_style = space From f00de7ee4b9bbc5a0fb5f4c06755780c773a0f5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 22 Jan 2026 11:49:58 -0800 Subject: [PATCH 572/605] Updated AI usage policy for contributions Follow up to #8289 The rise of agentic programming has eliminated the natural effort-based backpressure that previously limited low-effort contributions. It is now too easy to create large amounts of bad content with minimal effort. Open source projects have always had poor quality issues, PRs, etc. That comes with the territory. Unfortunately, the ease and carelessness by which these are now manifested has increased the "bad" count by 10x if not more. It's ruining it for the rest of us. This policy is a result of the bad, and I'm sorry about it. **Going forward, AI generated contributions will only be allowed for accepted issues and maintainers.** Drive-by pull requests with AI generated content will be immediately closed. **Going further, users who contribute bad AI generated content will be immediately banned from all future contributions.** This is a zero-tolerance policy. If you use AI, you are responsible for the quality of your contributions. If you're using low-effort AI to create low-effort content, I have no human obligation to help you. If you are a junior developer who is really trying to learn and get better, then please put aside the AI, do your best, and I will still help. I want to help. But I expect effort and organic thinking in return. This is not an anti-AI stance. This is an anti-idiot stance. Ghostty is written with plenty of AI assistance and many of our maintainers use AI daily. We just want quality contributions, regardless of how they are made. --- AI_POLICY.md | 69 +++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 87 ++----------------------------------------------- 2 files changed, 72 insertions(+), 84 deletions(-) create mode 100644 AI_POLICY.md diff --git a/AI_POLICY.md b/AI_POLICY.md new file mode 100644 index 000000000..1ed0006d4 --- /dev/null +++ b/AI_POLICY.md @@ -0,0 +1,69 @@ +# AI Usage Policy + +The Ghostty project has strict rules for AI usage: + +- **All AI usage in any form must be disclosed.** You must state + the tool you used (e.g. Claude Code, Cursor, Amp) along with + the extent that the work was AI-assisted. + +- **Pull requests created in any way by AI can only be for accepted issues.** + Drive-by pull requests that do not reference an accepted issue will be + closed. If AI isn't disclosed but a maintainer suspects its use, the + PR will be closed. If you want to share code for a non-accepted issue, + open a discussion or attach it to an existing discussion. + +- **Pull requests created by AI must have been fully verified with + human use.** AI must not create hypothetically correct code that + hasn't been tested. Importantly, you must not allow AI to write + code for platforms or environments you don't have access to manually + test on. + +- **Issues and discussions can use AI assistance but must have a full + human-in-the-loop.** This means that any content generated with AI + must have been reviewed _and edited_ by a human before submission. + AI is very good at being overly verbose and including noise that + distracts from the main point. Humans must do their research and + trim this down. + +- **No AI-generated media is allowed (art, images, videos, audio, etc.).** + Text and code are the only acceptable AI-generated content, per the + other rules in this policy. + +- **Bad AI drivers will be banned and ridiculed in public.** You've + been warned. We love to help junior developers learn and grow, but + if you're interested in that then don't use AI, and we'll help you. + I'm sorry that bad AI drivers have ruined this for you. + +These rules apply only to outside contributions to Ghostty. Maintainers +are exempt from these rules and may use AI tools at their discretion; +they've proven themselves trustworthy to apply good judgment. + +## There are Humans Here + +Please remember that Ghostty is maintained by humans. + +Every discussion, issue, and pull request is read and reviewed by +humans (and sometimes machines, too). It is a boundary point at which +people interact with each other and the work done. It is rude and +disrespectful to approach this boundary with low-effort, unqualified +work, since it puts the burden of validation on the maintainer. + +In a perfect world, AI would produce high-quality, accurate work +every time. But today, that reality depends on the driver of the AI. +And today, most drivers of AI are just not good enough. So, until either +the people get better, the AI gets better, or both, we have to have +strict rules to protect maintainers. + +## AI is Welcome Here + +Ghostty is written with plenty of AI assistance, and many maintainers embrace +AI tools as a productive tool in their workflow. As a project, we welcome +AI as a tool! + +**Our reason for the strict AI policy is not due to an anti-AI stance**, but +instead due to the number of highly unqualified people using AI. It's the +people, not the tools, that are the problem. + +I include this section to be transparent about the project's usage about +AI for people who may disagree with it, and to address the misconception +that this policy is anti-AI in nature. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbb6927d6..41376765b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,91 +13,10 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. > time to fixing bugs, maintaining features, and reviewing code, I do kindly > ask you spend a few minutes reading this document. Thank you. ❤️ -## AI Assistance Notice +## AI Usage -> [!IMPORTANT] -> -> The Ghostty project allows AI-**assisted** _code contributions_, which -> must be properly disclosed in the pull request. - -If you are using any kind of AI assistance while contributing to Ghostty, -**this must be disclosed in the pull request**, along with the extent to -which AI assistance was used (e.g. docs only vs. code generation). - -The submitter must have also tested the pull request on all impacted -platforms, and it's **highly discouraged** to code for an unfamiliar platform -with AI assistance alone: if you only have a macOS machine, do **not** ask AI -to write the equivalent GTK code, and vice versa — someone else with more -expertise will eventually get to it and do it for you. - -> [!WARNING] -> **Note that AI _assistance_ does not equal AI _generation_**. We require -> a significant amount of human accountability, involvement and interaction -> even within AI-assisted contributions. Contributors are required to be able -> to understand the AI-assisted output, reason with it and answer critical -> questions about it. Should a PR see no visible human accountability and -> involvement, or it is so broken that it requires significant rework to be -> acceptable, **we reserve the right to close it without hesitation**. - -**In addition, we currently restrict AI assistance to code changes only.** -No AI-generated media, e.g. artwork, icons, videos and other assets is -allowed, as it goes against the methodology and ethos behind Ghostty. -While AI-assisted code can help with productive prototyping, creative -inspiration and even automated bugfinding, we have currently found zero -benefit to AI-generated assets. Instead, we are far more interested and -invested in funding professional work done by human designers and artists. -If you intend to submit AI-generated assets to Ghostty, sorry, -we are not interested. - -Likewise, all community interactions, including all comments on issues and -discussions and all PR titles and descriptions **must be composed by a human**. -Community moderators and Ghostty maintainers reserve the right to mark -AI-generated responses as spam or disruptive content, and ban users who have -been repeatedly caught relying entirely on LLMs during interactions. - -> [!NOTE] -> If your English isn't the best and you are currently relying on an LLM to -> translate your responses, don't fret — usually we maintainers will be able -> to understand your messages well enough. We'd like to encourage real humans -> to interact with each other more, and the positive impact of genuine, -> responsive yet imperfect human interaction more than makes up for any -> language barrier. -> -> Please write your responses yourself, to the best of your ability. -> If you do feel the need to polish your sentences, however, please use -> dedicated translation software rather than an LLM. -> -> We greatly appreciate it. Thank you. ❤️ - -Minor exceptions to this policy include trivial AI-generated tab completion -functionality, as it usually does not impact the quality of the code and -do not need to be disclosed, and commit titles and messages, which are often -generated by AI coding agents. - -An example disclosure: - -> This PR was written primarily by Claude Code. - -Or a more detailed disclosure: - -> I consulted ChatGPT to understand the codebase but the solution -> was fully authored manually by myself. - -An example of a **problematic** disclosure (not having tested all platforms): - -> I used Amp to code both macOS and GTK UIs, but I have not yet tested -> the GTK UI as I don't have a Linux setup. - -Failure to disclose this is first and foremost rude to the human operators -on the other end of the pull request, but it also makes it difficult to -determine how much scrutiny to apply to the contribution. - -In a perfect world, AI assistance would produce equal or higher quality -work than any human. That isn't the world we live in today, and in most cases -it's generating slop. I say this despite being a fan of and using them -successfully myself (with heavy supervision)! - -Please be respectful to maintainers and disclose AI assistance. +The Ghostty project has strict rules for AI usage. Please see +the [AI Usage Policy](AI_POLICY.md). **This is very important.** ## Quick Guide From 6d607f0f1f2cf086a04c841ccafbc6de6ce5b91c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:15:25 +0000 Subject: [PATCH 573/605] build(deps): bump actions/checkout from 6.0.1 to 6.0.2 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 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 | 8 +-- .github/workflows/release-tip.yml | 18 +++---- .github/workflows/test.yml | 62 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d992ba034..0fc662ad4 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -39,7 +39,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 960ff4efe..4d58f1128 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -132,7 +132,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: DeterminateSystems/nix-installer-action@main with: @@ -306,7 +306,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download macOS Artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 3af59e7a5..5ad0bac46 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -29,7 +29,7 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Important so that build number generation works fetch-depth: 0 @@ -66,7 +66,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -81,7 +81,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install sentry-cli run: | @@ -104,7 +104,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install sentry-cli run: | @@ -127,7 +127,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install sentry-cli run: | @@ -159,7 +159,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -217,7 +217,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Important so that build number generation works fetch-depth: 0 @@ -451,7 +451,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Important so that build number generation works fetch-depth: 0 @@ -635,7 +635,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 734b8d224..d4d18a5e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,7 +74,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -117,7 +117,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -150,7 +150,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -184,7 +184,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -228,7 +228,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -264,7 +264,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -293,7 +293,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -326,7 +326,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -372,7 +372,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -410,7 +410,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Trigger Snap workflow run: | @@ -428,7 +428,7 @@ jobs: needs: [build-dist, build-flatpak] steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Trigger Flatpak workflow run: | @@ -445,7 +445,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -488,7 +488,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -525,7 +525,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -596,7 +596,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get required Zig version id: zig @@ -643,7 +643,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -691,7 +691,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -726,7 +726,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -753,7 +753,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -790,7 +790,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -820,7 +820,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -850,7 +850,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -879,7 +879,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -906,7 +906,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -933,7 +933,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -960,7 +960,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -992,7 +992,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1019,7 +1019,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: @@ -1053,7 +1053,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1115,7 +1115,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 @@ -1154,7 +1154,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # # - name: Start SSH # run: | diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index db57ca8b9..101c2a156 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 From e1b82ff3981200fcd722734493b9ddc11ab82543 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 22 Jan 2026 22:06:39 -0600 Subject: [PATCH 574/605] osc: parse iTerm2 OSC 1337 extensions Add a framework for parsing iTerm2's OSC 1337 extensions. Implement a couple (`Copy` and `CurrentDir`) that map easily onto existing OSC commands. --- src/terminal/osc.zig | 17 +- src/terminal/osc/parsers.zig | 2 + src/terminal/osc/parsers/iterm2.zig | 435 ++++++++++++++++++++++++++++ 3 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 src/terminal/osc/parsers/iterm2.zig diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 3fccb2812..368da4afc 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -406,6 +406,7 @@ pub const Parser = struct { @"119", @"133", @"777", + @"1337", }; pub fn init(alloc: ?Allocator) Parser { @@ -663,8 +664,20 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"0", .@"133", + => switch (c) { + ';' => self.writeToFixed(), + '7' => self.state = .@"1337", + else => self.state = .invalid, + }, + + .@"1337", + => switch (c) { + ';' => self.writeToFixed(), + else => self.state = .invalid, + }, + + .@"0", .@"22", .@"777", .@"8", @@ -741,6 +754,8 @@ pub const Parser = struct { .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), .@"777" => parsers.rxvt_extension.parse(self, terminator_ch), + + .@"1337" => parsers.iterm2.parse(self, terminator_ch), }; } }; diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index 9c1c39b2c..f3028ec79 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -5,6 +5,7 @@ pub const change_window_title = @import("parsers/change_window_title.zig"); pub const clipboard_operation = @import("parsers/clipboard_operation.zig"); pub const color = @import("parsers/color.zig"); pub const hyperlink = @import("parsers/hyperlink.zig"); +pub const iterm2 = @import("parsers/iterm2.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); pub const mouse_shape = @import("parsers/mouse_shape.zig"); @@ -19,6 +20,7 @@ test { _ = clipboard_operation; _ = color; _ = hyperlink; + _ = iterm2; _ = kitty_color; _ = kitty_text_sizing; _ = mouse_shape; diff --git a/src/terminal/osc/parsers/iterm2.zig b/src/terminal/osc/parsers/iterm2.zig new file mode 100644 index 000000000..bd64977cf --- /dev/null +++ b/src/terminal/osc/parsers/iterm2.zig @@ -0,0 +1,435 @@ +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; +const simd = @import("../../../simd/main.zig"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_iterm2); + +const Key = enum { + AddAnnotation, + AddHiddenAnnotation, + Block, + Button, + ClearCapturedOutput, + ClearScrollback, + Copy, + CopyToClipboard, + CurrentDir, + CursorShape, + Custom, + Disinter, + EndCopy, + File, + FileEnd, + FilePart, + HighlightCursorLine, + MultipartFile, + OpenURL, + PopKeyLabels, + PushKeyLabels, + RemoteHost, + ReportCellSize, + ReportVariable, + RequestAttention, + RequestUpload, + SetBackgroundImageFile, + SetBadgeFormat, + SetColors, + SetKeyLabel, + SetMark, + SetProfile, + SetUserVar, + ShellIntegrationVersion, + StealFocus, + UnicodeVersion, +}; + +// Instead of using `std.meta.stringToEnum` we set up a StaticStringMap so +// that we can get ASCII case-insensitive lookups. +const Map = std.StaticStringMapWithEql(Key, std.ascii.eqlIgnoreCase); +const map: Map = .initComptime( + map: { + const fields = @typeInfo(Key).@"enum".fields; + var tmp: [fields.len]struct { [:0]const u8, Key } = undefined; + for (fields, 0..) |field, i| { + tmp[i] = .{ field.name, @enumFromInt(field.value) }; + } + break :map tmp; + }, +); + +/// Parse OSC 1337 +/// https://iterm2.com/documentation-escape-codes.html +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + assert(parser.state == .@"1337"); + + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + writer.writeByte(0) catch { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + + const key_str: [:0]u8, const value_: ?[:0]u8 = kv: { + const index = std.mem.indexOfScalar(u8, data, '=') orelse { + break :kv .{ data[0 .. data.len - 1 :0], null }; + }; + data[index] = 0; + break :kv .{ data[0..index :0], data[index + 1 .. data.len - 1 :0] }; + }; + + const key = map.get(key_str) orelse { + parser.command = .invalid; + return null; + }; + + switch (key) { + .Copy => { + var value = value_ orelse { + parser.command = .invalid; + return null; + }; + + // Sending a blank entry to clear the clipboard is an OSC 52-ism, + // make sure that is invalid here. + if (value.len == 0) { + parser.command = .invalid; + return null; + } + + // base64 value must be prefixed by a colon + if (value[0] != ':') { + parser.command = .invalid; + return null; + } + + value = value[1..value.len :0]; + + // Sending a blank entry to clear the clipboard is an OSC 52-ism, + // make sure that is invalid here. + if (value.len == 0) { + parser.command = .invalid; + return null; + } + + // Sending a '?' to query the clipboard is an OSC 52-ism, make sure + // that is invalid here. + if (value.len == 1 and value[0] == '?') { + parser.command = .invalid; + return null; + } + + // It would be better to check for valid base64 data here, but that + // would mean parsing the base64 data twice in the "normal" case. + + parser.command = .{ + .clipboard_contents = .{ + .kind = 'c', + .data = value, + }, + }; + return &parser.command; + }, + + .CurrentDir => { + const value = value_ orelse { + parser.command = .invalid; + return null; + }; + if (value.len == 0) { + parser.command = .invalid; + return null; + } + parser.command = .{ + .report_pwd = .{ + .value = value, + }, + }; + return &parser.command; + }, + + .AddAnnotation, + .AddHiddenAnnotation, + .Block, + .Button, + .ClearCapturedOutput, + .ClearScrollback, + .CopyToClipboard, + .CursorShape, + .Custom, + .Disinter, + .EndCopy, + .File, + .FileEnd, + .FilePart, + .HighlightCursorLine, + .MultipartFile, + .OpenURL, + .PopKeyLabels, + .PushKeyLabels, + .RemoteHost, + .ReportCellSize, + .ReportVariable, + .RequestAttention, + .RequestUpload, + .SetBackgroundImageFile, + .SetBadgeFormat, + .SetColors, + .SetKeyLabel, + .SetMark, + .SetProfile, + .SetUserVar, + .ShellIntegrationVersion, + .StealFocus, + .UnicodeVersion, + => { + log.debug("unimplemented OSC 1337: {t}", .{key}); + parser.command = .invalid; + return null; + }, + } + return &parser.command; +} + +test "OSC: 1337: test valid unimplemented key with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;SetBadgeFormat"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid unimplemented key with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;SetBadgeFormat="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid unimplemented key with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;SetBadgeFormat=abc123"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid key with lower case and with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;setbadgeformat"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid key with lower case and with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;setbadgeformat="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test valid key with lower case and with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;setbadgeformat=abc123"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test invalid key with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;BobrKurwa"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test invalid key with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;BobrKurwa="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test invalid key with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;BobrKurwa=abc123"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with only prefix colon" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=:"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with question mark" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=:?"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with non-empty value that is invalid base64" { + // For performance reasons, we don't check for valid base64 data + // right now. + return error.SkipZigTest; + + // const testing = std.testing; + + // var p: Parser = .init(testing.allocator); + // defer p.deinit(); + + // const input = "1337;Copy=:abc123"; + // for (input) |ch| p.next(ch); + + // try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with non-empty value that is valid base64 but not prefixed with a colon" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=YWJjMTIz"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test Copy with non-empty value that is valid base64" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;Copy=:YWJjMTIz"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual('c', cmd.clipboard_contents.kind); + try testing.expectEqualStrings("YWJjMTIz", cmd.clipboard_contents.data); +} + +test "OSC: 1337: test CurrentDir with no value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;CurrentDir"; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test CurrentDir with empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;CurrentDir="; + for (input) |ch| p.next(ch); + + try testing.expect(p.end('\x1b') == null); +} + +test "OSC: 1337: test CurrentDir with non-empty value" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "1337;CurrentDir=abc123"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .report_pwd); + try testing.expectEqualStrings("abc123", cmd.report_pwd.value); +} From 3b75d25e6c9b4f6f171edf4669fc3d05b3a9b45d Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Fri, 23 Jan 2026 08:48:16 -0500 Subject: [PATCH 575/605] fix typo --- src/font/shaper/harfbuzz.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 1ed0e9893..1cbbaaf8c 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1442,7 +1442,7 @@ test "shape Bengali ligatures with out of order vowels" { try testing.expectEqual(@as(u16, 0), cells[1].x); // Whereas CoreText puts everything all into the first cell (see the - // coresponding test), HarfBuzz splits into three clusters. + // corresponding test), HarfBuzz splits into three clusters. try testing.expectEqual(@as(u16, 1), cells[2].x); try testing.expectEqual(@as(u16, 1), cells[3].x); try testing.expectEqual(@as(u16, 2), cells[4].x); From f0b4e86ab55164347027eff262a9f932d4e62214 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 23 Jan 2026 12:09:18 -0600 Subject: [PATCH 576/605] gtk: add read-only indicator for surfaces Fixes: #9889 --- src/apprt/gtk/class/application.zig | 11 +++++++++- src/apprt/gtk/class/surface.zig | 34 +++++++++++++++++++++++++++++ src/apprt/gtk/css/style.css | 10 +++++++++ src/apprt/gtk/ui/1.2/surface.blp | 33 ++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 6a07cab84..403f94599 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -733,6 +733,7 @@ pub const Application = extern struct { .toggle_split_zoom => return Action.toggleSplitZoom(target), .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), + .readonly => return Action.setReadonly(target, value), .start_search => Action.startSearch(target, value), .end_search => Action.endSearch(target), @@ -753,7 +754,6 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, - .readonly, => { log.warn("unimplemented action={}", .{action}); return false; @@ -2678,6 +2678,15 @@ const Action = struct { } } + pub fn setReadonly(target: apprt.Target, value: apprt.Action.Value(.readonly)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().setReadonly(value); + }, + } + } + pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool { switch (target) { .app => { diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 5c3bf18b6..7a1aa4326 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -400,6 +400,25 @@ pub const Surface = extern struct { }, ); }; + + pub const readonly = struct { + pub const name = "readonly"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getReadonly, + }, + ), + }, + ); + }; }; pub const signals = struct { @@ -1106,6 +1125,20 @@ pub const Surface = extern struct { return true; } + /// Get the readonly state from the core surface. + pub fn getReadonly(self: *Self) bool { + const priv: *Private = self.private(); + const surface = priv.core_surface orelse return false; + return surface.readonly; + } + + /// Notify anyone interested that the readonly status has changed. + pub fn setReadonly(self: *Self, _: apprt.Action.Value(.readonly)) bool { + self.as(gobject.Object).notifyByPspec(properties.readonly.impl.param_spec); + + return true; + } + /// Key press event (press or release). /// /// At a high level, we want to construct an `input.KeyEvent` and @@ -3480,6 +3513,7 @@ pub const Surface = extern struct { properties.@"title-override".impl, properties.zoom.impl, properties.@"is-split".impl, + properties.readonly.impl, // For Gtk.Scrollable properties.hadjustment.impl, diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index f5491b7de..9c0f115f1 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -134,6 +134,16 @@ label.resize-overlay { border-style: solid; } +.surface .readonly_overlay { + /* Should be the equivalent of the following SwiftUI color: */ + /* Color(hue: 0.08, saturation: 0.5, brightness: 0.8) */ + color: hsl(25 50 75); + padding: 8px 8px 8px 8px; + margin: 8px 8px 8px 8px; + border-radius: 6px 6px 6px 6px; + outline-style: solid; + outline-width: 1px; +} /* * Command Palette */ diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index a594ba98f..dd6ded5de 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -71,6 +71,39 @@ Overlay terminal_page { } }; + [overlay] + Revealer { + reveal-child: bind template.readonly; + transition-type: crossfade; + transition-duration: 500; + // Revealers take up the full size, we need this to not capture events. + can-focus: false; + can-target: false; + focusable: false; + + Box readonly_overlay { + styles [ + "readonly_overlay", + ] + + // TODO: the tooltip doesn't actually work, but keep it here for now so + // that we can get the tooltip text translated. + has-tooltip: true; + tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."); + halign: end; + valign: start; + spacing: 6; + + Image { + icon-name: "changes-prevent-symbolic"; + } + + Label { + label: _("Read-only"); + } + } + } + [overlay] ProgressBar progress_bar_overlay { styles [ From d040c935e2cd9d3277aef85caf67ea958c7928e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:01:20 -0800 Subject: [PATCH 577/605] terminal/osc: boilerplate new OSC 133 parsing --- src/terminal/osc.zig | 7 +- src/terminal/osc/parsers.zig | 15 +-- src/terminal/osc/parsers/semantic_prompt.zig | 3 +- src/terminal/osc/parsers/semantic_prompt2.zig | 92 +++++++++++++++++++ src/terminal/stream.zig | 3 + 5 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/terminal/osc/parsers/semantic_prompt2.zig diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 368da4afc..65a1b1121 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -41,6 +41,9 @@ pub const Command = union(Key) { /// in the log. change_window_icon: [:0]const u8, + /// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md + semantic_prompt: parsers.semantic_prompt2.Command, + /// First do a fresh-line. Then start a new command, and enter prompt mode: /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed @@ -225,6 +228,7 @@ pub const Command = union(Key) { "invalid", "change_window_title", "change_window_icon", + "semantic_prompt", "prompt_start", "prompt_end", "end_of_input", @@ -469,6 +473,7 @@ pub const Parser = struct { .prompt_end, .prompt_start, .report_pwd, + .semantic_prompt, .show_desktop_notification, .kitty_text_sizing, => {}, @@ -751,7 +756,7 @@ pub const Parser = struct { .@"77" => null, - .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), + .@"133" => parsers.semantic_prompt2.parse(self, terminator_ch), .@"777" => parsers.rxvt_extension.parse(self, terminator_ch), diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index f3028ec79..d005bd4c0 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -13,19 +13,8 @@ pub const osc9 = @import("parsers/osc9.zig"); pub const report_pwd = @import("parsers/report_pwd.zig"); pub const rxvt_extension = @import("parsers/rxvt_extension.zig"); pub const semantic_prompt = @import("parsers/semantic_prompt.zig"); +pub const semantic_prompt2 = @import("parsers/semantic_prompt2.zig"); test { - _ = change_window_icon; - _ = change_window_title; - _ = clipboard_operation; - _ = color; - _ = hyperlink; - _ = iterm2; - _ = kitty_color; - _ = kitty_text_sizing; - _ = mouse_shape; - _ = osc9; - _ = report_pwd; - _ = rxvt_extension; - _ = semantic_prompt; + std.testing.refAllDecls(@This()); } diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 652fe34da..d7cfe7c35 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -1,7 +1,6 @@ +//! https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md const std = @import("std"); - const string_encoding = @import("../../../os/string_encoding.zig"); - const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig new file mode 100644 index 000000000..954c101a1 --- /dev/null +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -0,0 +1,92 @@ +//! https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +const std = @import("std"); +const Parser = @import("../../osc.zig").Parser; +const OSCCommand = @import("../../osc.zig").Command; + +const log = std.log.scoped(.osc_semantic_prompt); + +pub const Command = union(enum) { + fresh_line, + fresh_line_new_prompt: Options, +}; + +pub const Options = struct { + aid: ?[:0]const u8, + cl: ?Click, + // TODO: more + + pub const init: Options = .{ + .aid = null, + .click = null, + }; +}; + +pub const Click = enum { + line, + multiple, + conservative_vertical, + smart_vertical, +}; + +/// Parse OSC 133, semantic prompts +pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { + const writer = parser.writer orelse { + parser.state = .invalid; + return null; + }; + const data = writer.buffered(); + if (data.len == 0) { + parser.state = .invalid; + return null; + } + + parser.command = command: { + parse: switch (data[0]) { + 'L' => { + if (data.len > 1) break :parse; + break :command .{ .semantic_prompt = .fresh_line }; + }, + + else => {}, + } + + // Any fallthroughs are invalid + parser.state = .invalid; + return null; + }; + + return &parser.command; +} + +test "OSC 133: fresh_line" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;L"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line); +} + +test "OSC 133: fresh_line extra contents" { + const testing = std.testing; + + // Random + { + var p: Parser = .init(null); + const input = "133;Lol"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); + } + + // Options + { + var p: Parser = .init(null); + const input = "133;L;aid=foo"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); + } +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 4e1398d8d..d12c59ef3 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -2003,6 +2003,9 @@ pub fn Stream(comptime Handler: type) type { // ref: https://github.com/qwerasd205/asciinema-stats switch (cmd) { + // TODO + .semantic_prompt => {}, + .change_window_title => |title| { @branchHint(.likely); if (!std.unicode.utf8ValidateSlice(title)) { From 65c56c7c77be738034cc21200a092c69ca4a4281 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:10:51 -0800 Subject: [PATCH 578/605] terminal/osc: add 'A' --- src/terminal/osc/parsers/semantic_prompt2.zig | 251 +++++++++++++++++- 1 file changed, 239 insertions(+), 12 deletions(-) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index 954c101a1..62833f31a 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -17,8 +17,22 @@ pub const Options = struct { pub const init: Options = .{ .aid = null, - .click = null, + .cl = null, }; + + pub fn parse(self: *Options, it: *KVIterator) void { + while (it.next()) |kv| { + const key = kv.key orelse continue; + if (std.mem.eql(u8, key, "aid")) { + self.aid = kv.value; + } else if (std.mem.eql(u8, key, "cl")) cl: { + const value = kv.value orelse break :cl; + self.cl = std.meta.stringToEnum(Click, value); + } else { + log.info("OSC 133: unknown semantic prompt option: {s}", .{key}); + } + } + } }; pub const Click = enum { @@ -40,23 +54,108 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { return null; } - parser.command = command: { - parse: switch (data[0]) { - 'L' => { - if (data.len > 1) break :parse; - break :command .{ .semantic_prompt = .fresh_line }; + // All valid cases terminate within this block. Any fallthroughs + // are invalid. This makes some of our parse logic a little less + // repetitive. + valid: { + switch (data[0]) { + 'A' => fresh_line: { + parser.command = .{ .semantic_prompt = .{ .fresh_line_new_prompt = .init } }; + if (data.len == 1) break :fresh_line; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + parser.command.semantic_prompt.fresh_line_new_prompt.parse(&it); }, - else => {}, + 'L' => { + if (data.len > 1) break :valid; + parser.command = .{ .semantic_prompt = .fresh_line }; + }, + + else => break :valid, } - // Any fallthroughs are invalid - parser.state = .invalid; - return null; + return &parser.command; + } + // Any fallthroughs are invalid + parser.state = .invalid; + return null; +} + +const KVIterator = struct { + index: usize, + string: []u8, + + pub const KV = struct { + key: ?[:0]u8, + value: ?[:0]u8, + + pub const empty: KV = .{ + .key = null, + .value = null, + }; }; - return &parser.command; -} + pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!KVIterator { + // Add a semicolon to make it easier to find and sentinel terminate + // the values. + try writer.writeByte(';'); + return .{ + .index = 0, + .string = writer.buffered()[2..], + }; + } + + pub fn next(self: *KVIterator) ?KV { + if (self.index >= self.string.len) return null; + + const kv = kv: { + const index = std.mem.indexOfScalarPos( + u8, + self.string, + self.index, + ';', + ) orelse { + self.index = self.string.len; + return null; + }; + self.string[index] = 0; + const kv = self.string[self.index..index :0]; + self.index = index + 1; + break :kv kv; + }; + + // If we have an empty item, we return a null key and value. + // + // This allows for trailing semicolons, but also lets us parse + // (or rather, ignore) empty fields; for example `a=b;;e=f`. + if (kv.len < 1) return .empty; + + const key = key: { + const index = std.mem.indexOfScalar( + u8, + kv, + '=', + ) orelse { + // If there is no '=' return entire `kv` string as the key and + // a null value. + return .{ + .key = kv, + .value = null, + }; + }; + + kv[index] = 0; + break :key kv[0..index :0]; + }; + const value = kv[key.len + 1 .. :0]; + + return .{ + .key = key, + .value = value, + }; + } +}; test "OSC 133: fresh_line" { const testing = std.testing; @@ -90,3 +189,131 @@ test "OSC 133: fresh_line extra contents" { try testing.expect(p.end(null) == null); } } + +test "OSC 133: fresh_line_new_prompt" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.aid == null); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == null); +} + +test "OSC 133: fresh_line_new_prompt with aid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=14"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expectEqualStrings("14", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); +} + +test "OSC 133: fresh_line_new_prompt with '=' in aid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=a=b"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expectEqualStrings("a=b", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); +} + +test "OSC 133: fresh_line_new_prompt with cl=line" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;cl=line"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .line); +} + +test "OSC 133: fresh_line_new_prompt with cl=multiple" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;cl=multiple"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .multiple); +} + +test "OSC 133: fresh_line_new_prompt with invalid cl" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;cl=invalid"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == null); +} + +test "OSC 133: fresh_line_new_prompt with trailing ;" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); +} + +test "OSC 133: fresh_line_new_prompt with bare key" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;barekey"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.aid == null); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == null); +} + +test "OSC 133: fresh_line_new_prompt with multiple options" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;aid=foo;cl=line"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); + try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .line); +} From 7968358234ed74c4c3e1f02ebe70eb37159d7b46 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:23:30 -0800 Subject: [PATCH 579/605] terminal/osc: semantic prompt options --- src/terminal/osc/parsers/semantic_prompt2.zig | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index 62833f31a..e3cdb815f 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -13,11 +13,16 @@ pub const Command = union(enum) { pub const Options = struct { aid: ?[:0]const u8, cl: ?Click, - // TODO: more + prompt_kind: ?PromptKind, + exit_code: ?i32, + err: ?[:0]const u8, pub const init: Options = .{ .aid = null, .cl = null, + .prompt_kind = null, + .exit_code = null, + .err = null, }; pub fn parse(self: *Options, it: *KVIterator) void { @@ -28,6 +33,15 @@ pub const Options = struct { } else if (std.mem.eql(u8, key, "cl")) cl: { const value = kv.value orelse break :cl; self.cl = std.meta.stringToEnum(Click, value); + } else if (std.mem.eql(u8, key, "k")) k: { + const value = kv.value orelse break :k; + if (value.len != 1) break :k; + self.prompt_kind = .init(value[0]); + } else if (std.mem.eql(u8, key, "err")) { + self.err = kv.value; + } else if (key.len == 0) exit_code: { + const value = kv.value orelse break :exit_code; + self.exit_code = std.fmt.parseInt(i32, value, 10) catch break :exit_code; } else { log.info("OSC 133: unknown semantic prompt option: {s}", .{key}); } @@ -42,6 +56,23 @@ pub const Click = enum { smart_vertical, }; +pub const PromptKind = enum { + initial, + right, + continuation, + secondary, + + pub fn init(c: u8) ?PromptKind { + return switch (c) { + 'i' => .initial, + 'r' => .right, + 'c' => .continuation, + 's' => .secondary, + else => null, + }; + } +}; + /// Parse OSC 133, semantic prompts pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { const writer = parser.writer orelse { From 39c0f79b8da5f1b914fe450c200bacb448e2fc56 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:35:13 -0800 Subject: [PATCH 580/605] terminal/osc: semantic prompt 'P' --- src/terminal/osc/parsers/semantic_prompt2.zig | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index e3cdb815f..b46157692 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -8,6 +8,7 @@ const log = std.log.scoped(.osc_semantic_prompt); pub const Command = union(enum) { fresh_line, fresh_line_new_prompt: Options, + prompt_start: Options, }; pub const Options = struct { @@ -103,6 +104,14 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command = .{ .semantic_prompt = .fresh_line }; }, + 'P' => prompt_start: { + parser.command = .{ .semantic_prompt = .{ .prompt_start = .init } }; + if (data.len == 1) break :prompt_start; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + parser.command.semantic_prompt.prompt_start.parse(&it); + }, + else => break :valid, } @@ -348,3 +357,96 @@ test "OSC 133: fresh_line_new_prompt with multiple options" { try testing.expectEqualStrings("foo", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .line); } + +test "OSC 133: prompt_start" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;P"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .prompt_start); + try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == null); +} + +test "OSC 133: prompt_start with k=i" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;P;k=i"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .prompt_start); + try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .initial); +} + +test "OSC 133: prompt_start with k=r" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;P;k=r"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .prompt_start); + try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .right); +} + +test "OSC 133: prompt_start with k=c" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;P;k=c"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .prompt_start); + try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .continuation); +} + +test "OSC 133: prompt_start with k=s" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;P;k=s"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .prompt_start); + try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .secondary); +} + +test "OSC 133: prompt_start with invalid k" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;P;k=x"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .prompt_start); + try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == null); +} + +test "OSC 133: prompt_start extra contents" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;Pextra"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} From 0d9216bb5a06ff33e5aadbeea27bd3c63567f732 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:37:34 -0800 Subject: [PATCH 581/605] terminal/osc: semantic prompt 'N' --- src/terminal/osc/parsers/semantic_prompt2.zig | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index b46157692..890fe714b 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -8,6 +8,7 @@ const log = std.log.scoped(.osc_semantic_prompt); pub const Command = union(enum) { fresh_line, fresh_line_new_prompt: Options, + new_command: Options, prompt_start: Options, }; @@ -104,6 +105,14 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command = .{ .semantic_prompt = .fresh_line }; }, + 'N' => new_command: { + parser.command = .{ .semantic_prompt = .{ .new_command = .init } }; + if (data.len == 1) break :new_command; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + parser.command.semantic_prompt.new_command.parse(&it); + }, + 'P' => prompt_start: { parser.command = .{ .semantic_prompt = .{ .prompt_start = .init } }; if (data.len == 1) break :prompt_start; @@ -450,3 +459,70 @@ test "OSC 133: prompt_start extra contents" { for (input) |ch| p.next(ch); try testing.expect(p.end(null) == null); } + +test "OSC 133: new_command" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;N"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .new_command); + try testing.expect(cmd.semantic_prompt.new_command.aid == null); + try testing.expect(cmd.semantic_prompt.new_command.cl == null); +} + +test "OSC 133: new_command with aid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;N;aid=foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .new_command); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.new_command.aid.?); +} + +test "OSC 133: new_command with cl=line" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;N;cl=line"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .new_command); + try testing.expect(cmd.semantic_prompt.new_command.cl == .line); +} + +test "OSC 133: new_command with multiple options" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;N;aid=foo;cl=line"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .new_command); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.new_command.aid.?); + try testing.expect(cmd.semantic_prompt.new_command.cl == .line); +} + +test "OSC 133: new_command extra contents" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;Nextra"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} From fdc6a6b10a216af505f134cd7982bb83f228f124 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:39:32 -0800 Subject: [PATCH 582/605] terminal/osc: semantic prompt 'B' --- src/terminal/osc/parsers/semantic_prompt2.zig | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index 890fe714b..d2547abe4 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -10,6 +10,7 @@ pub const Command = union(enum) { fresh_line_new_prompt: Options, new_command: Options, prompt_start: Options, + end_prompt_start_input: Options, }; pub const Options = struct { @@ -100,6 +101,14 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command.semantic_prompt.fresh_line_new_prompt.parse(&it); }, + 'B' => end_prompt: { + parser.command = .{ .semantic_prompt = .{ .end_prompt_start_input = .init } }; + if (data.len == 1) break :end_prompt; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + parser.command.semantic_prompt.end_prompt_start_input.parse(&it); + }, + 'L' => { if (data.len > 1) break :valid; parser.command = .{ .semantic_prompt = .fresh_line }; @@ -526,3 +535,38 @@ test "OSC 133: new_command extra contents" { for (input) |ch| p.next(ch); try testing.expect(p.end(null) == null); } + +test "OSC 133: end_prompt_start_input" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;B"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_prompt_start_input); +} + +test "OSC 133: end_prompt_start_input extra contents" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;Bextra"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 133: end_prompt_start_input with options" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;B;aid=foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_prompt_start_input); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_prompt_start_input.aid.?); +} From 7421e78f1eacdd4a53feb8582f5eaeb11bc82b1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:42:23 -0800 Subject: [PATCH 583/605] terminal/osc: semantic prompt 'I' --- src/terminal/osc/parsers/semantic_prompt2.zig | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index d2547abe4..7dfc01b3e 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -11,6 +11,7 @@ pub const Command = union(enum) { new_command: Options, prompt_start: Options, end_prompt_start_input: Options, + end_prompt_start_input_terminate_eol: Options, }; pub const Options = struct { @@ -109,6 +110,14 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command.semantic_prompt.end_prompt_start_input.parse(&it); }, + 'I' => end_prompt_line: { + parser.command = .{ .semantic_prompt = .{ .end_prompt_start_input_terminate_eol = .init } }; + if (data.len == 1) break :end_prompt_line; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + parser.command.semantic_prompt.end_prompt_start_input_terminate_eol.parse(&it); + }, + 'L' => { if (data.len > 1) break :valid; parser.command = .{ .semantic_prompt = .fresh_line }; @@ -570,3 +579,38 @@ test "OSC 133: end_prompt_start_input with options" { try testing.expect(cmd.semantic_prompt == .end_prompt_start_input); try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_prompt_start_input.aid.?); } + +test "OSC 133: end_prompt_start_input_terminate_eol" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;I"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_prompt_start_input_terminate_eol); +} + +test "OSC 133: end_prompt_start_input_terminate_eol extra contents" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;Iextra"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 133: end_prompt_start_input_terminate_eol with options" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;I;aid=foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_prompt_start_input_terminate_eol); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_prompt_start_input_terminate_eol.aid.?); +} From 9d1282eb956e459de66f8fe1fe06bbe78312e04e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:44:34 -0800 Subject: [PATCH 584/605] terminal/osc: semantic prompt 'C' --- src/terminal/osc/parsers/semantic_prompt2.zig | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index 7dfc01b3e..d25f5485e 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -12,6 +12,7 @@ pub const Command = union(enum) { prompt_start: Options, end_prompt_start_input: Options, end_prompt_start_input_terminate_eol: Options, + end_input_start_output: Options, }; pub const Options = struct { @@ -118,6 +119,14 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command.semantic_prompt.end_prompt_start_input_terminate_eol.parse(&it); }, + 'C' => end_input: { + parser.command = .{ .semantic_prompt = .{ .end_input_start_output = .init } }; + if (data.len == 1) break :end_input; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + parser.command.semantic_prompt.end_input_start_output.parse(&it); + }, + 'L' => { if (data.len > 1) break :valid; parser.command = .{ .semantic_prompt = .fresh_line }; @@ -224,6 +233,43 @@ const KVIterator = struct { } }; +test "OSC 133: end_input_start_output" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;C"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_input_start_output); + try testing.expect(cmd.semantic_prompt.end_input_start_output.aid == null); + try testing.expect(cmd.semantic_prompt.end_input_start_output.cl == null); +} + +test "OSC 133: end_input_start_output extra contents" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;Cextra"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 133: end_input_start_output with options" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;C;aid=foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_input_start_output); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_input_start_output.aid.?); +} + test "OSC 133: fresh_line" { const testing = std.testing; From a9e23c135f1ea023e827f2a15b33f74bbe224789 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 13:47:27 -0800 Subject: [PATCH 585/605] terminal/osc: semantic prompt 'D' --- src/terminal/osc/parsers/semantic_prompt2.zig | 88 ++++++++++++++++++- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index d25f5485e..58d4b1835 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -13,15 +13,20 @@ pub const Command = union(enum) { end_prompt_start_input: Options, end_prompt_start_input_terminate_eol: Options, end_input_start_output: Options, + end_command: Options, }; pub const Options = struct { aid: ?[:0]const u8, cl: ?Click, prompt_kind: ?PromptKind, - exit_code: ?i32, err: ?[:0]const u8, + // Not technically an option that can be set with k=v and only + // present currently with command 'D' but its easier to just + // parse it into our options. + exit_code: ?i32, + pub const init: Options = .{ .aid = null, .cl = null, @@ -44,9 +49,6 @@ pub const Options = struct { self.prompt_kind = .init(value[0]); } else if (std.mem.eql(u8, key, "err")) { self.err = kv.value; - } else if (key.len == 0) exit_code: { - const value = kv.value orelse break :exit_code; - self.exit_code = std.fmt.parseInt(i32, value, 10) catch break :exit_code; } else { log.info("OSC 133: unknown semantic prompt option: {s}", .{key}); } @@ -127,6 +129,30 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command.semantic_prompt.end_input_start_output.parse(&it); }, + 'D' => end_command: { + parser.command = .{ .semantic_prompt = .{ .end_command = .init } }; + if (data.len == 1) break :end_command; + if (data[1] != ';') break :valid; + var it = KVIterator.init(writer) catch break :valid; + + // If there are options, the first option MUST be the + // exit code. The specification appears to mandate this + // and disallow options without an exit code. + { + const first = it.next() orelse break :end_command; + if (first.value != null) break :end_command; + const key = first.key orelse break :end_command; + parser.command.semantic_prompt.end_command.exit_code = std.fmt.parseInt( + i32, + key, + 10, + ) catch null; + } + + // Parse the remaining options + parser.command.semantic_prompt.end_command.parse(&it); + }, + 'L' => { if (data.len > 1) break :valid; parser.command = .{ .semantic_prompt = .fresh_line }; @@ -660,3 +686,57 @@ test "OSC 133: end_prompt_start_input_terminate_eol with options" { try testing.expect(cmd.semantic_prompt == .end_prompt_start_input_terminate_eol); try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_prompt_start_input_terminate_eol.aid.?); } + +test "OSC 133: end_command" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_command); + try testing.expect(cmd.semantic_prompt.end_command.exit_code == null); + try testing.expect(cmd.semantic_prompt.end_command.aid == null); + try testing.expect(cmd.semantic_prompt.end_command.err == null); +} + +test "OSC 133: end_command extra contents" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "133;Dextra"; + for (input) |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 133: end_command with exit code 0" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D;0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_command); + try testing.expect(cmd.semantic_prompt.end_command.exit_code == 0); +} + +test "OSC 133: end_command with exit code and aid" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;D;12;aid=foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt == .end_command); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_command.aid.?); + try testing.expect(cmd.semantic_prompt.end_command.exit_code == 12); +} From edafe8620388288b7af7b3042aca31916071e8d6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:03:44 -0800 Subject: [PATCH 586/605] terminal/osc: semantic prompt is a struct not tagged union --- src/terminal/osc/parsers/semantic_prompt2.zig | 202 ++++++++++-------- 1 file changed, 109 insertions(+), 93 deletions(-) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index 58d4b1835..b9752a9f4 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -5,15 +5,30 @@ const OSCCommand = @import("../../osc.zig").Command; const log = std.log.scoped(.osc_semantic_prompt); -pub const Command = union(enum) { - fresh_line, - fresh_line_new_prompt: Options, - new_command: Options, - prompt_start: Options, - end_prompt_start_input: Options, - end_prompt_start_input_terminate_eol: Options, - end_input_start_output: Options, - end_command: Options, +/// A single semantic prompt command. +/// +/// Technically according to the spec, not all commands have options +/// but it is easier to be "liberal in what we accept" here since +/// all except one do and the spec does also say to ignore unknown +/// options. So, I think this is a fair interpretation. +pub const Command = struct { + action: Action, + options: Options, + + pub const Action = enum { + fresh_line, // 'L' + fresh_line_new_prompt, // 'A' + new_command, // 'N' + prompt_start, // 'P' + end_prompt_start_input, // 'B' + end_prompt_start_input_terminate_eol, // 'I' + end_input_start_output, // 'C' + end_command, // 'D' + }; + + pub fn init(action: Action) Command { + return .{ .action = action, .options = .init }; + } }; pub const Options = struct { @@ -40,12 +55,12 @@ pub const Options = struct { const key = kv.key orelse continue; if (std.mem.eql(u8, key, "aid")) { self.aid = kv.value; - } else if (std.mem.eql(u8, key, "cl")) cl: { - const value = kv.value orelse break :cl; + } else if (std.mem.eql(u8, key, "cl")) { + const value = kv.value orelse continue; self.cl = std.meta.stringToEnum(Click, value); - } else if (std.mem.eql(u8, key, "k")) k: { - const value = kv.value orelse break :k; - if (value.len != 1) break :k; + } else if (std.mem.eql(u8, key, "k")) { + const value = kv.value orelse continue; + if (value.len != 1) continue; self.prompt_kind = .init(value[0]); } else if (std.mem.eql(u8, key, "err")) { self.err = kv.value; @@ -98,39 +113,39 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { valid: { switch (data[0]) { 'A' => fresh_line: { - parser.command = .{ .semantic_prompt = .{ .fresh_line_new_prompt = .init } }; + parser.command = .{ .semantic_prompt = .init(.fresh_line_new_prompt) }; if (data.len == 1) break :fresh_line; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.fresh_line_new_prompt.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, 'B' => end_prompt: { - parser.command = .{ .semantic_prompt = .{ .end_prompt_start_input = .init } }; + parser.command = .{ .semantic_prompt = .init(.end_prompt_start_input) }; if (data.len == 1) break :end_prompt; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.end_prompt_start_input.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, 'I' => end_prompt_line: { - parser.command = .{ .semantic_prompt = .{ .end_prompt_start_input_terminate_eol = .init } }; + parser.command = .{ .semantic_prompt = .init(.end_prompt_start_input_terminate_eol) }; if (data.len == 1) break :end_prompt_line; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.end_prompt_start_input_terminate_eol.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, 'C' => end_input: { - parser.command = .{ .semantic_prompt = .{ .end_input_start_output = .init } }; + parser.command = .{ .semantic_prompt = .init(.end_input_start_output) }; if (data.len == 1) break :end_input; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.end_input_start_output.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, 'D' => end_command: { - parser.command = .{ .semantic_prompt = .{ .end_command = .init } }; + parser.command = .{ .semantic_prompt = .init(.end_command) }; if (data.len == 1) break :end_command; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; @@ -142,7 +157,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { const first = it.next() orelse break :end_command; if (first.value != null) break :end_command; const key = first.key orelse break :end_command; - parser.command.semantic_prompt.end_command.exit_code = std.fmt.parseInt( + parser.command.semantic_prompt.options.exit_code = std.fmt.parseInt( i32, key, 10, @@ -150,28 +165,28 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { } // Parse the remaining options - parser.command.semantic_prompt.end_command.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, 'L' => { if (data.len > 1) break :valid; - parser.command = .{ .semantic_prompt = .fresh_line }; + parser.command = .{ .semantic_prompt = .init(.fresh_line) }; }, 'N' => new_command: { - parser.command = .{ .semantic_prompt = .{ .new_command = .init } }; + parser.command = .{ .semantic_prompt = .init(.new_command) }; if (data.len == 1) break :new_command; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.new_command.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, 'P' => prompt_start: { - parser.command = .{ .semantic_prompt = .{ .prompt_start = .init } }; + parser.command = .{ .semantic_prompt = .init(.prompt_start) }; if (data.len == 1) break :prompt_start; if (data[1] != ';') break :valid; var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.prompt_start.parse(&it); + parser.command.semantic_prompt.options.parse(&it); }, else => break :valid, @@ -179,6 +194,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { return &parser.command; } + // Any fallthroughs are invalid parser.state = .invalid; return null; @@ -269,9 +285,9 @@ test "OSC 133: end_input_start_output" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_input_start_output); - try testing.expect(cmd.semantic_prompt.end_input_start_output.aid == null); - try testing.expect(cmd.semantic_prompt.end_input_start_output.cl == null); + try testing.expect(cmd.semantic_prompt.action == .end_input_start_output); + try testing.expect(cmd.semantic_prompt.options.aid == null); + try testing.expect(cmd.semantic_prompt.options.cl == null); } test "OSC 133: end_input_start_output extra contents" { @@ -292,8 +308,8 @@ test "OSC 133: end_input_start_output with options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_input_start_output); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_input_start_output.aid.?); + try testing.expect(cmd.semantic_prompt.action == .end_input_start_output); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); } test "OSC 133: fresh_line" { @@ -306,7 +322,7 @@ test "OSC 133: fresh_line" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line); + try testing.expect(cmd.semantic_prompt.action == .fresh_line); } test "OSC 133: fresh_line extra contents" { @@ -339,9 +355,9 @@ test "OSC 133: fresh_line_new_prompt" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.aid == null); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == null); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.aid == null); + try testing.expect(cmd.semantic_prompt.options.cl == null); } test "OSC 133: fresh_line_new_prompt with aid" { @@ -354,8 +370,8 @@ test "OSC 133: fresh_line_new_prompt with aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expectEqualStrings("14", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expectEqualStrings("14", cmd.semantic_prompt.options.aid.?); } test "OSC 133: fresh_line_new_prompt with '=' in aid" { @@ -368,8 +384,8 @@ test "OSC 133: fresh_line_new_prompt with '=' in aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expectEqualStrings("a=b", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expectEqualStrings("a=b", cmd.semantic_prompt.options.aid.?); } test "OSC 133: fresh_line_new_prompt with cl=line" { @@ -382,8 +398,8 @@ test "OSC 133: fresh_line_new_prompt with cl=line" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .line); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.cl == .line); } test "OSC 133: fresh_line_new_prompt with cl=multiple" { @@ -396,8 +412,8 @@ test "OSC 133: fresh_line_new_prompt with cl=multiple" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .multiple); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.cl == .multiple); } test "OSC 133: fresh_line_new_prompt with invalid cl" { @@ -410,8 +426,8 @@ test "OSC 133: fresh_line_new_prompt with invalid cl" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == null); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.cl == null); } test "OSC 133: fresh_line_new_prompt with trailing ;" { @@ -424,7 +440,7 @@ test "OSC 133: fresh_line_new_prompt with trailing ;" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); } test "OSC 133: fresh_line_new_prompt with bare key" { @@ -437,9 +453,9 @@ test "OSC 133: fresh_line_new_prompt with bare key" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.aid == null); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == null); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.aid == null); + try testing.expect(cmd.semantic_prompt.options.cl == null); } test "OSC 133: fresh_line_new_prompt with multiple options" { @@ -452,9 +468,9 @@ test "OSC 133: fresh_line_new_prompt with multiple options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .fresh_line_new_prompt); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.fresh_line_new_prompt.aid.?); - try testing.expect(cmd.semantic_prompt.fresh_line_new_prompt.cl == .line); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expect(cmd.semantic_prompt.options.cl == .line); } test "OSC 133: prompt_start" { @@ -467,8 +483,8 @@ test "OSC 133: prompt_start" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .prompt_start); - try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == null); + try testing.expect(cmd.semantic_prompt.action == .prompt_start); + try testing.expect(cmd.semantic_prompt.options.prompt_kind == null); } test "OSC 133: prompt_start with k=i" { @@ -481,8 +497,8 @@ test "OSC 133: prompt_start with k=i" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .prompt_start); - try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .initial); + try testing.expect(cmd.semantic_prompt.action == .prompt_start); + try testing.expect(cmd.semantic_prompt.options.prompt_kind == .initial); } test "OSC 133: prompt_start with k=r" { @@ -495,8 +511,8 @@ test "OSC 133: prompt_start with k=r" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .prompt_start); - try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .right); + try testing.expect(cmd.semantic_prompt.action == .prompt_start); + try testing.expect(cmd.semantic_prompt.options.prompt_kind == .right); } test "OSC 133: prompt_start with k=c" { @@ -509,8 +525,8 @@ test "OSC 133: prompt_start with k=c" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .prompt_start); - try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .continuation); + try testing.expect(cmd.semantic_prompt.action == .prompt_start); + try testing.expect(cmd.semantic_prompt.options.prompt_kind == .continuation); } test "OSC 133: prompt_start with k=s" { @@ -523,8 +539,8 @@ test "OSC 133: prompt_start with k=s" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .prompt_start); - try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == .secondary); + try testing.expect(cmd.semantic_prompt.action == .prompt_start); + try testing.expect(cmd.semantic_prompt.options.prompt_kind == .secondary); } test "OSC 133: prompt_start with invalid k" { @@ -537,8 +553,8 @@ test "OSC 133: prompt_start with invalid k" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .prompt_start); - try testing.expect(cmd.semantic_prompt.prompt_start.prompt_kind == null); + try testing.expect(cmd.semantic_prompt.action == .prompt_start); + try testing.expect(cmd.semantic_prompt.options.prompt_kind == null); } test "OSC 133: prompt_start extra contents" { @@ -560,9 +576,9 @@ test "OSC 133: new_command" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .new_command); - try testing.expect(cmd.semantic_prompt.new_command.aid == null); - try testing.expect(cmd.semantic_prompt.new_command.cl == null); + try testing.expect(cmd.semantic_prompt.action == .new_command); + try testing.expect(cmd.semantic_prompt.options.aid == null); + try testing.expect(cmd.semantic_prompt.options.cl == null); } test "OSC 133: new_command with aid" { @@ -575,8 +591,8 @@ test "OSC 133: new_command with aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .new_command); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.new_command.aid.?); + try testing.expect(cmd.semantic_prompt.action == .new_command); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); } test "OSC 133: new_command with cl=line" { @@ -589,8 +605,8 @@ test "OSC 133: new_command with cl=line" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .new_command); - try testing.expect(cmd.semantic_prompt.new_command.cl == .line); + try testing.expect(cmd.semantic_prompt.action == .new_command); + try testing.expect(cmd.semantic_prompt.options.cl == .line); } test "OSC 133: new_command with multiple options" { @@ -603,9 +619,9 @@ test "OSC 133: new_command with multiple options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .new_command); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.new_command.aid.?); - try testing.expect(cmd.semantic_prompt.new_command.cl == .line); + try testing.expect(cmd.semantic_prompt.action == .new_command); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expect(cmd.semantic_prompt.options.cl == .line); } test "OSC 133: new_command extra contents" { @@ -627,7 +643,7 @@ test "OSC 133: end_prompt_start_input" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_prompt_start_input); + try testing.expect(cmd.semantic_prompt.action == .end_prompt_start_input); } test "OSC 133: end_prompt_start_input extra contents" { @@ -648,8 +664,8 @@ test "OSC 133: end_prompt_start_input with options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_prompt_start_input); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_prompt_start_input.aid.?); + try testing.expect(cmd.semantic_prompt.action == .end_prompt_start_input); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); } test "OSC 133: end_prompt_start_input_terminate_eol" { @@ -662,7 +678,7 @@ test "OSC 133: end_prompt_start_input_terminate_eol" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_prompt_start_input_terminate_eol); + try testing.expect(cmd.semantic_prompt.action == .end_prompt_start_input_terminate_eol); } test "OSC 133: end_prompt_start_input_terminate_eol extra contents" { @@ -683,8 +699,8 @@ test "OSC 133: end_prompt_start_input_terminate_eol with options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_prompt_start_input_terminate_eol); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_prompt_start_input_terminate_eol.aid.?); + try testing.expect(cmd.semantic_prompt.action == .end_prompt_start_input_terminate_eol); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); } test "OSC 133: end_command" { @@ -697,10 +713,10 @@ test "OSC 133: end_command" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_command); - try testing.expect(cmd.semantic_prompt.end_command.exit_code == null); - try testing.expect(cmd.semantic_prompt.end_command.aid == null); - try testing.expect(cmd.semantic_prompt.end_command.err == null); + try testing.expect(cmd.semantic_prompt.action == .end_command); + try testing.expect(cmd.semantic_prompt.options.exit_code == null); + try testing.expect(cmd.semantic_prompt.options.aid == null); + try testing.expect(cmd.semantic_prompt.options.err == null); } test "OSC 133: end_command extra contents" { @@ -722,8 +738,8 @@ test "OSC 133: end_command with exit code 0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_command); - try testing.expect(cmd.semantic_prompt.end_command.exit_code == 0); + try testing.expect(cmd.semantic_prompt.action == .end_command); + try testing.expect(cmd.semantic_prompt.options.exit_code == 0); } test "OSC 133: end_command with exit code and aid" { @@ -736,7 +752,7 @@ test "OSC 133: end_command with exit code and aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); - try testing.expect(cmd.semantic_prompt == .end_command); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.end_command.aid.?); - try testing.expect(cmd.semantic_prompt.end_command.exit_code == 12); + try testing.expect(cmd.semantic_prompt.action == .end_command); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expect(cmd.semantic_prompt.options.exit_code == 12); } From 9f2808ce4052d02df497e08a363473f7ec6fbac6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:09:24 -0800 Subject: [PATCH 587/605] terminal: stream handles new SemanticPrompt type --- src/terminal/osc.zig | 4 ++- src/terminal/stream.zig | 9 +++++-- src/terminal/stream_readonly.zig | 34 +++++++++++++++++++++++++ src/termio/stream_handler.zig | 43 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 65a1b1121..aec0b495d 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -42,7 +42,7 @@ pub const Command = union(Key) { change_window_icon: [:0]const u8, /// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md - semantic_prompt: parsers.semantic_prompt2.Command, + semantic_prompt: SemanticPrompt, /// First do a fresh-line. Then start a new command, and enter prompt mode: /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a @@ -221,6 +221,8 @@ pub const Command = union(Key) { /// Kitty text sizing protocol (OSC 66) kitty_text_sizing: parsers.kitty_text_sizing.OSC, + pub const SemanticPrompt = parsers.semantic_prompt2.Command; + pub const Key = LibEnum( if (build_options.c_abi) .c else .zig, // NOTE: Order matters, see LibEnum documentation. diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d12c59ef3..5d4a37c43 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -130,6 +130,7 @@ pub const Action = union(Key) { set_attribute: sgr.Attribute, kitty_color_report: kitty.color.OSC, color_operation: ColorOperation, + semantic_prompt: SemanticPrompt, pub const Key = lib.Enum( lib_target, @@ -231,6 +232,7 @@ pub const Action = union(Key) { "set_attribute", "kitty_color_report", "color_operation", + "semantic_prompt", }, ); @@ -448,6 +450,8 @@ pub const Action = union(Key) { return {}; } }; + + pub const SemanticPrompt = osc.Command.SemanticPrompt; }; /// Returns a type that can process a stream of tty control characters. @@ -2003,8 +2007,9 @@ pub fn Stream(comptime Handler: type) type { // ref: https://github.com/qwerasd205/asciinema-stats switch (cmd) { - // TODO - .semantic_prompt => {}, + .semantic_prompt => |sp| { + try self.handler.vt(.semantic_prompt, sp); + }, .change_window_title => |title| { @branchHint(.likely); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 90fcead93..86879c0d5 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -161,6 +161,7 @@ pub const Handler = struct { .prompt_end => self.terminal.markSemanticPrompt(.input), .end_of_input => self.terminal.markSemanticPrompt(.command), .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, + .semantic_prompt => self.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), @@ -216,6 +217,39 @@ pub const Handler = struct { } } + fn semanticPrompt( + self: *Handler, + cmd: Action.SemanticPrompt, + ) void { + switch (cmd.action) { + .fresh_line => { + if (self.terminal.screens.active.cursor.x != 0) { + self.terminal.carriageReturn(); + self.terminal.index() catch {}; + } + }, + .fresh_line_new_prompt => { + if (self.terminal.screens.active.cursor.x != 0) { + self.terminal.carriageReturn(); + self.terminal.index() catch {}; + } + self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; + }, + .new_command => {}, + .prompt_start => { + const kind = cmd.options.prompt_kind orelse .initial; + switch (kind) { + .initial, .right => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt, + .continuation, .secondary => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, + } + }, + .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), + .end_prompt_start_input_terminate_eol => self.terminal.markSemanticPrompt(.input), + .end_input_start_output => self.terminal.markSemanticPrompt(.command), + .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, + } + } + fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { // Set the mode on the terminal self.terminal.modes.set(mode, enabled); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 2a2b338a4..29ffefbda 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -325,6 +325,7 @@ pub const StreamHandler = struct { .prompt_start => self.promptStart(value.aid, value.redraw), .prompt_continuation => self.promptContinuation(value.aid), .end_of_command => self.endOfCommand(value.exit_code), + .semantic_prompt => self.semanticPrompt(value), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), .set_attribute => { @@ -1094,6 +1095,48 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .stop_command = exit_code }); } + fn semanticPrompt( + self: *StreamHandler, + cmd: Stream.Action.SemanticPrompt, + ) void { + switch (cmd.action) { + .fresh_line => { + if (self.terminal.screens.active.cursor.x != 0) { + self.terminal.carriageReturn(); + self.terminal.index() catch {}; + } + }, + .fresh_line_new_prompt => { + if (self.terminal.screens.active.cursor.x != 0) { + self.terminal.carriageReturn(); + self.terminal.index() catch {}; + } + self.promptStart(cmd.options.aid, false); + }, + .new_command => {}, + .prompt_start => { + const kind = cmd.options.prompt_kind orelse .initial; + switch (kind) { + .initial, .right => self.promptStart(cmd.options.aid, false), + .continuation, .secondary => self.promptContinuation(cmd.options.aid), + } + }, + .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), + .end_prompt_start_input_terminate_eol => self.terminal.markSemanticPrompt(.input), + .end_input_start_output => { + self.terminal.markSemanticPrompt(.command); + self.surfaceMessageWriter(.start_command); + }, + .end_command => { + const exit_code: ?u8 = if (cmd.options.exit_code) |code| + if (code >= 0 and code <= 255) @intCast(code) else null + else + null; + self.surfaceMessageWriter(.{ .stop_command = exit_code }); + }, + } + } + fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any From 6ce45fb65a07007384e28501ecb7390923a99378 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:19:23 -0800 Subject: [PATCH 588/605] terminal/osc: semantic prompt redraw option from Kitty --- src/terminal/osc/parsers/semantic_prompt2.zig | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index b9752a9f4..5ab615713 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -37,6 +37,13 @@ pub const Options = struct { prompt_kind: ?PromptKind, err: ?[:0]const u8, + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + // Kitty supports a "redraw" option for prompt_start. I can't find + // this documented anywhere but can see in the code that this is used + // by shell environments to tell the terminal that the shell will NOT + // redraw the prompt so we should attempt to resize it. + redraw: bool, + // Not technically an option that can be set with k=v and only // present currently with command 'D' but its easier to just // parse it into our options. @@ -48,6 +55,7 @@ pub const Options = struct { .prompt_kind = null, .exit_code = null, .err = null, + .redraw = false, }; pub fn parse(self: *Options, it: *KVIterator) void { @@ -64,6 +72,14 @@ pub const Options = struct { self.prompt_kind = .init(value[0]); } else if (std.mem.eql(u8, key, "err")) { self.err = kv.value; + } else if (std.mem.eql(u8, key, "redraw")) redraw: { + const value = kv.value orelse break :redraw; + if (value.len != 1) break :redraw; + self.redraw = switch (value[0]) { + '0' => false, + '1' => true, + else => break :redraw, + }; } else { log.info("OSC 133: unknown semantic prompt option: {s}", .{key}); } @@ -473,6 +489,62 @@ test "OSC 133: fresh_line_new_prompt with multiple options" { try testing.expect(cmd.semantic_prompt.options.cl == .line); } +test "OSC 133: fresh_line_new_prompt default redraw" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.redraw == true); +} + +test "OSC 133: fresh_line_new_prompt with redraw=0" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.redraw == false); +} + +test "OSC 133: fresh_line_new_prompt with redraw=1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.redraw == true); +} + +test "OSC 133: fresh_line_new_prompt with invalid redraw" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "133;A;redraw=x"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .semantic_prompt); + try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); + try testing.expect(cmd.semantic_prompt.options.redraw == true); +} + test "OSC 133: prompt_start" { const testing = std.testing; From 389439b167a0c2ec79c37f9ace003d8f926a19e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:25:46 -0800 Subject: [PATCH 589/605] terminal: handle semantic prompt same as old --- src/terminal/stream_readonly.zig | 35 ++++++++++++----------- src/termio/stream_handler.zig | 49 ++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 86879c0d5..ba2aec6f9 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -222,31 +222,32 @@ pub const Handler = struct { cmd: Action.SemanticPrompt, ) void { switch (cmd.action) { - .fresh_line => { - if (self.terminal.screens.active.cursor.x != 0) { - self.terminal.carriageReturn(); - self.terminal.index() catch {}; - } - }, .fresh_line_new_prompt => { - if (self.terminal.screens.active.cursor.x != 0) { - self.terminal.carriageReturn(); - self.terminal.index() catch {}; - } - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; - }, - .new_command => {}, - .prompt_start => { const kind = cmd.options.prompt_kind orelse .initial; switch (kind) { - .initial, .right => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt, - .continuation, .secondary => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, + .initial, .right => { + self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; + self.terminal.flags.shell_redraws_prompt = cmd.options.redraw; + }, + .continuation, .secondary => { + self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation; + }, } }, + .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), - .end_prompt_start_input_terminate_eol => self.terminal.markSemanticPrompt(.input), .end_input_start_output => self.terminal.markSemanticPrompt(.command), .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, + + // All of these commands weren't previously handled by our + // semantic prompt code. I am PR-ing the parser separate from the + // handling so we just ignore these like we did before, even + // though we should handle them eventually. + .end_prompt_start_input_terminate_eol, + .fresh_line, + .new_command, + .prompt_start, + => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 29ffefbda..1bf22ff55 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1100,40 +1100,45 @@ pub const StreamHandler = struct { cmd: Stream.Action.SemanticPrompt, ) void { switch (cmd.action) { - .fresh_line => { - if (self.terminal.screens.active.cursor.x != 0) { - self.terminal.carriageReturn(); - self.terminal.index() catch {}; - } - }, .fresh_line_new_prompt => { - if (self.terminal.screens.active.cursor.x != 0) { - self.terminal.carriageReturn(); - self.terminal.index() catch {}; - } - self.promptStart(cmd.options.aid, false); - }, - .new_command => {}, - .prompt_start => { const kind = cmd.options.prompt_kind orelse .initial; switch (kind) { - .initial, .right => self.promptStart(cmd.options.aid, false), - .continuation, .secondary => self.promptContinuation(cmd.options.aid), + .initial, .right => { + self.terminal.markSemanticPrompt(.prompt); + self.terminal.flags.shell_redraws_prompt = cmd.options.redraw; + }, + .continuation, .secondary => { + self.terminal.markSemanticPrompt(.prompt_continuation); + }, } }, + .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), - .end_prompt_start_input_terminate_eol => self.terminal.markSemanticPrompt(.input), .end_input_start_output => { self.terminal.markSemanticPrompt(.command); self.surfaceMessageWriter(.start_command); }, .end_command => { - const exit_code: ?u8 = if (cmd.options.exit_code) |code| - if (code >= 0 and code <= 255) @intCast(code) else null - else - null; - self.surfaceMessageWriter(.{ .stop_command = exit_code }); + // The specification seems to not specify the type but + // other terminals accept 32-bits, but exit codes are really + // bytes, so we just do our best here. + const code: u8 = code: { + const raw: i32 = cmd.options.exit_code orelse 0; + break :code std.math.cast(u8, raw) orelse 1; + }; + + self.surfaceMessageWriter(.{ .stop_command = code }); }, + + // All of these commands weren't previously handled by our + // semantic prompt code. I am PR-ing the parser separate from the + // handling so we just ignore these like we did before, even + // though we should handle them eventually. + .end_prompt_start_input_terminate_eol, + .fresh_line, + .new_command, + .prompt_start, + => {}, } } From d23722dbd7a670665553a0b1e67376203e8485ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:34:24 -0800 Subject: [PATCH 590/605] terminal: remove old semantic prompt handling --- src/terminal/osc.zig | 81 +- src/terminal/osc/parsers.zig | 3 +- src/terminal/osc/parsers/osc9.zig | 8 +- src/terminal/osc/parsers/semantic_prompt.zig | 770 ------------------- src/terminal/stream.zig | 81 +- src/terminal/stream_readonly.zig | 8 - src/termio/stream_handler.zig | 29 - 7 files changed, 9 insertions(+), 971 deletions(-) delete mode 100644 src/terminal/osc/parsers/semantic_prompt.zig diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index aec0b495d..b9061e2e9 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -44,75 +44,6 @@ pub const Command = union(Key) { /// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md semantic_prompt: SemanticPrompt, - /// First do a fresh-line. Then start a new command, and enter prompt mode: - /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a - /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed - /// not all shells will send the prompt end code. - prompt_start: struct { - /// "aid" is an optional "application identifier" that helps disambiguate - /// nested shell sessions. It can be anything but is usually a process ID. - aid: ?[:0]const u8 = null, - /// "kind" tells us which kind of semantic prompt sequence this is: - /// - primary: normal, left-aligned first-line prompt (initial, default) - /// - continuation: an editable continuation line - /// - secondary: a non-editable continuation line - /// - right: a right-aligned prompt that may need adjustment during reflow - kind: enum { primary, continuation, secondary, right } = .primary, - /// If true, the shell will not redraw the prompt on resize so don't erase it. - /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - redraw: bool = true, - /// Use a special key instead of arrow keys to move the cursor on - /// mouse click. Useful if arrow keys have side-effets like triggering - /// auto-complete. The shell integration script should bind the special - /// key as needed. - /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - special_key: bool = false, - /// If true, the shell is capable of handling mouse click events. - /// Ghostty will then send a click event to the shell when the user - /// clicks somewhere in the prompt. The shell can then move the cursor - /// to that position or perform some other appropriate action. If false, - /// Ghostty may generate a number of fake key events to move the cursor - /// which is not very robust. - /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - click_events: bool = false, - }, - - /// End of prompt and start of user input, terminated by a OSC "133;C" - /// or another prompt (OSC "133;P"). - prompt_end: void, - - /// The OSC "133;C" command can be used to explicitly end - /// the input area and begin the output area. However, some applications - /// don't provide a convenient way to emit that command. - /// That is why we also specify an implicit way to end the input area - /// at the end of the line. In the case of multiple input lines: If the - /// cursor is on a fresh (empty) line and we see either OSC "133;P" or - /// OSC "133;I" then this is the start of a continuation input line. - /// If we see anything else, it is the start of the output area (or end - /// of command). - end_of_input: struct { - /// The command line that the user entered. - /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - cmdline: ?[:0]const u8 = null, - }, - - /// End of current command. - /// - /// The exit-code need not be specified if there are no options, - /// or if the command was cancelled (no OSC "133;C"), such as by typing - /// an interrupt/cancel character (typically ctrl-C) during line-editing. - /// Otherwise, it must be an integer code, where 0 means the command - /// succeeded, and other values indicate failure. In additing to the - /// exit-code there may be an err= option, which non-legacy terminals - /// should give precedence to. The err=_value_ option is more general: - /// an empty string is success, and any non-empty value (which need not - /// be an integer) is an error code. So to indicate success both ways you - /// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter. - end_of_command: struct { - exit_code: ?u8 = null, - // TODO: err option - }, - /// Set or get clipboard contents. If data is null, then the current /// clipboard contents are sent to the pty. If data is set, this /// contents is set on the clipboard. @@ -221,7 +152,7 @@ pub const Command = union(Key) { /// Kitty text sizing protocol (OSC 66) kitty_text_sizing: parsers.kitty_text_sizing.OSC, - pub const SemanticPrompt = parsers.semantic_prompt2.Command; + pub const SemanticPrompt = parsers.semantic_prompt.Command; pub const Key = LibEnum( if (build_options.c_abi) .c else .zig, @@ -231,10 +162,6 @@ pub const Command = union(Key) { "change_window_title", "change_window_icon", "semantic_prompt", - "prompt_start", - "prompt_end", - "end_of_input", - "end_of_command", "clipboard_contents", "report_pwd", "mouse_shape", @@ -466,14 +393,10 @@ pub const Parser = struct { .conemu_sleep, .conemu_wait_input, .conemu_xterm_emulation, - .end_of_command, - .end_of_input, .hyperlink_end, .hyperlink_start, .invalid, .mouse_shape, - .prompt_end, - .prompt_start, .report_pwd, .semantic_prompt, .show_desktop_notification, @@ -758,7 +681,7 @@ pub const Parser = struct { .@"77" => null, - .@"133" => parsers.semantic_prompt2.parse(self, terminator_ch), + .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), .@"777" => parsers.rxvt_extension.parse(self, terminator_ch), diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index d005bd4c0..5570b7702 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -12,8 +12,7 @@ pub const mouse_shape = @import("parsers/mouse_shape.zig"); pub const osc9 = @import("parsers/osc9.zig"); pub const report_pwd = @import("parsers/report_pwd.zig"); pub const rxvt_extension = @import("parsers/rxvt_extension.zig"); -pub const semantic_prompt = @import("parsers/semantic_prompt.zig"); -pub const semantic_prompt2 = @import("parsers/semantic_prompt2.zig"); +pub const semantic_prompt = @import("parsers/semantic_prompt2.zig"); test { std.testing.refAllDecls(@This()); diff --git a/src/terminal/osc/parsers/osc9.zig b/src/terminal/osc/parsers/osc9.zig index aba6f294a..f636813d9 100644 --- a/src/terminal/osc/parsers/osc9.zig +++ b/src/terminal/osc/parsers/osc9.zig @@ -98,9 +98,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { }, // OSC 9;12 mark prompt start '2' => { - parser.command = .{ - .prompt_start = .{}, - }; + parser.command = .{ .semantic_prompt = .init(.fresh_line_new_prompt) }; return &parser.command; }, else => break :conemu, @@ -1125,7 +1123,7 @@ test "OSC: 9;12: ConEmu mark prompt start 1" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .prompt_start); + try testing.expect(cmd == .semantic_prompt); } test "OSC: 9;12: ConEmu mark prompt start 2" { @@ -1138,5 +1136,5 @@ test "OSC: 9;12: ConEmu mark prompt start 2" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .prompt_start); + try testing.expect(cmd == .semantic_prompt); } diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig deleted file mode 100644 index d7cfe7c35..000000000 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ /dev/null @@ -1,770 +0,0 @@ -//! https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md -const std = @import("std"); -const string_encoding = @import("../../../os/string_encoding.zig"); -const Parser = @import("../../osc.zig").Parser; -const Command = @import("../../osc.zig").Command; - -const log = std.log.scoped(.osc_semantic_prompt); - -/// Parse OSC 133, semantic prompts -pub fn parse(parser: *Parser, _: ?u8) ?*Command { - const writer = parser.writer orelse { - parser.state = .invalid; - return null; - }; - const data = writer.buffered(); - if (data.len == 0) { - parser.state = .invalid; - return null; - } - switch (data[0]) { - 'A' => prompt_start: { - parser.command = .{ - .prompt_start = .{}, - }; - if (data.len == 1) break :prompt_start; - if (data[1] != ';') { - parser.state = .invalid; - return null; - } - var it = SemanticPromptKVIterator.init(writer) catch { - parser.state = .invalid; - return null; - }; - while (it.next()) |kv| { - const key = kv.key orelse continue; - if (std.mem.eql(u8, key, "aid")) { - parser.command.prompt_start.aid = kv.value; - } else if (std.mem.eql(u8, key, "redraw")) redraw: { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. - parser.command.prompt_start.redraw = (value: { - const value = kv.value orelse break :value null; - if (value.len != 1) break :value null; - switch (value[0]) { - '0' => break :value false, - '1' => break :value true, - else => break :value null, - } - }) orelse { - log.info("OSC 133 A: invalid redraw value: {?s}", .{kv.value}); - break :redraw; - }; - } else if (std.mem.eql(u8, key, "special_key")) redraw: { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - parser.command.prompt_start.special_key = (value: { - const value = kv.value orelse break :value null; - if (value.len != 1) break :value null; - switch (value[0]) { - '0' => break :value false, - '1' => break :value true, - else => break :value null, - } - }) orelse { - log.info("OSC 133 A invalid special_key value: {?s}", .{kv.value}); - break :redraw; - }; - } else if (std.mem.eql(u8, key, "click_events")) redraw: { - // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - parser.command.prompt_start.click_events = (value: { - const value = kv.value orelse break :value null; - if (value.len != 1) break :value null; - switch (value[0]) { - '0' => break :value false, - '1' => break :value true, - else => break :value null, - } - }) orelse { - log.info("OSC 133 A invalid click_events value: {?s}", .{kv.value}); - break :redraw; - }; - } else if (std.mem.eql(u8, key, "k")) k: { - // The "k" marks the kind of prompt, or "primary" if we don't know. - // This can be used to distinguish between the first (initial) prompt, - // a continuation, etc. - const value = kv.value orelse break :k; - if (value.len != 1) break :k; - parser.command.prompt_start.kind = switch (value[0]) { - 'c' => .continuation, - 's' => .secondary, - 'r' => .right, - 'i' => .primary, - else => .primary, - }; - } else log.info("OSC 133 A: unknown semantic prompt option: {?s}", .{kv.key}); - } - }, - 'B' => prompt_end: { - parser.command = .prompt_end; - if (data.len == 1) break :prompt_end; - if (data[1] != ';') { - parser.state = .invalid; - return null; - } - var it = SemanticPromptKVIterator.init(writer) catch { - parser.state = .invalid; - return null; - }; - while (it.next()) |kv| { - log.info("OSC 133 B: unknown semantic prompt option: {?s}", .{kv.key}); - } - }, - 'C' => end_of_input: { - parser.command = .{ - .end_of_input = .{}, - }; - if (data.len == 1) break :end_of_input; - if (data[1] != ';') { - parser.state = .invalid; - return null; - } - var it = SemanticPromptKVIterator.init(writer) catch { - parser.state = .invalid; - return null; - }; - while (it.next()) |kv| { - const key = kv.key orelse continue; - if (std.mem.eql(u8, key, "cmdline")) { - parser.command.end_of_input.cmdline = if (kv.value) |value| string_encoding.printfQDecode(value) catch null else null; - } else if (std.mem.eql(u8, key, "cmdline_url")) { - parser.command.end_of_input.cmdline = if (kv.value) |value| string_encoding.urlPercentDecode(value) catch null else null; - } else { - log.info("OSC 133 C: unknown semantic prompt option: {s}", .{key}); - } - } - }, - 'D' => { - const exit_code: ?u8 = exit_code: { - if (data.len == 1) break :exit_code null; - if (data[1] != ';') { - parser.state = .invalid; - return null; - } - break :exit_code std.fmt.parseUnsigned(u8, data[2..], 10) catch null; - }; - parser.command = .{ - .end_of_command = .{ - .exit_code = exit_code, - }, - }; - }, - else => { - parser.state = .invalid; - return null; - }, - } - return &parser.command; -} - -const SemanticPromptKVIterator = struct { - index: usize, - string: []u8, - - pub const SemanticPromptKV = struct { - key: ?[:0]u8, - value: ?[:0]u8, - }; - - pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!SemanticPromptKVIterator { - // add a semicolon to make it easier to find and sentinel terminate the values - try writer.writeByte(';'); - return .{ - .index = 0, - .string = writer.buffered()[2..], - }; - } - - pub fn next(self: *SemanticPromptKVIterator) ?SemanticPromptKV { - if (self.index >= self.string.len) return null; - - const kv = kv: { - const index = std.mem.indexOfScalarPos(u8, self.string, self.index, ';') orelse { - self.index = self.string.len; - return null; - }; - self.string[index] = 0; - const kv = self.string[self.index..index :0]; - self.index = index + 1; - break :kv kv; - }; - - // If we have an empty item, we return a null key and value. - // - // This allows for trailing semicolons, but also lets us parse - // (or rather, ignore) empty fields; for example `a=b;;e=f`. - if (kv.len < 1) return .{ - .key = null, - .value = null, - }; - - const key = key: { - const index = std.mem.indexOfScalar(u8, kv, '=') orelse { - // If there is no '=' return entire `kv` string as the key and - // a null value. - return .{ - .key = kv, - .value = null, - }; - }; - kv[index] = 0; - const key = kv[0..index :0]; - break :key key; - }; - - const value = kv[key.len + 1 .. :0]; - - return .{ - .key = key, - .value = value, - }; - } -}; - -test "OSC 133: prompt_start" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.aid == null); - try testing.expect(cmd.prompt_start.redraw); -} - -test "OSC 133: prompt_start with single option" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;aid=14"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); -} - -test "OSC 133: prompt_start with '=' in aid" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;aid=a=b;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("a=b", cmd.prompt_start.aid.?); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC 133: prompt_start with redraw disabled" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC 133: prompt_start with redraw invalid value" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;redraw=42"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.redraw); - try testing.expect(cmd.prompt_start.kind == .primary); -} - -test "OSC 133: prompt_start with continuation" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;k=c"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .continuation); -} - -test "OSC 133: prompt_start with secondary" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;k=s"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .secondary); -} - -test "OSC 133: prompt_start with special_key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key=1"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == true); -} - -test "OSC 133: prompt_start with special_key invalid" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key=bobr"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == false); -} - -test "OSC 133: prompt_start with special_key 0" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == false); -} - -test "OSC 133: prompt_start with special_key empty" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;special_key="; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.special_key == false); -} - -test "OSC 133: prompt_start with trailing ;" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); -} - -test "OSC 133: prompt_start with click_events true" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events=1"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == true); -} - -test "OSC 133: prompt_start with click_events false" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == false); -} - -test "OSC 133: prompt_start with click_events empty" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events="; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == false); -} - -test "OSC 133: prompt_start with click_events bare key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;click_events"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.click_events == false); -} - -test "OSC 133: prompt_start with invalid bare key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;A;barekey"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.aid == null); - try testing.expectEqual(.primary, cmd.prompt_start.kind); - try testing.expect(cmd.prompt_start.redraw == true); - try testing.expect(cmd.prompt_start.special_key == false); - try testing.expect(cmd.prompt_start.click_events == false); -} - -test "OSC 133: end_of_command no exit code" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;D"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); -} - -test "OSC 133: end_of_command with exit code" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;D;25"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); - try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); -} - -test "OSC 133: prompt_end" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;B"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_end); -} - -test "OSC 133: end_of_input" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); -} - -test "OSC 133: end_of_input with cmdline 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=echo bobr\\ kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 3" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=echo bobr\\nkurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr\nkurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 4" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'echo bobr kurwa'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 5" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline='echo bobr kurwa'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline 6" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline='echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 7" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 8" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 9" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline=$'"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline 10" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline="; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 1" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 2" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%20kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 3" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%3bkurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr;kurwa", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 4" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%3kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 5" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 6" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr%kurwa"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 7" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa%20"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expectEqualStrings("echo bobr kurwa ", cmd.end_of_input.cmdline.?); -} - -test "OSC 133: end_of_input with cmdline_url 8" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa%2"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with cmdline_url 9" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url=echo bobr kurwa%2"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} - -test "OSC 133: end_of_input with bare key" { - const testing = std.testing; - - var p: Parser = .init(null); - - const input = "133;C;cmdline_url"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); - try testing.expect(cmd.end_of_input.cmdline == null); -} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5d4a37c43..d0d2c1bb3 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -111,8 +111,6 @@ pub const Action = union(Key) { apc_start, apc_end, apc_put: u8, - prompt_end, - end_of_input, end_hyperlink, active_status_display: ansi.StatusDisplay, decaln, @@ -122,9 +120,6 @@ pub const Action = union(Key) { progress_report: osc.Command.ProgressReport, start_hyperlink: StartHyperlink, clipboard_contents: ClipboardContents, - prompt_start: PromptStart, - prompt_continuation: PromptContinuation, - end_of_command: EndOfCommand, mouse_shape: MouseShape, configure_charset: ConfigureCharset, set_attribute: sgr.Attribute, @@ -213,8 +208,6 @@ pub const Action = union(Key) { "apc_start", "apc_end", "apc_put", - "prompt_end", - "end_of_input", "end_hyperlink", "active_status_display", "decaln", @@ -224,9 +217,6 @@ pub const Action = union(Key) { "progress_report", "start_hyperlink", "clipboard_contents", - "prompt_start", - "prompt_continuation", - "end_of_command", "mouse_shape", "configure_charset", "set_attribute", @@ -393,47 +383,6 @@ pub const Action = union(Key) { } }; - pub const PromptStart = struct { - aid: ?[]const u8, - redraw: bool, - - pub const C = extern struct { - aid: lib.String, - redraw: bool, - }; - - pub fn cval(self: PromptStart) PromptStart.C { - return .{ - .aid = .init(self.aid orelse ""), - .redraw = self.redraw, - }; - } - }; - - pub const PromptContinuation = struct { - aid: ?[]const u8, - - pub const C = lib.String; - - pub fn cval(self: PromptContinuation) PromptContinuation.C { - return .init(self.aid orelse ""); - } - }; - - pub const EndOfCommand = struct { - exit_code: ?u8, - - pub const C = extern struct { - exit_code: i16, - }; - - pub fn cval(self: EndOfCommand) EndOfCommand.C { - return .{ - .exit_code = if (self.exit_code) |code| @intCast(code) else -1, - }; - } - }; - pub const ConfigureCharset = lib.Struct(lib_target, struct { slot: charsets.Slots, charset: charsets.Charset, @@ -1992,10 +1941,9 @@ pub fn Stream(comptime Handler: type) type { // 4. hyperlink_start // 5. report_pwd // 6. color_operation - // 7. prompt_start - // 8. prompt_end + // 7. semantic_prompt // - // Together, these 8 commands make up about 96% of all + // Together, these 7 commands make up about 96% of all // OSC commands encountered in real world scenarios. // // Additionally, within the prongs, unlikely branch @@ -2008,6 +1956,7 @@ pub fn Stream(comptime Handler: type) type { switch (cmd) { .semantic_prompt => |sp| { + @branchHint(.likely); try self.handler.vt(.semantic_prompt, sp); }, @@ -2034,30 +1983,6 @@ pub fn Stream(comptime Handler: type) type { }); }, - .prompt_start => |v| { - @branchHint(.likely); - switch (v.kind) { - .primary, .right => try self.handler.vt(.prompt_start, .{ - .aid = v.aid, - .redraw = v.redraw, - }), - .continuation, .secondary => try self.handler.vt(.prompt_continuation, .{ - .aid = v.aid, - }), - } - }, - - .prompt_end => { - @branchHint(.likely); - try self.handler.vt(.prompt_end, {}); - }, - - .end_of_input => try self.handler.vt(.end_of_input, {}), - - .end_of_command => |end| { - try self.handler.vt(.end_of_command, .{ .exit_code = end.exit_code }); - }, - .report_pwd => |v| { @branchHint(.likely); try self.handler.vt(.report_pwd, .{ .url = v.value }); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index ba2aec6f9..57227a057 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -153,14 +153,6 @@ pub const Handler = struct { .full_reset => self.terminal.fullReset(), .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), - .prompt_start => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; - self.terminal.flags.shell_redraws_prompt = value.redraw; - }, - .prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, - .prompt_end => self.terminal.markSemanticPrompt(.input), - .end_of_input => self.terminal.markSemanticPrompt(.command), - .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, .semantic_prompt => self.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 1bf22ff55..cfe68fd1c 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -311,8 +311,6 @@ pub const StreamHandler = struct { }, .kitty_color_report => try self.kittyColorReport(value), .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator), - .prompt_end => try self.promptEnd(), - .end_of_input => try self.endOfInput(), .end_hyperlink => try self.endHyperlink(), .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), @@ -322,9 +320,6 @@ pub const StreamHandler = struct { .progress_report => self.progressReport(value), .start_hyperlink => try self.startHyperlink(value.uri, value.id), .clipboard_contents => try self.clipboardContents(value.kind, value.data), - .prompt_start => self.promptStart(value.aid, value.redraw), - .prompt_continuation => self.promptContinuation(value.aid), - .end_of_command => self.endOfCommand(value.exit_code), .semantic_prompt => self.semanticPrompt(value), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), @@ -1071,30 +1066,6 @@ pub const StreamHandler = struct { }); } - inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt); - self.terminal.flags.shell_redraws_prompt = redraw; - } - - inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) void { - _ = aid; - self.terminal.markSemanticPrompt(.prompt_continuation); - } - - pub inline fn promptEnd(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.input); - } - - pub inline fn endOfInput(self: *StreamHandler) !void { - self.terminal.markSemanticPrompt(.command); - self.surfaceMessageWriter(.start_command); - } - - inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) void { - self.surfaceMessageWriter(.{ .stop_command = exit_code }); - } - fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, From afea12116d6632681e7a27ba50297eb942b6ce73 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:37:41 -0800 Subject: [PATCH 591/605] terminal/osc: Kitty extensions to semantic prompt options --- src/terminal/osc/parsers/semantic_prompt2.zig | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt2.zig index 5ab615713..3d6e6a13f 100644 --- a/src/terminal/osc/parsers/semantic_prompt2.zig +++ b/src/terminal/osc/parsers/semantic_prompt2.zig @@ -44,6 +44,22 @@ pub const Options = struct { // redraw the prompt so we should attempt to resize it. redraw: bool, + // Use a special key instead of arrow keys to move the cursor on + // mouse click. Useful if arrow keys have side-effets like triggering + // auto-complete. The shell integration script should bind the special + // key as needed. + // See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + special_key: bool, + + // If true, the shell is capable of handling mouse click events. + // Ghostty will then send a click event to the shell when the user + // clicks somewhere in the prompt. The shell can then move the cursor + // to that position or perform some other appropriate action. If false, + // Ghostty may generate a number of fake key events to move the cursor + // which is not very robust. + // See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + click_events: bool, + // Not technically an option that can be set with k=v and only // present currently with command 'D' but its easier to just // parse it into our options. @@ -56,6 +72,8 @@ pub const Options = struct { .exit_code = null, .err = null, .redraw = false, + .special_key = false, + .click_events = false, }; pub fn parse(self: *Options, it: *KVIterator) void { @@ -80,6 +98,22 @@ pub const Options = struct { '1' => true, else => break :redraw, }; + } else if (std.mem.eql(u8, key, "special_key")) { + const value = kv.value orelse continue; + if (value.len != 1) continue; + self.special_key = switch (value[0]) { + '0' => false, + '1' => true, + else => continue, + }; + } else if (std.mem.eql(u8, key, "click_events")) { + const value = kv.value orelse continue; + if (value.len != 1) continue; + self.click_events = switch (value[0]) { + '0' => false, + '1' => true, + else => continue, + }; } else { log.info("OSC 133: unknown semantic prompt option: {s}", .{key}); } @@ -500,7 +534,7 @@ test "OSC 133: fresh_line_new_prompt default redraw" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.redraw == true); + try testing.expect(cmd.semantic_prompt.options.redraw == false); } test "OSC 133: fresh_line_new_prompt with redraw=0" { @@ -542,7 +576,7 @@ test "OSC 133: fresh_line_new_prompt with invalid redraw" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.redraw == true); + try testing.expect(cmd.semantic_prompt.options.redraw == false); } test "OSC 133: prompt_start" { From c98e3e6fc7e6793ad5ea883062f43210ce07ca0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:38:28 -0800 Subject: [PATCH 592/605] terminal/osc: rename the prompt2 file --- src/terminal/osc/parsers.zig | 2 +- .../osc/parsers/{semantic_prompt2.zig => semantic_prompt.zig} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/terminal/osc/parsers/{semantic_prompt2.zig => semantic_prompt.zig} (100%) diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index 5570b7702..fb84785f2 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -12,7 +12,7 @@ pub const mouse_shape = @import("parsers/mouse_shape.zig"); pub const osc9 = @import("parsers/osc9.zig"); pub const report_pwd = @import("parsers/report_pwd.zig"); pub const rxvt_extension = @import("parsers/rxvt_extension.zig"); -pub const semantic_prompt = @import("parsers/semantic_prompt2.zig"); +pub const semantic_prompt = @import("parsers/semantic_prompt.zig"); test { std.testing.refAllDecls(@This()); diff --git a/src/terminal/osc/parsers/semantic_prompt2.zig b/src/terminal/osc/parsers/semantic_prompt.zig similarity index 100% rename from src/terminal/osc/parsers/semantic_prompt2.zig rename to src/terminal/osc/parsers/semantic_prompt.zig From 3f006f86a3421ee1ea1030ab28e65df99d9c1534 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 23 Jan 2026 14:41:10 -0800 Subject: [PATCH 593/605] lib-vt: fix up the OSC command keys --- include/ghostty/vt/osc.h | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index 7e2c8f322..f53077ab3 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -63,24 +63,26 @@ typedef enum { GHOSTTY_OSC_COMMAND_INVALID = 0, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_PROMPT_START = 3, - GHOSTTY_OSC_COMMAND_PROMPT_END = 4, - GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, - GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, + GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, + GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, + GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, + GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, + GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, + GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, + GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, + GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, + GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, + GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, + GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, + GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, + GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, + GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, + GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, + GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, + GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, + GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, + GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, + GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, } GhosttyOscCommandType; /** From c9e60b322b61fcb2c161f69311de1cbab5ea58f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:30:06 -0800 Subject: [PATCH 594/605] terminal: OSC133 options parse from raw string This changes our OSC133 parser to parse options lazily. We do this for multiple reasons: 1. Parsing all our options ahead of time balloons our required osc.Command tagged union type which has C ABI implications. Adding all supported options (including Kitty extensions) today already breaks our C ABI. 2. Invalid options are allowed by the specification and should be explicitly ignored, so we don't need to validate options at all during parse time. 3. Semantic prompt markers don't need to be high throughput, so we can afford to do some extra work at processing time to gather the options. They're also rather short usually. --- src/terminal/osc/parsers/semantic_prompt.zig | 447 ++++++++++--------- src/terminal/stream_readonly.zig | 6 +- src/termio/stream_handler.zig | 8 +- 3 files changed, 251 insertions(+), 210 deletions(-) diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 3d6e6a13f..f6a0cb593 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -13,7 +13,7 @@ const log = std.log.scoped(.osc_semantic_prompt); /// options. So, I think this is a fair interpretation. pub const Command = struct { action: Action, - options: Options, + options_unvalidated: []const u8, pub const Action = enum { fresh_line, // 'L' @@ -27,29 +27,39 @@ pub const Command = struct { }; pub fn init(action: Action) Command { - return .{ .action = action, .options = .init }; + return .{ + .action = action, + .options_unvalidated = "", + }; + } + + /// Read an option for this command. Returns null if unset or invalid. + pub fn readOption( + self: Command, + comptime option: Option, + ) ?option.Type() { + return option.read(self.options_unvalidated); } }; -pub const Options = struct { - aid: ?[:0]const u8, - cl: ?Click, - prompt_kind: ?PromptKind, - err: ?[:0]const u8, - +pub const Option = enum { + aid, + cl, + prompt_kind, + err, // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers // Kitty supports a "redraw" option for prompt_start. I can't find // this documented anywhere but can see in the code that this is used // by shell environments to tell the terminal that the shell will NOT // redraw the prompt so we should attempt to resize it. - redraw: bool, + redraw, // Use a special key instead of arrow keys to move the cursor on // mouse click. Useful if arrow keys have side-effets like triggering // auto-complete. The shell integration script should bind the special // key as needed. // See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - special_key: bool, + special_key, // If true, the shell is capable of handling mouse click events. // Ghostty will then send a click event to the shell when the user @@ -58,66 +68,119 @@ pub const Options = struct { // Ghostty may generate a number of fake key events to move the cursor // which is not very robust. // See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - click_events: bool, + click_events, // Not technically an option that can be set with k=v and only // present currently with command 'D' but its easier to just // parse it into our options. - exit_code: ?i32, + exit_code, - pub const init: Options = .{ - .aid = null, - .cl = null, - .prompt_kind = null, - .exit_code = null, - .err = null, - .redraw = false, - .special_key = false, - .click_events = false, - }; + pub fn Type(comptime self: Option) type { + return switch (self) { + .aid => []const u8, + .cl => Click, + .prompt_kind => PromptKind, + .err => []const u8, + .redraw => bool, + .special_key => bool, + .click_events => bool, + .exit_code => i32, + }; + } - pub fn parse(self: *Options, it: *KVIterator) void { - while (it.next()) |kv| { - const key = kv.key orelse continue; - if (std.mem.eql(u8, key, "aid")) { - self.aid = kv.value; - } else if (std.mem.eql(u8, key, "cl")) { - const value = kv.value orelse continue; - self.cl = std.meta.stringToEnum(Click, value); - } else if (std.mem.eql(u8, key, "k")) { - const value = kv.value orelse continue; - if (value.len != 1) continue; - self.prompt_kind = .init(value[0]); - } else if (std.mem.eql(u8, key, "err")) { - self.err = kv.value; - } else if (std.mem.eql(u8, key, "redraw")) redraw: { - const value = kv.value orelse break :redraw; - if (value.len != 1) break :redraw; - self.redraw = switch (value[0]) { - '0' => false, - '1' => true, - else => break :redraw, - }; - } else if (std.mem.eql(u8, key, "special_key")) { - const value = kv.value orelse continue; - if (value.len != 1) continue; - self.special_key = switch (value[0]) { - '0' => false, - '1' => true, - else => continue, - }; - } else if (std.mem.eql(u8, key, "click_events")) { - const value = kv.value orelse continue; - if (value.len != 1) continue; - self.click_events = switch (value[0]) { - '0' => false, - '1' => true, - else => continue, - }; - } else { - log.info("OSC 133: unknown semantic prompt option: {s}", .{key}); + fn key(comptime self: Option) []const u8 { + return switch (self) { + .aid => "aid", + .cl => "cl", + .prompt_kind => "k", + .err => "err", + .redraw => "redraw", + .special_key => "special_key", + .click_events => "click_events", + + // special case, handled before ever calling key + .exit_code => unreachable, + }; + } + + /// Read the option value from the raw options string. + /// + /// The raw options string is the raw unparsed data after the + /// OSC 133 command. e.g. for `133;A;aid=14;cl=line`, the + /// raw options string would be `aid=14;cl=line`. + /// + /// Any errors in the raw string will return null since the OSC133 + /// specification says to ignore unknown or malformed options. + pub fn read( + comptime self: Option, + raw: []const u8, + ) ?self.Type() { + var remaining = raw; + while (remaining.len > 0) { + // Length of the next value is up to the `;` or the + // end of the string. + const len = std.mem.indexOfScalar( + u8, + remaining, + ';', + ) orelse remaining.len; + + // Grab our full value and move our cursor past the `;` + const full = remaining[0..len]; + + // If we're looking for exit_code we special case it. + // as the first value. + if (comptime self == .exit_code) { + return std.fmt.parseInt( + i32, + full, + 10, + ) catch null; } + + // Parse our key=value and verify our key matches our + // expectation. + const value = value: { + if (std.mem.indexOfScalar( + u8, + full, + '=', + )) |eql_idx| { + if (std.mem.eql( + u8, + full[0..eql_idx], + self.key(), + )) { + break :value full[eql_idx + 1 ..]; + } + } + + // No match! + if (len < remaining.len) { + remaining = remaining[len + 1 ..]; + continue; + } + + break; + }; + + return switch (self) { + .aid => value, + .cl => std.meta.stringToEnum(Click, value), + .prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null, + .err => value, + .redraw, .special_key, .click_events => if (value.len == 1) switch (value[0]) { + '0' => false, + '1' => true, + else => null, + } else null, + // Handled above + .exit_code => unreachable, + }; } + + // Not found + return null; } }; @@ -166,56 +229,35 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command = .{ .semantic_prompt = .init(.fresh_line_new_prompt) }; if (data.len == 1) break :fresh_line; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, 'B' => end_prompt: { parser.command = .{ .semantic_prompt = .init(.end_prompt_start_input) }; if (data.len == 1) break :end_prompt; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, 'I' => end_prompt_line: { parser.command = .{ .semantic_prompt = .init(.end_prompt_start_input_terminate_eol) }; if (data.len == 1) break :end_prompt_line; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, 'C' => end_input: { parser.command = .{ .semantic_prompt = .init(.end_input_start_output) }; if (data.len == 1) break :end_input; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, 'D' => end_command: { parser.command = .{ .semantic_prompt = .init(.end_command) }; if (data.len == 1) break :end_command; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - - // If there are options, the first option MUST be the - // exit code. The specification appears to mandate this - // and disallow options without an exit code. - { - const first = it.next() orelse break :end_command; - if (first.value != null) break :end_command; - const key = first.key orelse break :end_command; - parser.command.semantic_prompt.options.exit_code = std.fmt.parseInt( - i32, - key, - 10, - ) catch null; - } - - // Parse the remaining options - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, 'L' => { @@ -227,16 +269,14 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { parser.command = .{ .semantic_prompt = .init(.new_command) }; if (data.len == 1) break :new_command; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, 'P' => prompt_start: { parser.command = .{ .semantic_prompt = .init(.prompt_start) }; if (data.len == 1) break :prompt_start; if (data[1] != ';') break :valid; - var it = KVIterator.init(writer) catch break :valid; - parser.command.semantic_prompt.options.parse(&it); + parser.command.semantic_prompt.options_unvalidated = data[2..]; }, else => break :valid, @@ -250,81 +290,6 @@ pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { return null; } -const KVIterator = struct { - index: usize, - string: []u8, - - pub const KV = struct { - key: ?[:0]u8, - value: ?[:0]u8, - - pub const empty: KV = .{ - .key = null, - .value = null, - }; - }; - - pub fn init(writer: *std.Io.Writer) std.Io.Writer.Error!KVIterator { - // Add a semicolon to make it easier to find and sentinel terminate - // the values. - try writer.writeByte(';'); - return .{ - .index = 0, - .string = writer.buffered()[2..], - }; - } - - pub fn next(self: *KVIterator) ?KV { - if (self.index >= self.string.len) return null; - - const kv = kv: { - const index = std.mem.indexOfScalarPos( - u8, - self.string, - self.index, - ';', - ) orelse { - self.index = self.string.len; - return null; - }; - self.string[index] = 0; - const kv = self.string[self.index..index :0]; - self.index = index + 1; - break :kv kv; - }; - - // If we have an empty item, we return a null key and value. - // - // This allows for trailing semicolons, but also lets us parse - // (or rather, ignore) empty fields; for example `a=b;;e=f`. - if (kv.len < 1) return .empty; - - const key = key: { - const index = std.mem.indexOfScalar( - u8, - kv, - '=', - ) orelse { - // If there is no '=' return entire `kv` string as the key and - // a null value. - return .{ - .key = kv, - .value = null, - }; - }; - - kv[index] = 0; - break :key kv[0..index :0]; - }; - const value = kv[key.len + 1 .. :0]; - - return .{ - .key = key, - .value = value, - }; - } -}; - test "OSC 133: end_input_start_output" { const testing = std.testing; @@ -336,8 +301,8 @@ test "OSC 133: end_input_start_output" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_input_start_output); - try testing.expect(cmd.semantic_prompt.options.aid == null); - try testing.expect(cmd.semantic_prompt.options.cl == null); + try testing.expect(cmd.semantic_prompt.readOption(.aid) == null); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == null); } test "OSC 133: end_input_start_output extra contents" { @@ -359,7 +324,7 @@ test "OSC 133: end_input_start_output with options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_input_start_output); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); } test "OSC 133: fresh_line" { @@ -406,8 +371,8 @@ test "OSC 133: fresh_line_new_prompt" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.aid == null); - try testing.expect(cmd.semantic_prompt.options.cl == null); + try testing.expect(cmd.semantic_prompt.readOption(.aid) == null); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == null); } test "OSC 133: fresh_line_new_prompt with aid" { @@ -421,7 +386,7 @@ test "OSC 133: fresh_line_new_prompt with aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expectEqualStrings("14", cmd.semantic_prompt.options.aid.?); + try testing.expectEqualStrings("14", cmd.semantic_prompt.readOption(.aid).?); } test "OSC 133: fresh_line_new_prompt with '=' in aid" { @@ -435,7 +400,7 @@ test "OSC 133: fresh_line_new_prompt with '=' in aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expectEqualStrings("a=b", cmd.semantic_prompt.options.aid.?); + try testing.expectEqualStrings("a=b", cmd.semantic_prompt.readOption(.aid).?); } test "OSC 133: fresh_line_new_prompt with cl=line" { @@ -449,7 +414,7 @@ test "OSC 133: fresh_line_new_prompt with cl=line" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.cl == .line); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == .line); } test "OSC 133: fresh_line_new_prompt with cl=multiple" { @@ -463,7 +428,7 @@ test "OSC 133: fresh_line_new_prompt with cl=multiple" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.cl == .multiple); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == .multiple); } test "OSC 133: fresh_line_new_prompt with invalid cl" { @@ -477,7 +442,7 @@ test "OSC 133: fresh_line_new_prompt with invalid cl" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.cl == null); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == null); } test "OSC 133: fresh_line_new_prompt with trailing ;" { @@ -504,8 +469,8 @@ test "OSC 133: fresh_line_new_prompt with bare key" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.aid == null); - try testing.expect(cmd.semantic_prompt.options.cl == null); + try testing.expect(cmd.semantic_prompt.readOption(.aid) == null); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == null); } test "OSC 133: fresh_line_new_prompt with multiple options" { @@ -519,8 +484,8 @@ test "OSC 133: fresh_line_new_prompt with multiple options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); - try testing.expect(cmd.semantic_prompt.options.cl == .line); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == .line); } test "OSC 133: fresh_line_new_prompt default redraw" { @@ -534,7 +499,7 @@ test "OSC 133: fresh_line_new_prompt default redraw" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.redraw == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw) == null); } test "OSC 133: fresh_line_new_prompt with redraw=0" { @@ -548,7 +513,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.redraw == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == false); } test "OSC 133: fresh_line_new_prompt with redraw=1" { @@ -562,7 +527,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=1" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.redraw == true); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == true); } test "OSC 133: fresh_line_new_prompt with invalid redraw" { @@ -576,7 +541,7 @@ test "OSC 133: fresh_line_new_prompt with invalid redraw" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.options.redraw == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw) == null); } test "OSC 133: prompt_start" { @@ -590,7 +555,7 @@ test "OSC 133: prompt_start" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .prompt_start); - try testing.expect(cmd.semantic_prompt.options.prompt_kind == null); + try testing.expect(cmd.semantic_prompt.readOption(.prompt_kind) == null); } test "OSC 133: prompt_start with k=i" { @@ -604,7 +569,7 @@ test "OSC 133: prompt_start with k=i" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .prompt_start); - try testing.expect(cmd.semantic_prompt.options.prompt_kind == .initial); + try testing.expect(cmd.semantic_prompt.readOption(.prompt_kind) == .initial); } test "OSC 133: prompt_start with k=r" { @@ -618,7 +583,7 @@ test "OSC 133: prompt_start with k=r" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .prompt_start); - try testing.expect(cmd.semantic_prompt.options.prompt_kind == .right); + try testing.expect(cmd.semantic_prompt.readOption(.prompt_kind) == .right); } test "OSC 133: prompt_start with k=c" { @@ -632,7 +597,7 @@ test "OSC 133: prompt_start with k=c" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .prompt_start); - try testing.expect(cmd.semantic_prompt.options.prompt_kind == .continuation); + try testing.expect(cmd.semantic_prompt.readOption(.prompt_kind) == .continuation); } test "OSC 133: prompt_start with k=s" { @@ -646,7 +611,7 @@ test "OSC 133: prompt_start with k=s" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .prompt_start); - try testing.expect(cmd.semantic_prompt.options.prompt_kind == .secondary); + try testing.expect(cmd.semantic_prompt.readOption(.prompt_kind) == .secondary); } test "OSC 133: prompt_start with invalid k" { @@ -660,7 +625,7 @@ test "OSC 133: prompt_start with invalid k" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .prompt_start); - try testing.expect(cmd.semantic_prompt.options.prompt_kind == null); + try testing.expect(cmd.semantic_prompt.readOption(.prompt_kind) == null); } test "OSC 133: prompt_start extra contents" { @@ -683,8 +648,8 @@ test "OSC 133: new_command" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .new_command); - try testing.expect(cmd.semantic_prompt.options.aid == null); - try testing.expect(cmd.semantic_prompt.options.cl == null); + try testing.expect(cmd.semantic_prompt.readOption(.aid) == null); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == null); } test "OSC 133: new_command with aid" { @@ -698,7 +663,7 @@ test "OSC 133: new_command with aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .new_command); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); } test "OSC 133: new_command with cl=line" { @@ -712,7 +677,7 @@ test "OSC 133: new_command with cl=line" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .new_command); - try testing.expect(cmd.semantic_prompt.options.cl == .line); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == .line); } test "OSC 133: new_command with multiple options" { @@ -726,8 +691,8 @@ test "OSC 133: new_command with multiple options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .new_command); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); - try testing.expect(cmd.semantic_prompt.options.cl == .line); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); + try testing.expect(cmd.semantic_prompt.readOption(.cl) == .line); } test "OSC 133: new_command extra contents" { @@ -771,7 +736,7 @@ test "OSC 133: end_prompt_start_input with options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_prompt_start_input); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); } test "OSC 133: end_prompt_start_input_terminate_eol" { @@ -806,7 +771,7 @@ test "OSC 133: end_prompt_start_input_terminate_eol with options" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_prompt_start_input_terminate_eol); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); } test "OSC 133: end_command" { @@ -820,9 +785,9 @@ test "OSC 133: end_command" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_command); - try testing.expect(cmd.semantic_prompt.options.exit_code == null); - try testing.expect(cmd.semantic_prompt.options.aid == null); - try testing.expect(cmd.semantic_prompt.options.err == null); + try testing.expect(cmd.semantic_prompt.readOption(.exit_code) == null); + try testing.expect(cmd.semantic_prompt.readOption(.aid) == null); + try testing.expect(cmd.semantic_prompt.readOption(.err) == null); } test "OSC 133: end_command extra contents" { @@ -845,7 +810,7 @@ test "OSC 133: end_command with exit code 0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_command); - try testing.expect(cmd.semantic_prompt.options.exit_code == 0); + try testing.expect(cmd.semantic_prompt.readOption(.exit_code) == 0); } test "OSC 133: end_command with exit code and aid" { @@ -859,6 +824,78 @@ test "OSC 133: end_command with exit code and aid" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .end_command); - try testing.expectEqualStrings("foo", cmd.semantic_prompt.options.aid.?); - try testing.expect(cmd.semantic_prompt.options.exit_code == 12); + try testing.expectEqualStrings("foo", cmd.semantic_prompt.readOption(.aid).?); + try testing.expect(cmd.semantic_prompt.readOption(.exit_code) == 12); +} + +test "Option.read aid" { + const testing = std.testing; + try testing.expectEqualStrings("test123", Option.aid.read("aid=test123").?); + try testing.expectEqualStrings("myaid", Option.aid.read("cl=line;aid=myaid;k=i").?); + try testing.expect(Option.aid.read("cl=line;k=i") == null); + try testing.expectEqualStrings("", Option.aid.read("aid=").?); + try testing.expectEqualStrings("last", Option.aid.read("k=i;aid=last").?); + try testing.expectEqualStrings("first", Option.aid.read("aid=first;k=i").?); + try testing.expect(Option.aid.read("") == null); + try testing.expect(Option.aid.read("aid") == null); + try testing.expectEqualStrings("value", Option.aid.read(";;aid=value;;").?); +} + +test "Option.read cl" { + const testing = std.testing; + try testing.expect(Option.cl.read("cl=line").? == .line); + try testing.expect(Option.cl.read("cl=multiple").? == .multiple); + try testing.expect(Option.cl.read("cl=conservative_vertical").? == .conservative_vertical); + try testing.expect(Option.cl.read("cl=smart_vertical").? == .smart_vertical); + try testing.expect(Option.cl.read("cl=invalid") == null); + try testing.expect(Option.cl.read("aid=foo") == null); +} + +test "Option.read prompt_kind" { + const testing = std.testing; + try testing.expect(Option.prompt_kind.read("k=i").? == .initial); + try testing.expect(Option.prompt_kind.read("k=r").? == .right); + try testing.expect(Option.prompt_kind.read("k=c").? == .continuation); + try testing.expect(Option.prompt_kind.read("k=s").? == .secondary); + try testing.expect(Option.prompt_kind.read("k=x") == null); + try testing.expect(Option.prompt_kind.read("k=ii") == null); + try testing.expect(Option.prompt_kind.read("k=") == null); +} + +test "Option.read err" { + const testing = std.testing; + try testing.expectEqualStrings("some_error", Option.err.read("err=some_error").?); + try testing.expect(Option.err.read("aid=foo") == null); +} + +test "Option.read redraw" { + const testing = std.testing; + try testing.expect(Option.redraw.read("redraw=1").? == true); + try testing.expect(Option.redraw.read("redraw=0").? == false); + try testing.expect(Option.redraw.read("redraw=2") == null); + try testing.expect(Option.redraw.read("redraw=10") == null); + try testing.expect(Option.redraw.read("redraw=") == null); +} + +test "Option.read special_key" { + const testing = std.testing; + try testing.expect(Option.special_key.read("special_key=1").? == true); + try testing.expect(Option.special_key.read("special_key=0").? == false); + try testing.expect(Option.special_key.read("special_key=x") == null); +} + +test "Option.read click_events" { + const testing = std.testing; + try testing.expect(Option.click_events.read("click_events=1").? == true); + try testing.expect(Option.click_events.read("click_events=0").? == false); + try testing.expect(Option.click_events.read("click_events=yes") == null); +} + +test "Option.read exit_code" { + const testing = std.testing; + try testing.expect(Option.exit_code.read("42").? == 42); + try testing.expect(Option.exit_code.read("0").? == 0); + try testing.expect(Option.exit_code.read("-1").? == -1); + try testing.expect(Option.exit_code.read("abc") == null); + try testing.expect(Option.exit_code.read("127;aid=foo").? == 127); } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 57227a057..18ed0dd42 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -215,11 +215,13 @@ pub const Handler = struct { ) void { switch (cmd.action) { .fresh_line_new_prompt => { - const kind = cmd.options.prompt_kind orelse .initial; + const kind = cmd.readOption(.prompt_kind) orelse .initial; switch (kind) { .initial, .right => { self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; - self.terminal.flags.shell_redraws_prompt = cmd.options.redraw; + if (cmd.readOption(.redraw)) |redraw| { + self.terminal.flags.shell_redraws_prompt = redraw; + } }, .continuation, .secondary => { self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index cfe68fd1c..63094b106 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1072,11 +1072,13 @@ pub const StreamHandler = struct { ) void { switch (cmd.action) { .fresh_line_new_prompt => { - const kind = cmd.options.prompt_kind orelse .initial; + const kind = cmd.readOption(.prompt_kind) orelse .initial; switch (kind) { .initial, .right => { self.terminal.markSemanticPrompt(.prompt); - self.terminal.flags.shell_redraws_prompt = cmd.options.redraw; + if (cmd.readOption(.redraw)) |redraw| { + self.terminal.flags.shell_redraws_prompt = redraw; + } }, .continuation, .secondary => { self.terminal.markSemanticPrompt(.prompt_continuation); @@ -1094,7 +1096,7 @@ pub const StreamHandler = struct { // other terminals accept 32-bits, but exit codes are really // bytes, so we just do our best here. const code: u8 = code: { - const raw: i32 = cmd.options.exit_code orelse 0; + const raw: i32 = cmd.readOption(.exit_code) orelse 0; break :code std.math.cast(u8, raw) orelse 1; }; From 4f7fcd595677d5f4d36ef633d26e3f1fefda0d06 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 26 Jan 2026 10:14:03 -0500 Subject: [PATCH 595/605] Skip tests if font family doesn't match --- src/font/shaper/harfbuzz.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 1cbbaaf8c..7468427ad 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -2122,6 +2122,15 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS defer disco_it.deinit(); var face: font.DeferredFace = (try disco_it.next()) orelse return error.FontNotFound; errdefer face.deinit(); + + // Check which font was discovered - skip if it doesn't match the request + var name_buf: [256]u8 = undefined; + const face_name = face.name(&name_buf) catch "(unknown)"; + if (std.mem.indexOf(u8, face_name, font_req) == null) { + face.deinit(); + return error.SkipZigTest; + } + _ = try c.add( alloc, try face.load(lib, .{ .size = .{ .points = 12 } }), From 17af9c13e26d911714b4d7273eeaf235951b8197 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 26 Jan 2026 10:22:17 -0500 Subject: [PATCH 596/605] Bengali text fix (likely grapheme break changes) --- src/font/shaper/harfbuzz.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 7468427ad..cb2bbc4bc 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1442,13 +1442,13 @@ test "shape Bengali ligatures with out of order vowels" { try testing.expectEqual(@as(u16, 0), cells[1].x); // Whereas CoreText puts everything all into the first cell (see the - // corresponding test), HarfBuzz splits into three clusters. + // corresponding test), HarfBuzz splits into two clusters. try testing.expectEqual(@as(u16, 1), cells[2].x); try testing.expectEqual(@as(u16, 1), cells[3].x); - try testing.expectEqual(@as(u16, 2), cells[4].x); - try testing.expectEqual(@as(u16, 2), cells[5].x); - try testing.expectEqual(@as(u16, 2), cells[6].x); - try testing.expectEqual(@as(u16, 2), cells[7].x); + try testing.expectEqual(@as(u16, 1), cells[4].x); + try testing.expectEqual(@as(u16, 1), cells[5].x); + try testing.expectEqual(@as(u16, 1), cells[6].x); + try testing.expectEqual(@as(u16, 1), cells[7].x); // The vowel sign E renders before the SSA: try testing.expect(cells[2].x_offset < cells[3].x_offset); From c2601dc7ec9277e8785a40b5df56e1435eb3b312 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 26 Jan 2026 10:32:58 -0500 Subject: [PATCH 597/605] don't double deinit --- src/font/shaper/harfbuzz.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index cb2bbc4bc..03cf9f1cc 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -2127,7 +2127,6 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS var name_buf: [256]u8 = undefined; const face_name = face.name(&name_buf) catch "(unknown)"; if (std.mem.indexOf(u8, face_name, font_req) == null) { - face.deinit(); return error.SkipZigTest; } From 57f3973040cfed72571170c737f0f818ad3b2789 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 26 Jan 2026 10:46:40 -0500 Subject: [PATCH 598/605] fix Tai Tham test for FreeType --- src/font/shaper/harfbuzz.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 03cf9f1cc..a16f063e1 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1125,13 +1125,17 @@ test "shape Tai Tham vowels (position differs from advance)" { count += 1; const cells = try shaper.shape(run); - const cell_width = run.grid.metrics.cell_width; try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 0), cells[1].x); - // The first glyph renders in the next cell - try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); + // The first glyph renders in the next cell. We expect the x_offset + // to equal the cell width. However, with FreeType the cell_width is + // computed from ASCII glyphs, and Noto Sans Tai Tham only has the + // space character in ASCII (with a 3px advance), so the cell_width + // metric doesn't match the actual Tai Tham glyph positioning. + const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); + try testing.expectEqual(expected_x_offset, cells[0].x_offset); try testing.expectEqual(@as(i16, 0), cells[1].x_offset); } try testing.expectEqual(@as(usize, 1), count); From 44aa761733b60143289ed922dce920d2affa2291 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 26 Jan 2026 11:00:15 -0500 Subject: [PATCH 599/605] skip testShaperWithDiscoveredFont if discovery is not available --- src/font/shaper/harfbuzz.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index a16f063e1..946611e79 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -2108,6 +2108,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { } fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestShaper { + if (font.Discover == void) return error.SkipZigTest; var lib = try Library.init(alloc); errdefer lib.deinit(); From 7123877c9c335ca3890f3b95335a608cfe298060 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 26 Jan 2026 14:33:21 -0600 Subject: [PATCH 600/605] build: don't allow `/` in branch names This should fix CI failures like in PRs #10449 and #10450 that use long automatically-generated branch names. --- src/build/GitVersion.zig | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/build/GitVersion.zig b/src/build/GitVersion.zig index bfa9af821..658eca244 100644 --- a/src/build/GitVersion.zig +++ b/src/build/GitVersion.zig @@ -19,14 +19,18 @@ branch: []const u8, pub fn detect(b: *std.Build) !Version { // Execute a bunch of git commands to determine the automatic version. var code: u8 = 0; - const branch: []const u8 = b.runAllowFail( - &[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--abbrev-ref", "HEAD" }, - &code, - .Ignore, - ) catch |err| switch (err) { - error.FileNotFound => return error.GitNotFound, - error.ExitCodeFailure => return error.GitNotRepository, - else => return err, + const branch: []const u8 = b: { + const tmp: []u8 = b.runAllowFail( + &[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--abbrev-ref", "HEAD" }, + &code, + .Ignore, + ) catch |err| switch (err) { + error.FileNotFound => return error.GitNotFound, + error.ExitCodeFailure => return error.GitNotRepository, + else => return err, + }; + std.mem.replaceScalar(u8, tmp, '/', '-'); + break :b tmp; }; const short_hash = short_hash: { From 9172f6c5384eb221cf158a460144196a45b1cf50 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 26 Jan 2026 14:43:54 -0600 Subject: [PATCH 601/605] build: include comments on why '/' is removed --- src/build/GitVersion.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/build/GitVersion.zig b/src/build/GitVersion.zig index 658eca244..566fec2e9 100644 --- a/src/build/GitVersion.zig +++ b/src/build/GitVersion.zig @@ -29,6 +29,10 @@ pub fn detect(b: *std.Build) !Version { error.ExitCodeFailure => return error.GitNotRepository, else => return err, }; + // Replace any '/' with '-' as including slashes will mess up building + // the dist tarball - the tarball uses the branch as part of the + // name and including slashes means that the tarball will end up in + // subdirectories instead of where it's supposed to be. std.mem.replaceScalar(u8, tmp, '/', '-'); break :b tmp; }; From 1952d873fec2b7de960d409fca3ef88c5f743856 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:00:10 +0000 Subject: [PATCH 602/605] build(deps): bump hustcer/milestone-action from 3.0 to 3.1 Bumps [hustcer/milestone-action](https://github.com/hustcer/milestone-action) from 3.0 to 3.1. - [Release notes](https://github.com/hustcer/milestone-action/releases) - [Changelog](https://github.com/hustcer/milestone-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/hustcer/milestone-action/compare/dcd6c3742acc1846929c054251c64cccd555a00d...ebed8d5daafd855a600d7e665c1b130f06d24130) --- updated-dependencies: - dependency-name: hustcer/milestone-action dependency-version: '3.1' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/milestone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 74f2dd7ce..49bba4e6b 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -15,7 +15,7 @@ jobs: name: Milestone Update steps: - name: Set Milestone for PR - uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0 + uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 if: github.event.pull_request.merged == true with: action: bind-pr # `bind-pr` is the default action @@ -24,7 +24,7 @@ jobs: # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue - uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0 + uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 if: github.event.issue.state == 'closed' with: action: bind-issue From 329d521d17cc5f2007425b2b1e32c953ffb3f331 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:01:15 +0000 Subject: [PATCH 603/605] build(deps): bump cachix/cachix-action Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad to 3ba601ff5bbb07c7220846facfa2cd81eeee15a1. - [Release notes](https://github.com/cachix/cachix-action/releases) - [Commits](https://github.com/cachix/cachix-action/compare/0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad...3ba601ff5bbb07c7220846facfa2cd81eeee15a1) --- updated-dependencies: - dependency-name: cachix/cachix-action dependency-version: 3ba601ff5bbb07c7220846facfa2cd81eeee15a1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 4 +- .github/workflows/release-tip.yml | 10 ++--- .github/workflows/test.yml | 54 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 0fc662ad4..90ce82989 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -50,7 +50,7 @@ jobs: uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 4d58f1128..1342c4db6 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@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -137,7 +137,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 5ad0bac46..82645c102 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -36,7 +36,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -169,7 +169,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -226,7 +226,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -460,7 +460,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -644,7 +644,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4d18a5e0..abd5901e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,7 +87,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -130,7 +130,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -163,7 +163,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -197,7 +197,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -241,7 +241,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -277,7 +277,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -306,7 +306,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -339,7 +339,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -385,7 +385,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -451,7 +451,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -494,7 +494,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -614,7 +614,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -656,7 +656,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -704,7 +704,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -739,7 +739,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -759,7 +759,7 @@ jobs: - uses: DeterminateSystems/nix-installer-action@main with: determinate: true - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -803,7 +803,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -830,7 +830,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -860,7 +860,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -889,7 +889,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -916,7 +916,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -943,7 +943,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -970,7 +970,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1002,7 +1002,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1029,7 +1029,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1066,7 +1066,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" @@ -1128,7 +1128,7 @@ jobs: - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 101c2a156..9395f19e1 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -32,7 +32,7 @@ jobs: uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 with: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" From d70eef69f9ec99b45e57ccbbb8cfe3cf458409c2 Mon Sep 17 00:00:00 2001 From: David Matos Date: Tue, 27 Jan 2026 00:51:50 +0100 Subject: [PATCH 604/605] address changes --- src/shell-integration/README.md | 4 +- .../nushell/vendor/autoload/ghostty.nu | 77 +++++++++---------- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index f3961599c..3484b0cdc 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -84,8 +84,8 @@ Nushell's vendor autoload mechanism. Ghostty then automatically imports the module using the `-e "use ghostty *"` flag when starting Nushell. Nushell provides many shell features itself, such as `title` and `cursor`, -so our integration focuses on Ghostty-specific features like `sudo`. Additionally, -we also provide `ssh-integration` via the `ssh-env` and `ssh-terminfo` features. +so our integration focuses on Ghostty-specific features like `sudo`, +`ssh-env`, and `ssh-terminfo`. The shell integration is automatically enabled when running Nushell in Ghostty, but you can also load it manually is shell integration is disabled: diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu index 6a6f83629..475a7a182 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -4,17 +4,6 @@ export module ghostty { $feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',') } - # Enables SSH environment variable compatibility. - # Converts TERM from xterm-ghostty to xterm-256color - # and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION - # check your sshd_config on remote host to see if these variables are accepted - def set_ssh_env []: nothing -> record> { - return { - ssh_term: "xterm-256color" - ssh_opts: ["-o" "SetEnv COLORTERM=truecolor" "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] - } - } - # Enables automatic terminfo installation on remote hosts. # Attempts to install Ghostty's terminfo entry using infocmp and tic when # connecting to hosts that lack it. @@ -24,7 +13,6 @@ export module ghostty { ssh_opts: list ssh_args: list ]: [nothing -> record>] { - mut ssh_opts = $ssh_opts let ssh_cfg = ^ssh -G ...($ssh_args) | lines | parse "{key} {value}" @@ -43,20 +31,16 @@ export module ghostty { ) if not $is_cached { - let ssh_opts_copy = $ssh_opts let terminfo_data = try { ^infocmp -0 -x xterm-ghostty } catch { print "Warning: Could not generate terminfo data." - return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts_copy} + return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts} } print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." let ctrl_path = ( - try { - mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" - } catch { - $"/tmp/ghostty-ssh-($ssh_cfg.user).($nu.pid)" - } | path join "socket" + mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" + | path join "socket" ) let master_parts = $ssh_opts ++ ["-o" "ControlMaster=yes" "-o" $"ControlPath=($ctrl_path)" "-o" "ControlPersist=60s"] ++ $ssh_args @@ -78,12 +62,45 @@ export module ghostty { } ^$ghostty_bin ...(["+ssh-cache" $"--add=($ssh_id)"]) o+e>| ignore - $ssh_opts ++= ["-o" $"ControlPath=($ctrl_path)"] + + return {ssh_term: "xterm-ghostty" ssh_opts: ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"])} } return {ssh_term: "xterm-ghostty" ssh_opts: $ssh_opts} } + # Wrap `ssh` with Ghostty TERMINFO support + export def --wrapped ssh [...ssh_args: string]: any -> any { + if ($ssh_args | is-empty) { + return (^ssh) + } + # `ssh-env` enables SSH environment variable compatibility. + # Converts TERM from xterm-ghostty to xterm-256color + # and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION + # Check your sshd_config on remote host to see if these variables are accepted + let base_ssh_opts = if (has_feature "ssh-env") { + ["-o" "SetEnv COLORTERM=truecolor" "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + } else { + [] + } + let base_ssh_term = if (has_feature "ssh-env") { + "xterm-256color" + } else { + ($env.TERM? | default "") + } + + let session = if (has_feature "ssh-terminfo") { + set_ssh_terminfo $base_ssh_opts $ssh_args + } else { + {ssh_term: $base_ssh_term ssh_opts: $base_ssh_opts} + } + + let ssh_parts = $session.ssh_opts ++ $ssh_args + with-env {TERM: $session.ssh_term} { + ^ssh ...$ssh_parts + } + } + # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable export def --wrapped sudo [ ...args # Arguments to pass to `sudo` @@ -106,26 +123,6 @@ export module ghostty { ^sudo ...$sudo_args } - # Wrap `ssh` to provide ghostty `ssh-integration` - export def --wrapped ssh [...ssh_args: string]: any -> any { - if ($ssh_args | is-empty) { - return (^ssh) - } - mut session = {ssh_term: "" ssh_opts: []} - let shell_features = $env.GHOSTTY_SHELL_FEATURES | split row ',' - - if (has_feature "ssh-env") { - $session = set_ssh_env - } - if (has_feature "ssh-terminfo") { - $session = set_ssh_terminfo $session.ssh_opts $ssh_args - } - - let ssh_parts = $session.ssh_opts ++ $ssh_args - with-env {TERM: $session.ssh_term} { - ^ssh ...$ssh_parts - } - } } # Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR From 675fa34e66ae6bdd7468f7c78b601fbd0c96a338 Mon Sep 17 00:00:00 2001 From: David Matos Date: Tue, 27 Jan 2026 00:59:39 +0100 Subject: [PATCH 605/605] unnecesary bind --- src/shell-integration/nushell/vendor/autoload/ghostty.nu | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/src/shell-integration/nushell/vendor/autoload/ghostty.nu index 475a7a182..93e5fd909 100644 --- a/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -95,9 +95,8 @@ export module ghostty { {ssh_term: $base_ssh_term ssh_opts: $base_ssh_opts} } - let ssh_parts = $session.ssh_opts ++ $ssh_args with-env {TERM: $session.ssh_term} { - ^ssh ...$ssh_parts + ^ssh ...($session.ssh_opts ++ $ssh_args) } }