diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f8d2671c..81d58a1ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -376,6 +376,41 @@ jobs: -Dgtk-adwaita=${{ matrix.adwaita }} \ -Dgtk-x11=${{ matrix.x11 }} + test-sentry-linux: + strategy: + fail-fast: false + matrix: + sentry: ["true", "false"] + name: Build -Dsentry=${{ matrix.sentry }} + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test Sentry Build + run: | + nix develop -c zig build -Dsentry=${{ matrix.sentry }} + test-macos: runs-on: namespace-profile-ghostty-macos needs: test @@ -478,3 +513,38 @@ jobs: useDaemon: false # sometimes fails on short jobs - name: typos check run: nix develop -c typos + + test-pkg-linux: + strategy: + fail-fast: false + matrix: + pkg: ["wuffs"] + name: Test pkg/${{ matrix.pkg }} + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test ${{ matrix.pkg }} Build + run: | + nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" diff --git a/build.zig b/build.zig index 3ba8b6b64..ceb7ed381 100644 --- a/build.zig +++ b/build.zig @@ -24,6 +24,8 @@ const XCFrameworkStep = @import("src/build/XCFrameworkStep.zig"); const Version = @import("src/build/Version.zig"); const Command = @import("src/Command.zig"); +const Scanner = @import("zig_wayland").Scanner; + comptime { // This is the required Zig version for building this project. We allow // any patch version but the major and minor must match exactly. @@ -105,19 +107,19 @@ pub fn build(b: *std.Build) !void { "Enables the use of Adwaita when using the GTK rendering backend.", ) orelse true; - config.x11 = b.option( - bool, - "gtk-x11", - "Enables linking against X11 libraries when using the GTK rendering backend.", - ) orelse x11: { - if (target.result.os.tag != .linux) break :x11 false; + var x11 = false; + var wayland = false; + if (target.result.os.tag == .linux) pkgconfig: { var pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator); pkgconfig.stdout_behavior = .Pipe; pkgconfig.stderr_behavior = .Pipe; - try pkgconfig.spawn(); + pkgconfig.spawn() catch |err| { + std.log.warn("failed to spawn pkg-config - disabling X11 and Wayland integrations: {}", .{err}); + break :pkgconfig; + }; const output_max_size = 50 * 1024; @@ -139,17 +141,44 @@ pub fn build(b: *std.Build) !void { switch (term) { .Exited => |code| { if (code == 0) { - if (std.mem.indexOf(u8, stdout.items, "x11")) |_| break :x11 true; - break :x11 false; + if (std.mem.indexOf(u8, stdout.items, "x11")) |_| x11 = true; + if (std.mem.indexOf(u8, stdout.items, "wayland")) |_| wayland = true; + } else { + std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); + return error.Unexpected; } - std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - return error.Unexpected; }, inline else => |code| { std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); return error.Unexpected; }, } + } + + config.x11 = b.option( + bool, + "gtk-x11", + "Enables linking against X11 libraries when using the GTK rendering backend.", + ) orelse x11; + + config.wayland = b.option( + bool, + "gtk-wayland", + "Enables linking against Wayland libraries when using the GTK rendering backend.", + ) orelse wayland; + + config.sentry = b.option( + bool, + "sentry", + "Build with Sentry crash reporting. Default for macOS is true, false for any other system.", + ) orelse sentry: { + switch (target.result.os.tag) { + .macos, .ios => break :sentry true, + + // Note its false for linux because the crash reports on Linux + // don't have much useful information. + else => break :sentry false, + } }; const pie = b.option( @@ -158,6 +187,16 @@ pub fn build(b: *std.Build) !void { "Build a Position Independent Executable. Default true for system packages.", ) orelse system_package; + const strip = b.option( + bool, + "strip", + "Strip the final executable. Default true for fast and small releases", + ) orelse switch (optimize) { + .Debug => false, + .ReleaseSafe => false, + .ReleaseFast, .ReleaseSmall => true, + }; + const conformance = b.option( []const u8, "conformance", @@ -342,11 +381,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, - .strip = switch (optimize) { - .Debug => false, - .ReleaseSafe => false, - .ReleaseFast, .ReleaseSmall => true, - }, + .strip = strip, }) else null; // Exe @@ -669,7 +704,12 @@ pub fn build(b: *std.Build) !void { b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png"); + + // Flatpaks only support icons up to 512x512. + if (!config.flatpak) { + b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png"); + } + b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png"); b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png"); @@ -685,6 +725,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main_c.zig"), .optimize = optimize, .target = target, + .strip = strip, }); _ = try addDeps(b, lib, config); @@ -702,6 +743,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main_c.zig"), .optimize = optimize, .target = target, + .strip = strip, }); _ = try addDeps(b, lib, config); @@ -1240,13 +1282,15 @@ fn addDeps( } // Sentry - const sentry_dep = b.dependency("sentry", .{ - .target = target, - .optimize = optimize, - .backend = .breakpad, - }); - step.root_module.addImport("sentry", sentry_dep.module("sentry")); - if (target.result.os.tag != .windows) { + if (config.sentry) { + const sentry_dep = b.dependency("sentry", .{ + .target = target, + .optimize = optimize, + .backend = .breakpad, + }); + + step.root_module.addImport("sentry", sentry_dep.module("sentry")); + // Sentry step.linkLibrary(sentry_dep.artifact("sentry")); try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin()); @@ -1430,6 +1474,24 @@ fn addDeps( if (config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); if (config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); + if (config.wayland) { + const scanner = Scanner.create(b, .{}); + + const wayland = b.createModule(.{ .root_source_file = scanner.result }); + + const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{ + .target = target, + .optimize = optimize, + }); + scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml")); + + scanner.generate("wl_compositor", 1); + scanner.generate("org_kde_kwin_blur_manager", 1); + + step.root_module.addImport("wayland", wayland); + step.linkSystemLibrary2("wayland-client", dynamic_link_opts); + } + { const gresource = @import("src/apprt/gtk/gresource.zig"); diff --git a/build.zig.zon b/build.zig.zon index 5c202e9cd..33be26193 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -25,6 +25,10 @@ .url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz", .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", }, + .zig_wayland = .{ + .url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz", + .hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c", + }, // C libs .cimgui = .{ .path = "./pkg/cimgui" }, @@ -49,8 +53,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", - .hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz", + .hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", @@ -64,5 +68,9 @@ .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a", .hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", }, + .plasma_wayland_protocols = .{ + .url = "git+https://invent.kde.org/libraries/plasma-wayland-protocols.git?ref=master#db525e8f9da548cffa2ac77618dd0fbe7f511b86", + .hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566", + }, }, } diff --git a/default.nix b/default.nix new file mode 100644 index 000000000..d6bf5743f --- /dev/null +++ b/default.nix @@ -0,0 +1,13 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) {src = ./.;}) +.defaultNix diff --git a/include/ghostty.h b/include/ghostty.h index 61c3aad32..0e444a2fa 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -375,9 +375,9 @@ typedef enum { typedef enum { GHOSTTY_GOTO_SPLIT_PREVIOUS, GHOSTTY_GOTO_SPLIT_NEXT, - GHOSTTY_GOTO_SPLIT_TOP, + GHOSTTY_GOTO_SPLIT_UP, GHOSTTY_GOTO_SPLIT_LEFT, - GHOSTTY_GOTO_SPLIT_BOTTOM, + GHOSTTY_GOTO_SPLIT_DOWN, GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; @@ -559,6 +559,7 @@ typedef struct { // apprt.Action.Key typedef enum { + GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_TAB, GHOSTTY_ACTION_NEW_SPLIT, @@ -681,10 +682,11 @@ void ghostty_config_open(); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, ghostty_config_t); void ghostty_app_free(ghostty_app_t); -bool ghostty_app_tick(ghostty_app_t); +void ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); void ghostty_app_set_focus(ghostty_app_t, bool); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); +bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); @@ -712,7 +714,8 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); -void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_button(ghostty_surface_t, diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 42479f0b3..1e37006c2 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -10,8 +10,8 @@ 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; - 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; + 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -71,6 +71,7 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -87,6 +88,8 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; }; + A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; @@ -108,8 +111,8 @@ 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; - 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; + 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; @@ -162,6 +165,7 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; @@ -177,6 +181,8 @@ A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = ""; }; + A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Event.swift; sourceTree = ""; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = ""; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -263,6 +269,7 @@ A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, + A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, @@ -351,12 +358,14 @@ A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, + A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, + A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, ); path = Ghostty; sourceTree = ""; @@ -399,13 +408,13 @@ children = ( FC9ABA9B2D0F538D0020D4C8 /* bash-completion */, 29C15B1C2CDC3B2000520DD4 /* bat */, - 55154BDF2B33911F001622DC /* ghostty */, - 552964E52B34A9B400030505 /* vim */, A586167B2B7703CC009BDB1D /* fish */, + 55154BDF2B33911F001622DC /* ghostty */, A5985CE52C33060F00C57AD3 /* man */, - A5A1F8842A489D6800D1E8BC /* terminfo */, - FC5218F92D10FFC7004C93E0 /* zsh */, 9351BE8E2D22937F003B3499 /* nvim */, + A5A1F8842A489D6800D1E8BC /* terminfo */, + 552964E52B34A9B400030505 /* vim */, + FC5218F92D10FFC7004C93E0 /* zsh */, ); name = Resources; sourceTree = ""; @@ -611,12 +620,14 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, + A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, @@ -647,6 +658,7 @@ A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, + A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 6d27bdf94..70873236a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -358,8 +358,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) @@ -425,6 +425,15 @@ class AppDelegate: NSObject, // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } + // If this event as-is would result in a key binding then we send it. + if let app = ghostty.app, + ghostty_app_key_is_binding( + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + ghostty_app_key(app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) + return nil + } + // If this event would be handled by our menu then we do nothing. if let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { @@ -438,13 +447,7 @@ class AppDelegate: NSObject, guard let ghostty = self.ghostty.app else { return event } // Build our event input and call ghostty - var key_ev = ghostty_input_key_s() - key_ev.action = GHOSTTY_ACTION_PRESS - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false - if (ghostty_app_key(ghostty, key_ev)) { + if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { // The key was used so we want to stop it from going to our Mac app Ghostty.logger.debug("local key event handled event=\(event)") return nil @@ -486,15 +489,16 @@ class AppDelegate: NSObject, // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is // explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior - // defined by our "auto-update" configuration. - if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false { - updaterController.updater.automaticallyChecksForUpdates = - config.autoUpdate == .check || config.autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = - config.autoUpdate == .download - } else { + // defined by our "auto-update" configuration (if set) or fall back to Sparkle + // user-based defaults. + if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false { updaterController.updater.automaticallyChecksForUpdates = false updaterController.updater.automaticallyDownloadsUpdates = false + } else if let autoUpdate = config.autoUpdate { + updaterController.updater.automaticallyChecksForUpdates = + autoUpdate == .check || autoUpdate == .download + updaterController.updater.automaticallyDownloadsUpdates = + autoUpdate == .download } // Config could change keybindings, so update everything that depends on that diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 3d2a2a045..0acbfec1b 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -89,13 +89,13 @@ enum QuickTerminalPosition : String { return .init(x: screen.frame.minX, y: -window.frame.height) case .left: - return .init(x: -window.frame.width, y: 0) + return .init(x: screen.frame.minX-window.frame.width, y: 0) case .right: return .init(x: screen.frame.maxX, y: 0) case .center: - return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: screen.visibleFrame.maxY - window.frame.width) + return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.height - window.frame.width) } } @@ -115,7 +115,7 @@ enum QuickTerminalPosition : String { return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) case .center: - return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: (screen.visibleFrame.maxY - window.frame.height) / 2) + return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) } } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 01b211730..393c6ef4d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -540,11 +540,11 @@ class BaseTerminalController: NSWindowController, } @IBAction func splitMoveFocusAbove(_ sender: Any) { - splitMoveFocus(direction: .top) + splitMoveFocus(direction: .up) } @IBAction func splitMoveFocusBelow(_ sender: Any) { - splitMoveFocus(direction: .bottom) + splitMoveFocus(direction: .down) } @IBAction func splitMoveFocusLeft(_ sender: Any) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c3b332cd4..2da498e3a 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController { // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } + if (!(fullscreenStyle?.isFullscreen ?? false) && + ghostty.config.macosTitlebarStyle == "hidden") + { + applyHiddenTitlebarStyle() + } + syncAppearance(focusedSurface.derivedConfig) } @@ -244,7 +250,9 @@ class TerminalController: BaseTerminalController { let backgroundColor: OSColor if let surfaceTree { if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.0) + // Similar to above, an alpha component of "0" causes compositor issues, so + // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308 + backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001) } else { // We don't have a focused surface or our surface doesn't border the // top. We choose to match the color of the top-left most surface. @@ -267,6 +275,28 @@ class TerminalController: BaseTerminalController { } } + private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + guard let window else { return } + + // If we don't have both an X and Y we center. + guard let x, let y else { + window.center() + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = window.screen ?? NSScreen.screens.first else { + window.center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + window.setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + window.frame.height))) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -274,6 +304,43 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } + fileprivate func applyHiddenTitlebarStyle() { + guard let window else { return } + + window.styleMask = [ + // We need `titled` in the mask to get the normal window frame + .titled, + + // Full size content view so we can extend + // content in to the hidden titlebar's area + .fullSizeContentView, + + .resizable, + .closable, + .miniaturizable, + ] + + // Hide the title + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + + // Hide the traffic lights (window control buttons) + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. + window.tabbingMode = .disallowed + + // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are + // some operations that appear to bring back the titlebar visibility so this ensures + // it is gone forever. + if let themeFrame = window.contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + override func windowDidLoad() { super.windowDidLoad() guard let window = window as? TerminalWindow else { return } @@ -325,9 +392,12 @@ class TerminalController: BaseTerminalController { } } - // Center the window to start, we'll move the window frame automatically - // when cascading. - window.center() + // Set our window positioning to coordinates if config value exists, otherwise + // fallback to original centering behavior + setInitialWindowPosition( + x: config.windowPositionX, + y: config.windowPositionY, + windowDecorations: config.windowDecorations) // Make sure our theme is set on the window so styling is correct. if let windowTheme = config.windowTheme { @@ -365,38 +435,7 @@ class TerminalController: BaseTerminalController { // If our titlebar style is "hidden" we adjust the style appropriately if (config.macosTitlebarStyle == "hidden") { - window.styleMask = [ - // We need `titled` in the mask to get the normal window frame - .titled, - - // Full size content view so we can extend - // content in to the hidden titlebar's area - .fullSizeContentView, - - .resizable, - .closable, - .miniaturizable, - ] - - // Hide the title - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - - // Hide the traffic lights (window control buttons) - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - - // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. - window.tabbingMode = .disallowed - - // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are - // some operations that appear to bring back the titlebar visibility so this ensures - // it is gone forever. - if let themeFrame = window.contentView?.superview, - let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } + applyHiddenTitlebarStyle() } // In various situations, macOS automatically tabs new windows. Ghostty handles diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2d9822d6e..ed140dcd5 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -117,23 +117,7 @@ extension Ghostty { func appTick() { guard let app = self.app else { return } - - // Tick our app, which lets us know if we want to quit - let exit = ghostty_app_tick(app) - if (!exit) { return } - - // On iOS, applications do not terminate programmatically like they do - // on macOS. On iOS, applications are only terminated when a user physically - // closes the application (i.e. going to the home screen). If we request - // exit on iOS we ignore it. - #if os(iOS) - logger.info("quit request received, ignoring on iOS") - #endif - - #if os(macOS) - // We want to quit, start that process - NSApplication.shared.terminate(nil) - #endif + ghostty_app_tick(app) } func openConfig() { @@ -454,6 +438,9 @@ extension Ghostty { // Action dispatch switch (action.tag) { + case GHOSTTY_ACTION_QUIT: + quit(app) + case GHOSTTY_ACTION_NEW_WINDOW: newWindow(app, target: target) @@ -559,6 +546,21 @@ extension Ghostty { } } + private static func quit(_ app: ghostty_app_t) { + // On iOS, applications do not terminate programmatically like they do + // on macOS. On iOS, applications are only terminated when a user physically + // closes the application (i.e. going to the home screen). If we request + // exit on iOS we ignore it. + #if os(iOS) + logger.info("quit request received, ignoring on iOS") + #endif + + #if os(macOS) + // We want to quit, start that process + NSApplication.shared.terminate(nil) + #endif + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1e733c5e1..ed9364914 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -149,6 +149,20 @@ extension Ghostty { guard let ptr = v else { return "" } return String(cString: ptr) } + + var windowPositionX: Int16? { + guard let config = self.config else { return nil } + var v: Int16 = 0 + let key = "window-position-x" + return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + } + + var windowPositionY: Int16? { + guard let config = self.config else { return nil } + var v: Int16 = 0 + let key = "window-position-y" + return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + } var windowNewTabPosition: String { guard let config = self.config else { return "" } @@ -361,13 +375,24 @@ extension Ghostty { ) } - // This isn't actually a configurable value currently but it could be done day. - // We put it here because it is a color that changes depending on the configuration. var splitDividerColor: Color { let backgroundColor = OSColor(backgroundColor) let isLightBackground = backgroundColor.isLightColor let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4) - return Color(newColor) + + guard let config = self.config else { return Color(newColor) } + + var color: ghostty_config_color_s = .init(); + let key = "split-divider-color" + if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + return Color(newColor) + } + + return .init( + red: Double(color.r) / 255, + green: Double(color.g) / 255, + blue: Double(color.b) / 255 + ) } #if canImport(AppKit) @@ -437,15 +462,14 @@ extension Ghostty { return v; } - var autoUpdate: AutoUpdate { - let defaultValue = AutoUpdate.check - guard let config = self.config else { return defaultValue } + var autoUpdate: AutoUpdate? { + guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } - guard let ptr = v else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard let ptr = v else { return nil } let str = String(cString: ptr) - return AutoUpdate(rawValue: str) ?? defaultValue + return AutoUpdate(rawValue: str) } var autoUpdateChannel: AutoUpdateChannel { diff --git a/macos/Sources/Ghostty/Ghostty.Event.swift b/macos/Sources/Ghostty/Ghostty.Event.swift new file mode 100644 index 000000000..1cde50ee7 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Event.swift @@ -0,0 +1,15 @@ +import Cocoa +import GhosttyKit + +extension Ghostty { + /// A comparable event. + struct ComparableKeyEvent: Equatable { + let keyCode: UInt16 + let flags: NSEvent.ModifierFlags + + init(event: NSEvent) { + self.keyCode = event.keyCode + self.flags = event.modifierFlags + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index f863eeada..899825d37 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -51,7 +51,7 @@ extension Ghostty { /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the /// next view to send focus to. - func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { + func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView { let container: Container switch (self) { case .leaf(let leaf): @@ -64,10 +64,10 @@ extension Ghostty { let node: SplitNode switch (direction) { - case .previous, .top, .left: + case .previous, .up, .left: node = container.bottomRight - case .next, .bottom, .right: + case .next, .down, .right: node = container.topLeft } @@ -431,12 +431,12 @@ extension Ghostty { struct Neighbors { var left: SplitNode? var right: SplitNode? - var top: SplitNode? - var bottom: SplitNode? + var up: SplitNode? + var down: SplitNode? /// These are the previous/next nodes. It will certainly be one of the above as well /// but we keep track of these separately because depending on the split direction - /// of the containing node, previous may be left OR top (same for next). + /// of the containing node, previous may be left OR up (same for next). var previous: SplitNode? var next: SplitNode? @@ -448,8 +448,8 @@ extension Ghostty { let map: [SplitFocusDirection : KeyPath] = [ .previous: \.previous, .next: \.next, - .top: \.top, - .bottom: \.bottom, + .up: \.up, + .down: \.down, .left: \.left, .right: \.right, ] diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 272cdabdb..cc3bef149 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -308,7 +308,7 @@ extension Ghostty { resizeIncrements: .init(width: 1, height: 1), resizePublisher: container.resizeEvent, left: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.down TerminalSplitNested( node: closeableTopLeft(), @@ -318,7 +318,7 @@ extension Ghostty { ]) ) }, right: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.top + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.up TerminalSplitNested( node: closeableBottomRight(), diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift new file mode 100644 index 000000000..4118cd94d --- /dev/null +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -0,0 +1,15 @@ +import Cocoa +import GhosttyKit + +extension NSEvent { + /// Create a Ghostty key event for a given keyboard action. + func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s { + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(modifierFlags) + key_ev.keycode = UInt32(keyCode) + key_ev.text = nil + key_ev.composing = false + return key_ev + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 65f928443..d09100212 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -66,7 +66,7 @@ extension Ghostty { /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { - case previous, next, top, bottom, left, right + case previous, next, up, down, left, right /// Initialize from a Ghostty API enum. static func from(direction: ghostty_action_goto_split_e) -> Self? { @@ -77,11 +77,11 @@ extension Ghostty { case GHOSTTY_GOTO_SPLIT_NEXT: return .next - case GHOSTTY_GOTO_SPLIT_TOP: - return .top + case GHOSTTY_GOTO_SPLIT_UP: + return .up - case GHOSTTY_GOTO_SPLIT_BOTTOM: - return .bottom + case GHOSTTY_GOTO_SPLIT_DOWN: + return .down case GHOSTTY_GOTO_SPLIT_LEFT: return .left @@ -102,11 +102,11 @@ extension Ghostty { case .next: return GHOSTTY_GOTO_SPLIT_NEXT - case .top: - return GHOSTTY_GOTO_SPLIT_TOP + case .up: + return GHOSTTY_GOTO_SPLIT_UP - case .bottom: - return GHOSTTY_GOTO_SPLIT_BOTTOM + case .down: + return GHOSTTY_GOTO_SPLIT_DOWN case .left: return GHOSTTY_GOTO_SPLIT_LEFT diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2cac4a0dd..4e0550cc2 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -113,6 +113,9 @@ extension Ghostty { // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? + /// Event monitor (see individual events for why) + private var eventMonitor: Any? = nil + // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -170,6 +173,15 @@ extension Ghostty { name: NSWindow.didChangeScreenNotification, object: nil) + // Listen for local events that we need to know of outside of + // single surface handlers. + self.eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [ + // We need keyUp because command+key events don't trigger keyUp. + .keyUp + ] + ) { [weak self] event in self?.localEventHandler(event) } + // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) @@ -212,6 +224,11 @@ extension Ghostty { let center = NotificationCenter.default center.removeObserver(self) + // Remove our event monitor + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + // Whenever the surface is removed, we need to note that our restorable // state is invalid to prevent the surface from being restored. invalidateRestorableState() @@ -356,6 +373,30 @@ extension Ghostty { } } + // MARK: Local Events + + private func localEventHandler(_ event: NSEvent) -> NSEvent? { + return switch event.type { + case .keyUp: + localEventKeyUp(event) + + default: + event + } + } + + private func localEventKeyUp(_ event: NSEvent) -> NSEvent? { + // We only care about events with "command" because all others will + // trigger the normal responder chain. + if (!event.modifierFlags.contains(.command)) { return event } + + // Command keyUp events are never sent to the normal responder chain + // so we send them here. + guard focused else { return event } + self.keyUp(with: event) + return nil + } + // MARK: - Notifications @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { @@ -764,8 +805,23 @@ extension Ghostty { // know if these events cleared it. let markedTextBefore = markedText.length > 0 + // We need to know the keyboard layout before below because some keyboard + // input events will change our keyboard layout and we don't want those + // going to the terminal. + let keyboardIdBefore: String? = if (!markedTextBefore) { + KeyboardLayout.id + } else { + nil + } + self.interpretKeyEvents([translationEvent]) + // If our keyboard changed from this we just assume an input method + // grabbed it and do nothing. + if (!markedTextBefore && keyboardIdBefore != KeyboardLayout.id) { + return + } + // If we have text, then we've composed a character, send that down. We do this // first because if we completed a preedit, the text will be available here // AND we'll have a preedit. @@ -773,7 +829,7 @@ extension Ghostty { if let list = keyTextAccumulator, list.count > 0 { handled = true for text in list { - keyAction(action, event: event, text: text) + _ = keyAction(action, event: event, text: text) } } @@ -783,38 +839,49 @@ extension Ghostty { // the preedit. if (markedText.length > 0 || markedTextBefore) { handled = true - keyAction(action, event: event, preedit: markedText.string) + _ = keyAction(action, event: event, preedit: markedText.string) } if (!handled) { // No text or anything, we want to handle this manually. - keyAction(action, event: event) + _ = keyAction(action, event: event) } } override func keyUp(with event: NSEvent) { - keyAction(GHOSTTY_ACTION_RELEASE, event: event) + _ = keyAction(GHOSTTY_ACTION_RELEASE, event: event) } /// Special case handling for some control keys override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Only process key down events - if (event.type != .keyDown) { + switch (event.type) { + case .keyDown: + // Continue, we care about key down events + break + + default: + // Any other key event we don't care about. I don't think its even + // possible to receive any other event type. return false } // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the // the first responder (I don't know why). This prevents us from handling it. + // Besides C-/, its important we don't process key equivalents if unfocused + // because there are other event listeners for that (i.e. AppDelegate's + // local event handler). if (!focused) { return false } - // Only process keys when Control is active. All known issues we're - // resolving happen only in this scenario. This probably isn't fully robust - // but we can broaden the scope as we find more cases. - if (!event.modifierFlags.contains(.control)) { - return false + // If this event as-is would result in a key binding then we send it. + if let surface, + ghostty_surface_key_is_binding( + surface, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + self.keyDown(with: event) + return true } let equivalent: String @@ -832,14 +899,25 @@ extension Ghostty { case "\r": // Pass C- through verbatim // (prevent the default context menu equivalent) + if (!event.modifierFlags.contains(.control)) { + return false + } + equivalent = "\r" + case ".": + if (!event.modifierFlags.contains(.command)) { + return false + } + + equivalent = "." + default: // Ignore other events return false } - let newEvent = NSEvent.keyEvent( + let finalEvent = NSEvent.keyEvent( with: .keyDown, location: event.locationInWindow, modifierFlags: event.modifierFlags, @@ -852,7 +930,7 @@ extension Ghostty { keyCode: event.keyCode ) - self.keyDown(with: newEvent!) + self.keyDown(with: finalEvent!) return true } @@ -897,45 +975,38 @@ extension Ghostty { } } - keyAction(action, event: event) + _ = keyAction(action, event: event) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { - guard let surface = self.surface else { return } - - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false - ghostty_surface_key(surface, key_ev) + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool { + guard let surface = self.surface else { return false } + return ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { - guard let surface = self.surface else { return } + private func keyAction( + _ action: ghostty_input_action_e, + event: NSEvent, preedit: String + ) -> Bool { + guard let surface = self.surface else { return false } - preedit.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) + return preedit.withCString { ptr in + var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr key_ev.composing = true - ghostty_surface_key(surface, key_ev) + return ghostty_surface_key(surface, key_ev) } } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { - guard let surface = self.surface else { return } + private func keyAction( + _ action: ghostty_input_action_e, + event: NSEvent, text: String + ) -> Bool { + guard let surface = self.surface else { return false } - text.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) + return text.withCString { ptr in + var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr - ghostty_surface_key(surface, key_ev) + return ghostty_surface_key(surface, key_ev) } } diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift new file mode 100644 index 000000000..8e573f495 --- /dev/null +++ b/macos/Sources/Helpers/KeyboardLayout.swift @@ -0,0 +1,14 @@ +import Carbon + +class KeyboardLayout { + /// Return a string ID of the current keyboard input source. + static var id: String? { + if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) { + let sourceId = unsafeBitCast(sourceIdPointer, to: CFString.self) + return sourceId as String + } + + return nil + } +} diff --git a/nix/devShell.nix b/nix/devShell.nix index 5e86427fe..c52afb6c0 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -51,6 +51,9 @@ pandoc, hyperfine, typos, + wayland, + wayland-scanner, + wayland-protocols, }: let # See package.nix. Keep in sync. rpathLibs = @@ -80,6 +83,7 @@ libadwaita gtk4 glib + wayland ]; in mkShell { @@ -153,6 +157,9 @@ in libadwaita gtk4 glib + wayland + wayland-scanner + wayland-protocols ]; # This should be set onto the rpath of the ghostty binary if you want diff --git a/nix/package.nix b/nix/package.nix index 78d2e2fdd..166a3c4fb 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -10,10 +10,6 @@ oniguruma, zlib, libGL, - libX11, - libXcursor, - libXi, - libXrandr, glib, gtk4, libadwaita, @@ -26,7 +22,15 @@ pandoc, revision ? "dirty", optimize ? "Debug", - x11 ? true, + enableX11 ? true, + libX11, + libXcursor, + libXi, + libXrandr, + enableWayland ? true, + wayland, + wayland-protocols, + wayland-scanner, }: let # The Zig hook has no way to select the release type without actual # overriding of the default flags. @@ -114,14 +118,19 @@ in version = "1.0.2"; inherit src; - nativeBuildInputs = [ - git - ncurses - pandoc - pkg-config - zig_hook - wrapGAppsHook4 - ]; + nativeBuildInputs = + [ + git + ncurses + pandoc + pkg-config + zig_hook + wrapGAppsHook4 + ] + ++ lib.optionals enableWayland [ + wayland-scanner + wayland-protocols + ]; buildInputs = [ @@ -142,16 +151,19 @@ in glib gsettings-desktop-schemas ] - ++ lib.optionals x11 [ + ++ lib.optionals enableX11 [ libX11 libXcursor libXi libXrandr + ] + ++ lib.optionals enableWayland [ + wayland ]; dontConfigure = true; - zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString x11}"; + zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString enableX11} -Dgtk-wayland=${lib.boolToString enableWayland}"; preBuild = '' rm -rf $ZIG_GLOBAL_CACHE_DIR diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index d4d451e03..f2592adf4 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-ot5onG1yq7EWQkNUgTNBuqvsnLuaoFs2UDS96IqgJmU=" +"sha256-eUY6MS3//r6pA/w9b+E4+YqmqUbzpUfL3afJJlnMhLY=" diff --git a/pkg/cimgui/build.zig.zon b/pkg/cimgui/build.zig.zon index 9d537c79a..2f2c9cfa0 100644 --- a/pkg/cimgui/build.zig.zon +++ b/pkg/cimgui/build.zig.zon @@ -1,10 +1,10 @@ .{ .name = "cimgui", - .version = "1.89.9", + .version = "1.90.6", // -docking branch .paths = .{""}, .dependencies = .{ // This should be kept in sync with the submodule in the cimgui source - // code to be safe that they're compatible. + // code in ./vendor/ to be safe that they're compatible. .imgui = .{ .url = "https://github.com/ocornut/imgui/archive/e391fe2e66eb1c96b1624ae8444dc64c23146ef4.tar.gz", .hash = "1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402", diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig index 5a4ce8eeb..96c4ffe4a 100644 --- a/pkg/fontconfig/build.zig +++ b/pkg/fontconfig/build.zig @@ -13,7 +13,56 @@ pub fn build(b: *std.Build) !void { ) orelse (target.result.os.tag != .windows); const freetype_enabled = b.option(bool, "enable-freetype", "Build freetype") orelse true; - const module = b.addModule("fontconfig", .{ .root_source_file = b.path("main.zig") }); + const module = b.addModule("fontconfig", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + // For dynamic linking, we prefer dynamic linking and to search by + // mode first. Mode first will search all paths for a dynamic library + // before falling back to static. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + + const test_exe = b.addTest(.{ + .name = "test", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + const tests_run = b.addRunArtifact(test_exe); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); + + if (b.systemIntegrationOption("fontconfig", .{})) { + module.linkSystemLibrary("fontconfig", dynamic_link_opts); + test_exe.linkSystemLibrary2("fontconfig", dynamic_link_opts); + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .libxml2_enabled = libxml2_enabled, + .libxml2_iconv_enabled = libxml2_iconv_enabled, + .freetype_enabled = freetype_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + test_exe.linkLibrary(lib); + } +} + +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const libxml2_enabled = options.libxml2_enabled; + const libxml2_iconv_enabled = options.libxml2_iconv_enabled; + const freetype_enabled = options.freetype_enabled; const upstream = b.dependency("fontconfig", .{}); const lib = b.addStaticLibrary(.{ @@ -131,19 +180,13 @@ pub fn build(b: *std.Build) !void { } } - // For dynamic linking, we prefer dynamic linking and to search by - // mode first. Mode first will search all paths for a dynamic library - // before falling back to static. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; + const dynamic_link_opts = options.dynamic_link_opts; // Freetype2 _ = b.systemIntegrationOption("freetype", .{}); // So it shows up in help if (freetype_enabled) { if (b.systemIntegrationOption("freetype", .{})) { - lib.linkSystemLibrary2("freetype", dynamic_link_opts); + lib.linkSystemLibrary2("freetype2", dynamic_link_opts); } else { const freetype_dep = b.dependency( "freetype", @@ -194,16 +237,7 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - const test_exe = b.addTest(.{ - .name = "test", - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - test_exe.linkLibrary(lib); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); + return lib; } const headers = &.{ diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index 46458c15c..6ff4f4340 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -5,7 +5,61 @@ pub fn build(b: *std.Build) !void { const optimize = b.standardOptimizeOption(.{}); const libpng_enabled = b.option(bool, "enable-libpng", "Build libpng") orelse false; - const module = b.addModule("freetype", .{ .root_source_file = b.path("main.zig") }); + const module = b.addModule("freetype", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + // For dynamic linking, we prefer dynamic linking and to search by + // mode first. Mode first will search all paths for a dynamic library + // before falling back to static. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + + var test_exe: ?*std.Build.Step.Compile = null; + if (target.query.isNative()) { + test_exe = b.addTest(.{ + .name = "test", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + const tests_run = b.addRunArtifact(test_exe.?); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); + } + + module.addIncludePath(b.path("")); + + if (b.systemIntegrationOption("freetype", .{})) { + module.linkSystemLibrary("freetype2", dynamic_link_opts); + if (test_exe) |exe| { + exe.linkSystemLibrary2("freetype2", dynamic_link_opts); + } + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .libpng_enabled = libpng_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + if (test_exe) |exe| { + exe.linkLibrary(lib); + } + } +} + +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const libpng_enabled = options.libpng_enabled; const upstream = b.dependency("freetype", .{}); const lib = b.addStaticLibrary(.{ @@ -21,16 +75,6 @@ pub fn build(b: *std.Build) !void { } module.addIncludePath(upstream.path("include")); - module.addIncludePath(b.path("")); - - // For dynamic linking, we prefer dynamic linking and to search by - // mode first. Mode first will search all paths for a dynamic library - // before falling back to static. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; - var flags = std.ArrayList([]const u8).init(b.allocator); defer flags.deinit(); try flags.appendSlice(&.{ @@ -44,6 +88,8 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize=undefined", }); + const dynamic_link_opts = options.dynamic_link_opts; + // Zlib if (b.systemIntegrationOption("zlib", .{})) { lib.linkSystemLibrary2("zlib", dynamic_link_opts); @@ -113,18 +159,7 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - if (target.query.isNative()) { - const test_exe = b.addTest(.{ - .name = "test", - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - test_exe.linkLibrary(lib); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); - } + return lib; } const srcs: []const []const u8 = &.{ diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index b5c5c3c1e..5b7d86408 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void { .@"enable-libpng" = true, }); const macos = b.dependency("macos", .{ .target = target, .optimize = optimize }); - const upstream = b.dependency("harfbuzz", .{}); const module = b.addModule("harfbuzz", .{ .root_source_file = b.path("main.zig"), @@ -26,6 +25,66 @@ pub fn build(b: *std.Build) !void { }, }); + // For dynamic linking, we prefer dynamic linking and to search by + // mode first. Mode first will search all paths for a dynamic library + // before falling back to static. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + + const test_exe = b.addTest(.{ + .name = "test", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + { + var it = module.import_table.iterator(); + while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); + if (b.systemIntegrationOption("freetype", .{})) { + test_exe.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + test_exe.linkLibrary(freetype.artifact("freetype")); + } + const tests_run = b.addRunArtifact(test_exe); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); + } + + if (b.systemIntegrationOption("harfbuzz", .{})) { + module.linkSystemLibrary("harfbuzz", dynamic_link_opts); + test_exe.linkSystemLibrary2("harfbuzz", dynamic_link_opts); + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .coretext_enabled = coretext_enabled, + .freetype_enabled = freetype_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + test_exe.linkLibrary(lib); + } +} + +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const coretext_enabled = options.coretext_enabled; + const freetype_enabled = options.freetype_enabled; + + const freetype = b.dependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + }); + + const upstream = b.dependency("harfbuzz", .{}); const lib = b.addStaticLibrary(.{ .name = "harfbuzz", .target = target, @@ -41,13 +100,7 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, module); } - // For dynamic linking, we prefer dynamic linking and to search by - // mode first. Mode first will search all paths for a dynamic library - // before falling back to static. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; + const dynamic_link_opts = options.dynamic_link_opts; var flags = std.ArrayList([]const u8).init(b.allocator); defer flags.deinit(); @@ -102,20 +155,5 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - { - const test_exe = b.addTest(.{ - .name = "test", - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - test_exe.linkLibrary(lib); - - var it = module.import_table.iterator(); - while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); - test_exe.linkLibrary(freetype.artifact("freetype")); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); - } + return lib; } diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index a7a5e1bcf..da7c90674 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -5,36 +5,59 @@ pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const module = b.addModule("oniguruma", .{ .root_source_file = b.path("main.zig") }); + const module = b.addModule("oniguruma", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); - const upstream = b.dependency("oniguruma", .{}); - const lib = try buildOniguruma(b, upstream, target, optimize); - module.addIncludePath(upstream.path("src")); - b.installArtifact(lib); + // For dynamic linking, we prefer dynamic linking and to search by + // mode first. Mode first will search all paths for a dynamic library + // before falling back to static. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + var test_exe: ?*std.Build.Step.Compile = null; if (target.query.isNative()) { - const test_exe = b.addTest(.{ + test_exe = b.addTest(.{ .name = "test", .root_source_file = b.path("main.zig"), .target = target, .optimize = optimize, }); - test_exe.linkLibrary(lib); - const tests_run = b.addRunArtifact(test_exe); + const tests_run = b.addRunArtifact(test_exe.?); const test_step = b.step("test", "Run tests"); test_step.dependOn(&tests_run.step); // Uncomment this if we're debugging tests - b.installArtifact(test_exe); + b.installArtifact(test_exe.?); + } + + if (b.systemIntegrationOption("oniguruma", .{})) { + module.linkSystemLibrary("oniguruma", dynamic_link_opts); + + if (test_exe) |exe| { + exe.linkSystemLibrary2("oniguruma", dynamic_link_opts); + } + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + }); + + if (test_exe) |exe| { + exe.linkLibrary(lib); + } } } -fn buildOniguruma( - b: *std.Build, - upstream: *std.Build.Dependency, - target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, -) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const upstream = b.dependency("oniguruma", .{}); const lib = b.addStaticLibrary(.{ .name = "oniguruma", .target = target, @@ -43,6 +66,7 @@ fn buildOniguruma( const t = target.result; lib.linkLibC(); lib.addIncludePath(upstream.path("src")); + module.addIncludePath(upstream.path("src")); if (target.result.isDarwin()) { const apple_sdk = @import("apple_sdk"); @@ -134,5 +158,7 @@ fn buildOniguruma( .{ .include_extensions = &.{".h"} }, ); + b.installArtifact(lib); + return lib; } diff --git a/pkg/simdutf/build.zig.zon b/pkg/simdutf/build.zig.zon index 07afe182c..63c5f41b5 100644 --- a/pkg/simdutf/build.zig.zon +++ b/pkg/simdutf/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "simdutf", - .version = "4.0.9", + .version = "5.2.8", .paths = .{""}, .dependencies = .{ .apple_sdk = .{ .path = "../apple-sdk" }, diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index 36bb5a07c..438f714d3 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void { .file = wuffs.path("release/c/wuffs-v0.4.c"), .flags = flags.items, }); + + const unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + unit_tests.linkLibC(); + unit_tests.addIncludePath(wuffs.path("release/c")); + unit_tests.addCSourceFile(.{ + .file = wuffs.path("release/c/wuffs-v0.4.c"), + .flags = flags.items, + }); + + const pixels = b.dependency("pixels", .{}); + + inline for (.{ "000000", "FFFFFF" }) |color| { + inline for (.{ "gif", "jpg", "png", "ppm" }) |extension| { + const filename = std.fmt.comptimePrint("1x1#{s}.{s}", .{ color, extension }); + unit_tests.root_module.addAnonymousImport( + filename, + .{ + .root_source_file = pixels.path(filename), + }, + ); + } + } + + const run_unit_tests = b.addRunArtifact(unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); } diff --git a/pkg/wuffs/build.zig.zon b/pkg/wuffs/build.zig.zon index 126e43aba..d84d6957e 100644 --- a/pkg/wuffs/build.zig.zon +++ b/pkg/wuffs/build.zig.zon @@ -3,8 +3,13 @@ .version = "0.0.0", .dependencies = .{ .wuffs = .{ - .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz", - .hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462", + .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz", + .hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd", + }, + + .pixels = .{ + .url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877", + .hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806", }, .apple_sdk = .{ .path = "../apple-sdk" }, diff --git a/pkg/wuffs/src/error.zig b/pkg/wuffs/src/error.zig index 609deec9c..c75188718 100644 --- a/pkg/wuffs/src/error.zig +++ b/pkg/wuffs/src/error.zig @@ -1,3 +1,13 @@ const std = @import("std"); +const c = @import("c.zig").c; + pub const Error = std.mem.Allocator.Error || error{WuffsError}; + +pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void { + if (!c.wuffs_base__status__is_ok(status)) { + const e = c.wuffs_base__status__message(status); + log.warn("decode err={s}", .{e}); + return error.WuffsError; + } +} diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig new file mode 100644 index 000000000..69628f582 --- /dev/null +++ b/pkg/wuffs/src/jpeg.zig @@ -0,0 +1,143 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const Error = @import("error.zig").Error; +const check = @import("error.zig").check; +const ImageData = @import("main.zig").ImageData; + +const log = std.log.scoped(.wuffs_jpeg); + +/// Decode a JPEG image. +pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { + // Work around some weirdness in WUFFS/Zig, there are some structs that + // are defined as "extern" by the Zig compiler which means that Zig won't + // allocate them on the stack at compile time. WUFFS has functions for + // dynamically allocating these structs but they use the C malloc/free. This + // gets around that by using the Zig allocator to allocate enough memory for + // the struct and then casts it to the appropriate pointer. + + const decoder_buf = try alloc.alloc(u8, c.sizeof__wuffs_jpeg__decoder()); + defer alloc.free(decoder_buf); + + const decoder: ?*c.wuffs_jpeg__decoder = @ptrCast(decoder_buf); + { + const status = c.wuffs_jpeg__decoder__initialize( + decoder, + c.sizeof__wuffs_jpeg__decoder(), + c.WUFFS_VERSION, + 0, + ); + try check(log, &status); + } + + var source_buffer: c.wuffs_base__io_buffer = .{ + .data = .{ .ptr = @constCast(@ptrCast(data.ptr)), .len = data.len }, + .meta = .{ + .wi = data.len, + .ri = 0, + .pos = 0, + .closed = true, + }, + }; + + var image_config: c.wuffs_base__image_config = undefined; + { + const status = c.wuffs_jpeg__decoder__decode_image_config( + decoder, + &image_config, + &source_buffer, + ); + try check(log, &status); + } + + const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); + const height = c.wuffs_base__pixel_config__height(&image_config.pixcfg); + + c.wuffs_base__pixel_config__set( + &image_config.pixcfg, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, + width, + height, + ); + + const destination = try alloc.alloc( + u8, + width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + errdefer alloc.free(destination); + + // temporary buffer for intermediate processing of image + const work_buffer = try alloc.alloc( + u8, + + // The type of this is a u64 on all systems but our allocator + // uses a usize which is a u32 on 32-bit systems. + std.math.cast( + usize, + c.wuffs_jpeg__decoder__workbuf_len(decoder).max_incl, + ) orelse return error.OutOfMemory, + ); + defer alloc.free(work_buffer); + + const work_slice = c.wuffs_base__make_slice_u8( + work_buffer.ptr, + work_buffer.len, + ); + + var pixel_buffer: c.wuffs_base__pixel_buffer = undefined; + { + const status = c.wuffs_base__pixel_buffer__set_from_slice( + &pixel_buffer, + &image_config.pixcfg, + c.wuffs_base__make_slice_u8(destination.ptr, destination.len), + ); + try check(log, &status); + } + + var frame_config: c.wuffs_base__frame_config = undefined; + { + const status = c.wuffs_jpeg__decoder__decode_frame_config( + decoder, + &frame_config, + &source_buffer, + ); + try check(log, &status); + } + + { + const status = c.wuffs_jpeg__decoder__decode_frame( + decoder, + &pixel_buffer, + &source_buffer, + c.WUFFS_BASE__PIXEL_BLEND__SRC, + work_slice, + null, + ); + try check(log, &status); + } + + return .{ + .width = width, + .height = height, + .data = destination, + }; +} + +test "jpeg_decode_000000" { + const data = try decode(std.testing.allocator, @embedFile("1x1#000000.jpg")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data); +} + +test "jpeg_decode_FFFFFF" { + const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.jpg")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); +} diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index 3f03a4158..f282261c2 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -1,2 +1,15 @@ +const std = @import("std"); + pub const png = @import("png.zig"); +pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); + +pub const ImageData = struct { + width: u32, + height: u32, + data: []const u8, +}; + +test { + std.testing.refAllDeclsRecursive(@This()); +} diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index 3a3ac9a35..b85e4d747 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -2,15 +2,13 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const c = @import("c.zig").c; const Error = @import("error.zig").Error; +const check = @import("error.zig").check; +const ImageData = @import("main.zig").ImageData; const log = std.log.scoped(.wuffs_png); /// Decode a PNG image. -pub fn decode(alloc: Allocator, data: []const u8) Error!struct { - width: u32, - height: u32, - data: []const u8, -} { +pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { // Work around some weirdness in WUFFS/Zig, there are some structs that // are defined as "extern" by the Zig compiler which means that Zig won't // allocate them on the stack at compile time. WUFFS has functions for @@ -29,11 +27,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { c.WUFFS_VERSION, 0, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } var source_buffer: c.wuffs_base__io_buffer = .{ @@ -53,11 +47,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &image_config, &source_buffer, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); @@ -102,11 +92,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &image_config.pixcfg, c.wuffs_base__make_slice_u8(destination.ptr, destination.len), ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } var frame_config: c.wuffs_base__frame_config = undefined; @@ -116,11 +102,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &frame_config, &source_buffer, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } { @@ -132,11 +114,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { work_slice, null, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } return .{ @@ -145,3 +123,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { .data = destination, }; } + +test "png_decode_000000" { + const data = try decode(std.testing.allocator, @embedFile("1x1#000000.png")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data); +} + +test "png_decode_FFFFFF" { + const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.png")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); +} diff --git a/src/App.zig b/src/App.zig index 279c4e497..a6b54db23 100644 --- a/src/App.zig +++ b/src/App.zig @@ -54,9 +54,6 @@ focused_surface: ?*Surface = null, /// this is a blocking queue so if it is full you will get errors (or block). mailbox: Mailbox.Queue, -/// Set to true once we're quitting. This never goes false again. -quit: bool, - /// The set of font GroupCache instances shared by surfaces with the /// same font configuration. font_grid_set: font.SharedGridSet, @@ -98,7 +95,6 @@ pub fn create( .alloc = alloc, .surfaces = .{}, .mailbox = .{}, - .quit = false, .font_grid_set = font_grid_set, .config_conditional_state = .{}, }; @@ -125,9 +121,7 @@ pub fn destroy(self: *App) void { /// Tick ticks the app loop. This will drain our mailbox and process those /// events. This should be called by the application runtime on every loop /// tick. -/// -/// This returns whether the app should quit or not. -pub fn tick(self: *App, rt_app: *apprt.App) !bool { +pub fn tick(self: *App, rt_app: *apprt.App) !void { // If any surfaces are closing, destroy them var i: usize = 0; while (i < self.surfaces.items.len) { @@ -142,13 +136,6 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool { // Drain our mailbox try self.drainMailbox(rt_app); - - // No matter what, we reset the quit flag after a tick. If the apprt - // doesn't want to quit, then we can't force it to. - defer self.quit = false; - - // We quit if our quit flag is on - return self.quit; } /// Update the configuration associated with the app. This can only be @@ -272,7 +259,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { // can try to quit as quickly as possible. .quit => { log.info("quit message received, short circuiting mailbox drain", .{}); - self.setQuit(); + try self.performAction(rt_app, .quit); return; }, } @@ -314,12 +301,6 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { ); } -/// Start quitting -pub fn setQuit(self: *App) void { - if (self.quit) return; - self.quit = true; -} - /// Handle an app-level focus event. This should be called whenever /// the focus state of the entire app containing Ghostty changes. /// This is separate from surface focus events. See the `focused` @@ -332,6 +313,25 @@ pub fn focusEvent(self: *App, focused: bool) void { self.focused = focused; } +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +pub fn keyEventIsBinding( + self: *App, + rt_app: *apprt.App, + event: input.KeyEvent, +) bool { + _ = self; + + switch (event.action) { + .release => return false, + .press, .repeat => {}, + } + + // If we have a keybinding for this event then we return true. + return rt_app.config.keybind.set.getEvent(event) != null; +} + /// Handle a key event at the app-scope. If this key event is used, /// this will return true and the caller shouldn't continue processing /// the event. If the event is not used, this will return false. @@ -437,7 +437,7 @@ pub fn performAction( switch (action) { .unbind => unreachable, .ignore => {}, - .quit => self.setQuit(), + .quit => try rt_app.performAction(.app, .quit, {}), .new_window => try self.newWindow(rt_app, .{ .parent = null }), .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try rt_app.performAction(.app, .reload_config, .{}), diff --git a/src/Command.zig b/src/Command.zig index 82b48fa18..6e30eae13 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -18,6 +18,7 @@ const Command = @This(); const std = @import("std"); const builtin = @import("builtin"); +const global_state = &@import("global.zig").state; const internal_os = @import("os/main.zig"); const windows = internal_os.windows; const TempDir = internal_os.TempDir; @@ -175,6 +176,10 @@ fn startPosix(self: *Command, arena: Allocator) !void { // We don't log because that'll show up in the output. }; + // Restore any rlimits that were set by Ghostty. This might fail but + // any failures are ignored (its best effort). + global_state.rlimits.restore(); + // If the user requested a pre exec callback, call it now. if (self.pre_exec) |f| f(self); diff --git a/src/Surface.zig b/src/Surface.zig index c359efd8a..1dc10fb27 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1156,7 +1156,6 @@ pub fn updateConfig( } // If we are in the middle of a key sequence, clear it. - self.keyboard.bindings = null; self.endKeySequence(.drop, .free); // Before sending any other config changes, we give the renderer a new font @@ -1638,6 +1637,31 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { try self.queueRender(); } +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +/// +/// Note that this function does not check if the binding itself +/// is performable, only if the key event would trigger a binding. +/// If a performable binding is found and the event is not performable, +/// then Ghosty will act as though the binding does not exist. +pub fn keyEventIsBinding( + self: *Surface, + event: input.KeyEvent, +) bool { + switch (event.action) { + .release => return false, + .press, .repeat => {}, + } + + // Our keybinding set is either our current nested set (for + // sequences) or the root set. + const set = self.keyboard.bindings orelse &self.config.keybind.set; + + // If we have a keybinding for this event then we return true. + return set.getEvent(event) != null; +} + /// Called for any key events. This handles keybindings, encoding and /// sending to the terminal, etc. pub fn keyCallback( @@ -1853,9 +1877,6 @@ fn maybeHandleBinding( if (self.keyboard.bindings != null and !event.key.modifier()) { - // Reset to the root set - self.keyboard.bindings = null; - // Encode everything up to this point self.endKeySequence(.flush, .retain); } @@ -1941,10 +1962,21 @@ fn maybeHandleBinding( return .closed; } + // If we have the performable flag and the action was not performed, + // then we act as though a binding didn't exist. + if (leaf.flags.performable and !performed) { + // If we're in a sequence, we treat this as if we pressed a key + // that doesn't exist in the sequence. Reset our sequence and flush + // any queued events. + self.endKeySequence(.flush, .retain); + + return null; + } + // If we consume this event, then we are done. If we don't consume // it, we processed the action but we still want to process our // encodings, too. - if (performed and consumed) { + if (consumed) { // If we had queued events, we deinit them since we consumed self.endKeySequence(.drop, .retain); @@ -1986,6 +2018,10 @@ fn endKeySequence( ); }; + // No matter what we clear our current binding set. This restores + // the set we look at to the root set. + self.keyboard.bindings = null; + if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { @@ -3889,7 +3925,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool log.err("error setting clipboard string err={}", .{err}); return true; }; + + return true; } + + return false; }, .paste_from_clipboard => try self.startClipboardRequest( diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 527535ffa..df30f7b7b 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -70,6 +70,9 @@ pub const Action = union(Key) { // entry. If the value type is void then only the key needs to be // added. Ensure the order matches exactly with the Zig code. + /// Quit the application. + quit, + /// Open a new window. The target determines whether properties such /// as font size should be inherited. new_window, @@ -219,6 +222,7 @@ pub const Action = union(Key) { /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { + quit, new_window, new_tab, new_split, @@ -332,9 +336,9 @@ pub const GotoSplit = enum(c_int) { previous, next, - top, + up, left, - bottom, + down, right, }; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b42225906..50d1e90e4 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -147,12 +147,12 @@ pub const App = struct { self.core_app.focusEvent(focused); } - /// See CoreApp.keyEvent. - pub fn keyEvent( + /// Convert a C key event into a Zig key event. + fn coreKeyEvent( self: *App, target: KeyTarget, event: KeyEvent, - ) !bool { + ) !?input.KeyEvent { const action = event.action; const keycode = event.keycode; const mods = event.mods; @@ -243,7 +243,7 @@ pub const App = struct { result.text, ) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } } else { @@ -251,7 +251,7 @@ pub const App = struct { .app => {}, .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } @@ -335,7 +335,7 @@ pub const App = struct { } else .invalid; // Build our final key event - const input_event: input.KeyEvent = .{ + return .{ .action = action, .key = key, .physical_key = physical_key, @@ -345,24 +345,39 @@ pub const App = struct { .utf8 = result.text, .unshifted_codepoint = unshifted_codepoint, }; + } + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + // Convert our C key event into a Zig one. + const input_event: input.KeyEvent = (try self.coreKeyEvent( + target, + event, + )) orelse return false; // Invoke the core Ghostty logic to handle this input. const effect: CoreSurface.InputEffect = switch (target) { .app => if (self.core_app.keyEvent( self, input_event, - )) - .consumed - else - .ignored, + )) .consumed else .ignored, - .surface => |surface| try surface.core_surface.keyCallback(input_event), + .surface => |surface| try surface.core_surface.keyCallback( + input_event, + ), }; return switch (effect) { .closed => true, .ignored => false, .consumed => consumed: { + const is_down = input_event.action == .press or + input_event.action == .repeat; + if (is_down) { // If we consume the key then we want to reset the dead // key state. @@ -1332,10 +1347,9 @@ pub const CAPI = struct { /// Tick the event loop. This should be called whenever the "wakeup" /// callback is invoked for the runtime. - export fn ghostty_app_tick(v: *App) bool { - return v.core_app.tick(v) catch |err| err: { + export fn ghostty_app_tick(v: *App) void { + v.core_app.tick(v) catch |err| { log.err("error app tick err={}", .{err}); - break :err false; }; } @@ -1372,6 +1386,28 @@ pub const CAPI = struct { }; } + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_app_key_is_binding( + app: *App, + event: KeyEvent, + ) bool { + const core_event = app.coreKeyEvent( + .app, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + } orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return app.core_app.keyEventIsBinding(app, core_event); + } + /// Notify the app that the keyboard was changed. This causes the /// keyboard layout to be reloaded from the OS. export fn ghostty_app_keyboard_changed(v: *App) void { @@ -1592,16 +1628,38 @@ pub const CAPI = struct { export fn ghostty_surface_key( surface: *Surface, event: KeyEvent, - ) void { - _ = surface.app.keyEvent( + ) bool { + return surface.app.keyEvent( .{ .surface = surface }, event.keyEvent(), ) catch |err| { log.warn("error processing key event err={}", .{err}); - return; + return false; }; } + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_surface_key_is_binding( + surface: *Surface, + event: KeyEvent, + ) bool { + const core_event = surface.app.coreKeyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + } orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return surface.core_surface.keyEventIsBinding(core_event); + } + /// Send raw text to the terminal. This is treated like a paste /// so this isn't useful for sending escape sequences. For that, /// individual key input should be used. @@ -1895,7 +1953,7 @@ pub const CAPI = struct { _ = CGSSetWindowBackgroundBlurRadius( CGSDefaultConnectionForThread(), nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), - @intCast(config.@"background-blur-radius"), + @intCast(config.@"background-blur-radius".cval()), ); } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 64b0cbe81..c91464068 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -35,6 +35,10 @@ pub const App = struct { app: *CoreApp, config: Config, + /// Flips to true to quit on the next event loop tick. This + /// never goes false and forces the event loop to exit. + quit: bool = false, + /// Mac-specific state. darwin: if (Darwin.enabled) Darwin else void, @@ -124,8 +128,10 @@ pub const App = struct { glfw.waitEvents(); // Tick the terminal app - const should_quit = try self.app.tick(self); - if (should_quit or self.app.surfaces.items.len == 0) { + try self.app.tick(self); + + // If the tick caused us to quit, then we're done. + if (self.quit or self.app.surfaces.items.len == 0) { for (self.app.surfaces.items) |surface| { surface.close(false); } @@ -149,6 +155,8 @@ pub const App = struct { value: apprt.Action.Value(action), ) !void { switch (action) { + .quit => self.quit = true, + .new_window => _ = try self.newSurface(switch (target) { .app => null, .surface => |v| v, @@ -510,6 +518,13 @@ pub const Surface = struct { ) orelse return glfw.mustGetErrorCode(); errdefer win.destroy(); + // Setup our + setInitialWindowPosition( + win, + app.config.@"window-position-x", + app.config.@"window-position-y", + ); + // Get our physical DPI - debug only because we don't have a use for // this but the logging of it may be useful if (builtin.mode == .Debug) { @@ -663,6 +678,17 @@ pub const Surface = struct { }); } + /// Set the initial window position. This is called exactly once at + /// surface initialization time. This may be called before "self" + /// is fully initialized. + fn setInitialWindowPosition(win: glfw.Window, x: ?i16, y: ?i16) void { + const start_position_x = x orelse return; + const start_position_y = y orelse return; + + log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y }); + win.setPos(.{ .x = start_position_x, .y = start_position_y }); + } + /// Set the size limits of the window. /// Note: this interface is not good, we should redo it if we plan /// to use this more. i.e. you can't set max width but no max height, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c10ba7993..3cc1782c8 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -37,6 +37,7 @@ const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); const x11 = @import("x11.zig"); +const wayland = @import("wayland.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -73,6 +74,9 @@ running: bool = true, /// Xkb state (X11 only). Will be null on Wayland. x11_xkb: ?x11.Xkb = null, +/// Wayland app state. Will be null on X11. +wayland: ?wayland.AppState = null, + /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled /// and initialization was successful. @@ -81,6 +85,9 @@ transient_cgroup_base: ?[]const u8 = null, /// CSS Provider for any styles based on ghostty configuration values css_provider: *c.GtkCssProvider, +/// Providers for loading custom stylesheets defined by user +custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider) = .{}, + /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { off: void, @@ -108,7 +115,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. // For the remainder of "why" see the 4.14 comment below. _ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); - _ = internal_os.setenv("GDK_DEBUG", "opengl"); + _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional"); } else if (version.atLeast(4, 14, 0)) { // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // Older versions of GTK do not support these values so it is safe @@ -123,7 +130,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan // and initializing a Vulkan context was causing a longer delay // on some systems. - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable"); + _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional"); } else { // Versions prior to 4.14 are a bit of an unknown for Ghostty. It // is an environment that isn't tested well and we don't have a @@ -394,6 +401,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { break :x11_xkb try x11.Xkb.init(display); }; + // Initialize Wayland state + var wl = wayland.AppState.init(display); + if (wl) |*w| try w.register(); + // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening @@ -419,6 +430,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .ctx = ctx, .cursor_none = cursor_none, .x11_xkb = x11_xkb, + .wayland = wl, .single_instance = single_instance, // If we are NOT the primary instance, then we never want to run. // This means that another instance of the GTK app is running and @@ -441,6 +453,11 @@ pub fn terminate(self: *App) void { if (self.context_menu) |context_menu| c.g_object_unref(context_menu); if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); + for (self.custom_css_providers.items) |provider| { + c.g_object_unref(provider); + } + self.custom_css_providers.deinit(self.core_app.alloc); + self.config.deinit(); } @@ -452,6 +469,7 @@ pub fn performAction( value: apprt.Action.Value(action), ) !void { switch (action) { + .quit => self.quit(), .new_window => _ = try self.newWindow(switch (target) { .app => null, .surface => |v| v, @@ -786,6 +804,7 @@ fn setInitialSize( ), } } + fn showDesktopNotification( self: *App, target: apprt.Target, @@ -828,9 +847,11 @@ fn configChange( new_config: *const Config, ) void { switch (target) { - // We don't do anything for surface config change events. There - // is nothing to sync with regards to a surface today. - .surface => {}, + .surface => |surface| { + if (surface.rt_surface.container.window()) |window| window.syncAppearance(new_config) catch |err| { + log.warn("error syncing appearance changes to window err={}", .{err}); + }; + }, .app => { // We clone (to take ownership) and update our configuration. @@ -892,7 +913,7 @@ fn syncConfigChanges(self: *App) !void { try self.updateConfigErrors(); try self.syncActionAccelerators(); - // Load our runtime CSS. If this fails then our window is just stuck + // Load our runtime and custom CSS. If this fails then our window is just stuck // with the old CSS but we don't want to fail the entire sync operation. self.loadRuntimeCss() catch |err| switch (err) { error.OutOfMemory => log.warn( @@ -900,6 +921,9 @@ fn syncConfigChanges(self: *App) !void { .{}, ), }; + self.loadCustomCss() catch |err| { + log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); + }; } /// This should be called whenever the configuration changes to update @@ -983,6 +1007,27 @@ fn loadRuntimeCss( unfocused_fill.b, }); + if (config.@"split-divider-color") |color| { + try writer.print( + \\.terminal-window .notebook separator {{ + \\ color: rgb({[r]d},{[g]d},{[b]d}); + \\ background: rgb({[r]d},{[g]d},{[b]d}); + \\}} + , .{ + .r = color.r, + .g = color.g, + .b = color.b, + }); + } + + if (config.@"window-title-font-family") |font_family| { + try writer.print( + \\.window headerbar {{ + \\ font-family: "{[font_family]s}"; + \\}} + , .{ .font_family = font_family }); + } + if (version.atLeast(4, 16, 0)) { switch (window_theme) { .ghostty => try writer.print( @@ -1033,11 +1078,66 @@ fn loadRuntimeCss( } // Clears any previously loaded CSS from this provider - c.gtk_css_provider_load_from_data( - self.css_provider, - buf.items.ptr, - @intCast(buf.items.len), - ); + loadCssProviderFromData(self.css_provider, buf.items); +} + +fn loadCustomCss(self: *App) !void { + const display = c.gdk_display_get_default(); + + // unload the previously loaded style providers + for (self.custom_css_providers.items) |provider| { + c.gtk_style_context_remove_provider_for_display( + display, + @ptrCast(provider), + ); + c.g_object_unref(provider); + } + self.custom_css_providers.clearRetainingCapacity(); + + for (self.config.@"gtk-custom-css".value.items) |p| { + const path, const optional = switch (p) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + const file = std.fs.openFileAbsolute(path, .{}) catch |err| { + if (err != error.FileNotFound or !optional) { + log.err("error opening gtk-custom-css file {s}: {}", .{ path, err }); + } + continue; + }; + defer file.close(); + + log.info("loading gtk-custom-css path={s}", .{path}); + const contents = try file.reader().readAllAlloc(self.core_app.alloc, 5 * 1024 * 1024 // 5MB + ); + defer self.core_app.alloc.free(contents); + + const provider = c.gtk_css_provider_new(); + c.gtk_style_context_add_provider_for_display( + display, + @ptrCast(provider), + c.GTK_STYLE_PROVIDER_PRIORITY_USER, + ); + + loadCssProviderFromData(provider, contents); + + try self.custom_css_providers.append(self.core_app.alloc, provider); + } +} + +fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void { + if (version.atLeast(4, 12, 0)) { + const g_bytes = c.g_bytes_new(data.ptr, data.len); + defer c.g_bytes_unref(g_bytes); + + c.gtk_css_provider_load_from_bytes(provider, g_bytes); + } else { + c.gtk_css_provider_load_from_data( + provider, + data.ptr, + @intCast(data.len), + ); + } } /// Called by CoreApp to wake up the event loop. @@ -1105,14 +1205,10 @@ pub fn run(self: *App) !void { _ = c.g_main_context_iteration(self.ctx, 1); // Tick the terminal app and see if we should quit. - const should_quit = try self.core_app.tick(self); + try self.core_app.tick(self); // Check if we must quit based on the current state. const must_quit = q: { - // If we've been told by GTK that we should quit, do so regardless - // of any other setting. - if (should_quit) break :q true; - // If we are configured to always stay running, don't quit. if (!self.config.@"quit-after-last-window-closed") break :q false; @@ -1216,6 +1312,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !void { } fn quit(self: *App) void { + // If we're already not running, do nothing. + if (!self.running) return; + // If we have no toplevel windows, then we're done. const list = c.gtk_window_list_toplevels(); if (list == null) { @@ -1556,7 +1655,9 @@ fn gtkActionQuit( ud: ?*anyopaque, ) callconv(.C) void { const self: *App = @ptrCast(@alignCast(ud orelse return)); - self.core_app.setQuit(); + self.core_app.performAction(self, .quit) catch |err| { + log.err("error quitting err={}", .{err}); + }; } /// Action sent by the window manager asking us to present a specific surface to @@ -1575,7 +1676,7 @@ fn gtkActionPresentSurface( // Convert that u64 to pointer to a core surface. A value of zero // means that there was no target surface for the notification so - // we dont' focus any surface. + // we don't focus any surface. const ptr_int: u64 = c.g_variant_get_uint64(parameter); if (ptr_int == 0) return; const surface: *CoreSurface = @ptrFromInt(ptr_int); diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index f0b60a2c6..a04271497 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -64,6 +64,8 @@ fn init( c.gtk_window_set_title(gtk_window, titleText(request)); c.gtk_window_set_default_size(gtk_window, 550, 275); c.gtk_window_set_resizable(gtk_window, 0); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "clipboard-confirmation-window"); _ = c.g_signal_connect_data( window, "destroy", diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index 3ff52908e..5fbf8e835 100644 --- a/src/apprt/gtk/ConfigErrorsWindow.zig +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -55,6 +55,8 @@ fn init(self: *ConfigErrors, app: *App) !void { c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "config-errors-window"); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); // Set some state diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 61c2edece..2d428acb2 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -111,16 +111,6 @@ pub fn init( // Keep a long-lived reference, which we unref in destroy. _ = c.g_object_ref(paned); - // Clicks - const gesture_click = c.gtk_gesture_click_new(); - errdefer c.g_object_unref(gesture_click); - c.gtk_event_controller_set_propagation_phase(@ptrCast(gesture_click), c.GTK_PHASE_CAPTURE); - c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 1); - c.gtk_widget_add_controller(paned, @ptrCast(gesture_click)); - - // Signals - _ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, c.G_CONNECT_DEFAULT); - // Update all of our containers to point to the right place. // The split has to point to where the sibling pointed to because // we're inheriting its parent. The sibling points to its location @@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 { return weight; } -fn gtkMouseDown( - _: *c.GtkGestureClick, - n_press: c.gint, - _: c.gdouble, - _: c.gdouble, - ud: ?*anyopaque, -) callconv(.C) void { - if (n_press == 2) { - const self: *Split = @ptrCast(@alignCast(ud)); - _ = equalize(self); - } -} - // maxPosition returns the maximum position of the GtkPaned, which is the // "max-position" attribute. fn maxPosition(self: *Split) f64 { @@ -339,7 +316,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap { // This behavior matches the behavior of macOS at the time of writing // this. There is an open issue (#524) to make this depend on the // actual physical location of the current split. - result.put(.top, prev.surface); + result.put(.up, prev.surface); result.put(.left, prev.surface); } } @@ -347,7 +324,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap { if (self.directionNext(from)) |next| { result.put(.next, next.surface); if (!next.wrapped) { - result.put(.bottom, next.surface); + result.put(.down, next.surface); result.put(.right, next.surface); } } diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index f61e34a07..056a3f40b 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { // can support fractional scaling. const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area))); - // If we are on X11, we also have to scale using Xft.dpi - const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: { - // Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied - // by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + // Also scale using font-specific DPI, which is often exposed to the user + // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). + const xft_dpi_scale = xft_scale: { + // gtk-xft-dpi is font DPI multiplied by 1024. See + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html const settings = c.gtk_settings_get_default(); var value: c.GValue = std.mem.zeroes(c.GValue); @@ -806,10 +807,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value); const gtk_xft_dpi = c.g_value_get_int(&value); - // As noted above Xft.dpi is multiplied by 1024, so we divide by 1024, - // then divide by the default value of Xft.dpi (96) to derive a scale. - // Note that gtk-xft-dpi can be fractional, so we use floating point - // math here. + // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by + // 1024, then divide by the default value (96) to derive a scale. Note + // gtk-xft-dpi can be fractional, so we use floating point math here. const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024; break :xft_scale xft_dpi / 96; }; @@ -1080,6 +1080,13 @@ pub fn setClipboardString( if (!confirm) { const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type); c.gdk_clipboard_set_text(clipboard, val.ptr); + // We only toast if we are copying to the standard clipboard. + if (clipboard_type == .standard and + self.app.config.@"adw-toast".@"clipboard-copy") + { + if (self.container.window()) |window| + window.sendToast("Copied to clipboard"); + } return; } @@ -1426,15 +1433,23 @@ fn gtkMouseMotion( .y = @floatCast(scaled.y), }; - // Our pos changed, update - self.cursor_pos = pos; + // When the GLArea is resized under the mouse, GTK issues a mouse motion + // event. This has the unfortunate side effect of causing focus to potentially + // change when `focus-follows-mouse` is enabled. To prevent this, we check + // if the cursor is still in the same place as the last event and only grab + // focus if it has moved. + const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and + @abs(self.cursor_pos.y - pos.y) < 1; // If we don't have focus, and we want it, grab it. const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); - if (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { + if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { self.grabFocus(); } + // Our pos changed, update + self.cursor_pos = pos; + // Get our modifiers const gtk_mods = c.gdk_event_get_modifier_state(event); const mods = gtk_key.translateMods(gtk_mods); diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 82384a44a..ed0804fd3 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -76,7 +76,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { // Set the userdata of the box to point to this tab. c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self); - try window.notebook.addTab(self, "Ghostty"); + window.notebook.addTab(self, "Ghostty"); // Attach all events _ = c.g_signal_connect_data(box_widget, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index c9e274ea0..63ee57d95 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -25,6 +25,7 @@ const gtk_key = @import("key.zig"); const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); +const wayland = @import("wayland.zig"); const log = std.log.scoped(.gtk); @@ -55,6 +56,8 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, +wayland: ?wayland.SurfaceState, + pub fn create(alloc: Allocator, app: *App) !*Window { // Allocate a fixed pointer for our window. We try to minimize // allocations but windows and other GUI requirements are so minimal @@ -79,6 +82,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, + .wayland = null, }; // Create the window @@ -99,6 +103,8 @@ pub fn init(self: *Window, app: *App) !void { self.window = gtk_window; c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 1000, 600); + c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window"); + c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window"); // GTK4 grabs F10 input by default to focus the menubar icon. We want // to disable this so that terminal programs can capture F10 (such as htop) @@ -113,21 +119,16 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty"); } - // Remove the window's background if any of the widgets need to be transparent - if (app.config.@"background-opacity" < 1) { - c.gtk_widget_remove_css_class(@ptrCast(window), "background"); - } - // Create our box which will hold our widgets in the main content area. const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); // Setup our notebook - self.notebook = Notebook.create(self); + self.notebook.init(); // If we are using Adwaita, then we can support the tab overview. self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0)) overview: { const tab_overview = c.adw_tab_overview_new(); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1); _ = c.g_signal_connect_data( tab_overview, @@ -189,7 +190,7 @@ pub fn init(self: *Window, app: *App) !void { .hidden => btn: { const btn = c.adw_tab_button_new(); - c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw_tab_view); + c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view); c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); break :btn btn; }, @@ -267,8 +268,8 @@ pub fn init(self: *Window, app: *App) !void { // If we have a tab overview then we can set it on our notebook. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable; - assert(self.notebook == .adw_tab_view); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + assert(self.notebook == .adw); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); @@ -288,6 +289,7 @@ pub fn init(self: *Window, app: *App) !void { // All of our events _ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(>kRefocusTerm), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(window, "realize", c.G_CALLBACK(>kRealize), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(>kKeyPressed), self, null, c.G_CONNECT_DEFAULT); @@ -305,7 +307,7 @@ pub fn init(self: *Window, app: *App) !void { if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); - c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view); + c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -338,9 +340,8 @@ pub fn init(self: *Window, app: *App) !void { ); } else tab_bar: { switch (self.notebook) { - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + .adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) { if (app.config.@"gtk-tabs-location" == .hidden) break :tab_bar; - // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?; @@ -360,12 +361,12 @@ pub fn init(self: *Window, app: *App) !void { ), .hidden => unreachable, } - c.adw_tab_bar_set_view(tab_bar, tab_view); + c.adw_tab_bar_set_view(tab_bar, adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); }, - .gtk_notebook => {}, + .gtk => {}, } // The box is our main child @@ -386,6 +387,28 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_show(window); } +/// Updates appearance based on config settings. Will be called once upon window +/// realization, and every time the config is reloaded. +/// +/// TODO: Many of the initial style settings in `create` could possibly be made +/// reactive by moving them here. +pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { + if (config.@"background-opacity" < 1) { + c.gtk_widget_remove_css_class(@ptrCast(self.window), "background"); + } else { + c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); + } + + if (self.wayland) |*wl| { + const blurred = switch (config.@"background-blur-radius") { + .false => false, + .true => true, + .radius => |v| v > 0, + }; + try wl.setBlur(blurred); + } +} + /// Sets up the GTK actions for the window scope. Actions are how GTK handles /// menus and such. The menu is defined in App.zig but the action is defined /// here. The string name binds them. @@ -423,6 +446,8 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); + if (self.wayland) |*wl| wl.deinit(); + if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); } @@ -542,7 +567,7 @@ pub fn onConfigReloaded(self: *Window) void { self.sendToast("Reloaded the configuration"); } -fn sendToast(self: *Window, title: [:0]const u8) void { +pub fn sendToast(self: *Window, title: [:0]const u8) void { if (comptime !adwaita.versionAtLeast(0, 0, 0)) return; const toast_overlay = self.toast_overlay orelse return; const toast = c.adw_toast_new(title); @@ -550,6 +575,20 @@ fn sendToast(self: *Window, title: [:0]const u8) void { c.adw_toast_overlay_add_toast(@ptrCast(toast_overlay), toast); } +fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { + const self = userdataSelf(ud.?); + + if (self.app.wayland) |*wl| { + self.wayland = wayland.SurfaceState.init(v, wl); + } + + self.syncAppearance(&self.app.config) catch |err| { + log.err("failed to initialize appearance={}", .{err}); + }; + + return true; +} + // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab // sends an undefined value. fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { @@ -570,7 +609,7 @@ fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwT const alloc = self.app.core_app.alloc; const surface = self.actionSurface(); const tab = Tab.create(alloc, self, surface) catch return null; - return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box))); + return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box))); } fn adwTabOverviewOpen( @@ -894,8 +933,6 @@ fn gtkActionCopy( log.warn("error performing binding action error={}", .{err}); return; }; - - self.sendToast("Copied to clipboard"); } fn gtkActionPaste( diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig index abd4821d3..dde99c78e 100644 --- a/src/apprt/gtk/c.zig +++ b/src/apprt/gtk/c.zig @@ -14,6 +14,9 @@ pub const c = @cImport({ // Xkb for X11 state handling @cInclude("X11/XKBlib.h"); } + if (build_options.wayland) { + @cInclude("gdk/wayland/gdkwayland.h"); + } // generated header files @cInclude("ghostty_resources.h"); diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index 119e20a6c..558175751 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -143,6 +143,8 @@ const Window = struct { c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "inspector-window"); // Initialize our imgui widget try self.imgui_widget.init(); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 80191b7f5..311bff0da 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -129,7 +129,7 @@ pub fn eventMods( // On Wayland, we have to use the GDK device because the mods sent // to this event do not have the modifier key applied if it was - // presssed (i.e. left control). + // pressed (i.e. left control). break :mods translateMods(c.gdk_device_get_modifier_state(device)); }; diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index 9d5f07f05..4676c2529 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -4,161 +4,76 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); +const NotebookAdw = @import("notebook_adw.zig").NotebookAdw; +const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk; const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk); const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. /// An abstraction over the GTK notebook and Adwaita tab view to manage /// all the terminal tabs in a window. pub const Notebook = union(enum) { - adw_tab_view: *AdwTabView, - gtk_notebook: *c.GtkNotebook, + adw: NotebookAdw, + gtk: NotebookGtk, - pub fn create(window: *Window) Notebook { + pub fn init(self: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", self); const app = window.app; - if (adwaita.enabled(&app.config)) return initAdw(window); - return initGtk(window); + if (adwaita.enabled(&app.config)) return NotebookAdw.init(self); + + return NotebookGtk.init(self); } - fn initGtk(window: *Window) Notebook { - const app = window.app; - - // Create a notebook to hold our tabs. - const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); - const notebook: *c.GtkNotebook = @ptrCast(notebook_widget); - const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { - .top, .hidden => c.GTK_POS_TOP, - .bottom => c.GTK_POS_BOTTOM, - .left => c.GTK_POS_LEFT, - .right => c.GTK_POS_RIGHT, - }; - c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos); - c.gtk_notebook_set_scrollable(notebook, 1); - c.gtk_notebook_set_show_tabs(notebook, 0); - c.gtk_notebook_set_show_border(notebook, 0); - - // This enables all Ghostty terminal tabs to be exchanged across windows. - c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs"); - - // This is important so the notebook expands to fit available space. - // Otherwise, it will be zero/zero in the box below. - c.gtk_widget_set_vexpand(notebook_widget, 1); - c.gtk_widget_set_hexpand(notebook_widget, 1); - - // Remove the background from the stack widget - const stack = c.gtk_widget_get_last_child(notebook_widget); - c.gtk_widget_add_css_class(stack, "transparent"); - - // All of our events - _ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); - - return .{ .gtk_notebook = notebook }; - } - - fn initAdw(window: *Window) Notebook { - const app = window.app; - assert(adwaita.enabled(&app.config)); - - const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; - - if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { - // Adwaita enables all of the shortcuts by default. - // We want to manage keybindings ourselves. - c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); - } - - _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); - - return .{ .adw_tab_view = tab_view }; - } - - pub fn asWidget(self: Notebook) *c.GtkWidget { - return switch (self) { - .adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view)), - .gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)), + pub fn asWidget(self: *Notebook) *c.GtkWidget { + return switch (self.*) { + .adw => |*adw| adw.asWidget(), + .gtk => |*gtk| gtk.asWidget(), }; } - pub fn nPages(self: Notebook) c_int { - return switch (self) { - .gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) - c.adw_tab_view_get_n_pages(tab_view) - else - unreachable, + pub fn nPages(self: *Notebook) c_int { + return switch (self.*) { + .adw => |*adw| adw.nPages(), + .gtk => |*gtk| gtk.nPages(), }; } /// Returns the index of the currently selected page. /// Returns null if the notebook has no pages. - fn currentPage(self: Notebook) ?c_int { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - return c.adw_tab_view_get_page_position(tab_view, page); - }, - - .gtk_notebook => |notebook| { - const current = c.gtk_notebook_get_current_page(notebook); - return if (current == -1) null else current; - }, - } + fn currentPage(self: *Notebook) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.currentPage(), + .gtk => |*gtk| gtk.currentPage(), + }; } /// Returns the currently selected tab or null if there are none. - pub fn currentTab(self: Notebook) ?*Tab { - const child = switch (self) { - .adw_tab_view => |tab_view| child: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - const child = c.adw_tab_page_get_child(page); - break :child child; - }, - - .gtk_notebook => |notebook| child: { - const page = self.currentPage() orelse return null; - break :child c.gtk_notebook_get_nth_page(notebook, page); - }, + pub fn currentTab(self: *Notebook) ?*Tab { + return switch (self.*) { + .adw => |*adw| adw.currentTab(), + .gtk => |*gtk| gtk.currentTab(), }; - return @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, - )); } - pub fn gotoNthTab(self: Notebook, position: c_int) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page_to_select = c.adw_tab_view_get_nth_page(tab_view, position); - c.adw_tab_view_set_selected_page(tab_view, page_to_select); - }, - .gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position), + pub fn gotoNthTab(self: *Notebook, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.gotoNthTab(position), + .gtk => |*gtk| gtk.gotoNthTab(position), } } - pub fn getTabPosition(self: Notebook, tab: *Tab) ?c_int { - return switch (self) { - .adw_tab_view => |tab_view| page_idx: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return null; - break :page_idx c.adw_tab_view_get_page_position(tab_view, page); - }, - .gtk_notebook => |notebook| page_idx: { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null; - break :page_idx getNotebookPageIndex(page); - }, + pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.getTabPosition(tab), + .gtk => |*gtk| gtk.getTabPosition(tab), }; } - pub fn gotoPreviousTab(self: Notebook, tab: *Tab) void { + pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; // The next index is the previous or we wrap around. @@ -173,7 +88,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn gotoNextTab(self: Notebook, tab: *Tab) void { + pub fn gotoNextTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -183,7 +98,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void { + pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -199,42 +114,28 @@ pub const Notebook = union(enum) { self.reorderPage(tab, new_position); } - pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void { - switch (self) { - .gtk_notebook => |notebook| { - c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position); - }, - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - _ = c.adw_tab_view_reorder_page(tab_view, page, position); - }, + pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.reorderPage(tab, position), + .gtk => |*gtk| gtk.reorderPage(tab, position), } } - pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_title(page, title.ptr); - }, - .gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr), + pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabLabel(tab, title), + .gtk => |*gtk| gtk.setTabLabel(tab, title), } } - pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_tooltip(page, tooltip.ptr); - }, - .gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr), + pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabTooltip(tab, tooltip), + .gtk => |*gtk| gtk.setTabTooltip(tab, tooltip), } } - fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { + fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int { const numPages = self.nPages(); return switch (tab.window.app.config.@"window-new-tab-position") { .current => if (self.currentPage()) |page| page + 1 else numPages, @@ -243,249 +144,23 @@ pub const Notebook = union(enum) { } /// Adds a new tab with the given title to the notebook. - pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void { - const box_widget: *c.GtkWidget = @ptrCast(tab.box); - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab)); - c.adw_tab_page_set_title(page, title.ptr); - - // Switch to the new tab - c.adw_tab_view_set_selected_page(tab_view, page); - }, - .gtk_notebook => |notebook| { - // Build the tab label - const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); - const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); - const label_text_widget = c.gtk_label_new(title.ptr); - const label_text: *c.GtkLabel = @ptrCast(label_text_widget); - c.gtk_box_append(label_box, label_text_widget); - tab.label_text = label_text; - - const window = tab.window; - if (window.app.config.@"gtk-wide-tabs") { - c.gtk_widget_set_hexpand(label_box_widget, 1); - c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); - c.gtk_widget_set_hexpand(label_text_widget, 1); - c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); - - // This ensures that tabs are always equal width. If they're too - // long, they'll be truncated with an ellipsis. - c.gtk_label_set_max_width_chars(label_text, 1); - c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); - - // We need to set a minimum width so that at a certain point - // the notebook will have an arrow button rather than shrinking tabs - // to an unreadably small size. - c.gtk_widget_set_size_request(label_text_widget, 100, 1); - } - - // Build the close button for the tab - const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); - const label_close: *c.GtkButton = @ptrCast(label_close_widget); - c.gtk_button_set_has_frame(label_close, 0); - c.gtk_box_append(label_box, label_close_widget); - - const page_idx = c.gtk_notebook_insert_page( - notebook, - box_widget, - label_box_widget, - self.newTabInsertPosition(tab), - ); - - // Clicks - const gesture_tab_click = c.gtk_gesture_click_new(); - c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); - c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); - - _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); - - // Tab settings - c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1); - c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1); - - if (self.nPages() > 1) { - c.gtk_notebook_set_show_tabs(notebook, 1); - } - - // Switch to the new tab - c.gtk_notebook_set_current_page(notebook, page_idx); - }, + pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + const position = self.newTabInsertPosition(tab); + switch (self.*) { + .adw => |*adw| adw.addTab(tab, position, title), + .gtk => |*gtk| gtk.addTab(tab, position, title), } } - pub fn closeTab(self: Notebook, tab: *Tab) void { - const window = tab.window; - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return; - c.adw_tab_view_close_page(tab_view, page); - - // If we have no more tabs we close the window - if (self.nPages() == 0) { - // libadw versions <= 1.3.x leak the final page view - // which causes our surface to not properly cleanup. We - // unref to force the cleanup. This will trigger a critical - // warning from GTK, but I don't know any other workaround. - // Note: I'm not actually sure if 1.4.0 contains the fix, - // I just know that 1.3.x is broken and 1.5.1 is fixed. - // If we know that 1.4.0 is fixed, we can change this. - if (!adwaita.versionAtLeast(1, 4, 0)) { - c.g_object_unref(tab.box); - } - - c.gtk_window_destroy(window.window); - } - }, - .gtk_notebook => |notebook| { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return; - - // Find page and tab which we're closing - const page_idx = getNotebookPageIndex(page); - - // Remove the page. This will destroy the GTK widgets in the page which - // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. - c.gtk_notebook_remove_page(notebook, page_idx); - - const remaining = self.nPages(); - switch (remaining) { - // If we have no more tabs we close the window - 0 => c.gtk_window_destroy(tab.window.window), - - // If we have one more tab we hide the tab bar - 1 => c.gtk_notebook_set_show_tabs(notebook, 0), - - else => {}, - } - - // If we have remaining tabs, we need to make sure we grab focus. - if (remaining > 0) window.focusCurrentTab(); - }, + pub fn closeTab(self: *Notebook, tab: *Tab) void { + switch (self.*) { + .adw => |*adw| adw.closeTab(tab), + .gtk => |*gtk| gtk.closeTab(tab), } } - - fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { - var value: c.GValue = std.mem.zeroes(c.GValue); - defer c.g_value_unset(&value); - _ = c.g_value_init(&value, c.G_TYPE_INT); - c.g_object_get_property( - @ptrCast(@alignCast(page)), - "position", - &value, - ); - - return c.g_value_get_int(&value); - } }; -fn gtkPageRemoved( - _: *c.GtkNotebook, - _: *c.GtkWidget, - _: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - const notebook: *c.GtkNotebook = self.notebook.gtk_notebook; - - // Hide the tab bar if we only have one tab after removal - const remaining = c.gtk_notebook_get_n_pages(notebook); - if (remaining == 1) { - c.gtk_notebook_set_show_tabs(notebook, 0); - } -} - -fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void { - _ = position; - _ = tab_view; - const self: *Window = @ptrCast(@alignCast(ud.?)); - - const child = c.adw_tab_page_get_child(page); - const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return)); - tab.window = self; - - self.focusCurrentTab(); -} - -fn gtkPageAdded( - notebook: *c.GtkNotebook, - _: *c.GtkWidget, - page_idx: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - // The added page can come from another window with drag and drop, thus we migrate the tab - // window to be self. - const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, - )); - tab.window = self; - - // Whenever a new page is added, we always grab focus of the - // currently selected page. This was added specifically so that when - // we drag a tab out to create a new window ("create-window" event) - // we grab focus in the new window. Without this, the terminal didn't - // have focus. - self.focusCurrentTab(); -} - -fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); - const page = c.adw_tab_view_get_selected_page(window.notebook.adw_tab_view) orelse return; - const title = c.adw_tab_page_get_title(page); - c.gtk_window_set_title(window.window, title); -} - -fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); - const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_notebook, page))); - const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); - const label_text = c.gtk_label_get_text(gtk_label); - c.gtk_window_set_title(window.window, label_text); -} - -fn adwTabViewCreateWindow( - _: *AdwTabView, - ud: ?*anyopaque, -) callconv(.C) ?*AdwTabView { - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - return window.notebook.adw_tab_view; -} - -fn gtkNotebookCreateWindow( - _: *c.GtkNotebook, - page: *c.GtkWidget, - ud: ?*anyopaque, -) callconv(.C) ?*c.GtkNotebook { - // The tab for the page is stored in the widget data. - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, - )); - - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - - // And add it to the new window. - tab.window = window; - - return window.notebook.gtk_notebook; -} - -fn createWindow(currentWindow: *Window) !*Window { +pub fn createWindow(currentWindow: *Window) !*Window { const alloc = currentWindow.app.core_app.alloc; const app = currentWindow.app; diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig new file mode 100644 index 000000000..85083a97e --- /dev/null +++ b/src/apprt/gtk/notebook_adw.zig @@ -0,0 +1,163 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; +const adwaita = @import("adwaita.zig"); + +const log = std.log.scoped(.gtk); + +const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque; + +pub const NotebookAdw = struct { + /// the tab view + tab_view: *AdwTabView, + + pub fn init(notebook: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", notebook); + const app = window.app; + assert(adwaita.enabled(&app.config)); + + const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; + c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook"); + + if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { + // Adwaita enables all of the shortcuts by default. + // We want to manage keybindings ourselves. + c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); + } + + notebook.* = .{ + .adw = .{ + .tab_view = tab_view, + }, + }; + + _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); + } + + pub fn asWidget(self: *NotebookAdw) *c.GtkWidget { + return @ptrCast(@alignCast(self.tab_view)); + } + + pub fn nPages(self: *NotebookAdw) c_int { + if (comptime adwaita.versionAtLeast(0, 0, 0)) + return c.adw_tab_view_get_n_pages(self.tab_view) + else + unreachable; + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookAdw) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookAdw) ?*Tab { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + const child = c.adw_tab_page_get_child(page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + pub fn gotoNthTab(self: *NotebookAdw, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position); + c.adw_tab_view_set_selected_page(self.tab_view, page_to_select); + } + + pub fn getTabPosition(self: *NotebookAdw, tab: *Tab) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + pub fn reorderPage(self: *NotebookAdw, tab: *Tab, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + _ = c.adw_tab_view_reorder_page(self.tab_view, page, position); + } + + pub fn setTabLabel(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + c.adw_tab_page_set_title(page, title.ptr); + } + + pub fn setTabTooltip(self: *NotebookAdw, tab: *Tab, tooltip: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + c.adw_tab_page_set_tooltip(page, tooltip.ptr); + } + + pub fn addTab(self: *NotebookAdw, tab: *Tab, position: c_int, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + const page = c.adw_tab_view_insert(self.tab_view, box_widget, position); + c.adw_tab_page_set_title(page, title.ptr); + c.adw_tab_view_set_selected_page(self.tab_view, page); + } + + pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; + c.adw_tab_view_close_page(self.tab_view, page); + + // If we have no more tabs we close the window + if (self.nPages() == 0) { + // libadw versions <= 1.3.x leak the final page view + // which causes our surface to not properly cleanup. We + // unref to force the cleanup. This will trigger a critical + // warning from GTK, but I don't know any other workaround. + // Note: I'm not actually sure if 1.4.0 contains the fix, + // I just know that 1.3.x is broken and 1.5.1 is fixed. + // If we know that 1.4.0 is fixed, we can change this. + if (!adwaita.versionAtLeast(1, 4, 0)) { + c.g_object_unref(tab.box); + } + + c.gtk_window_destroy(tab.window.window); + } + } +}; + +fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + + const child = c.adw_tab_page_get_child(page); + const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return)); + tab.window = window; + + window.focusCurrentTab(); +} + +fn adwTabViewCreateWindow( + _: *AdwTabView, + ud: ?*anyopaque, +) callconv(.C) ?*AdwTabView { + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const window = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + return window.notebook.adw.tab_view; +} + +fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return; + const title = c.adw_tab_page_get_title(page); + c.gtk_window_set_title(window.window, title); +} diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig new file mode 100644 index 000000000..6e8b016ba --- /dev/null +++ b/src/apprt/gtk/notebook_gtk.zig @@ -0,0 +1,285 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; + +const log = std.log.scoped(.gtk); + +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. +pub const NotebookGtk = struct { + notebook: *c.GtkNotebook, + + pub fn init(notebook: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", notebook); + const app = window.app; + + // Create a notebook to hold our tabs. + const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); + c.gtk_widget_add_css_class(notebook_widget, "notebook"); + + const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget); + const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { + .top, .hidden => c.GTK_POS_TOP, + .bottom => c.GTK_POS_BOTTOM, + .left => c.GTK_POS_LEFT, + .right => c.GTK_POS_RIGHT, + }; + c.gtk_notebook_set_tab_pos(gtk_notebook, notebook_tab_pos); + c.gtk_notebook_set_scrollable(gtk_notebook, 1); + c.gtk_notebook_set_show_tabs(gtk_notebook, 0); + c.gtk_notebook_set_show_border(gtk_notebook, 0); + + // This enables all Ghostty terminal tabs to be exchanged across windows. + c.gtk_notebook_set_group_name(gtk_notebook, "ghostty-terminal-tabs"); + + // This is important so the notebook expands to fit available space. + // Otherwise, it will be zero/zero in the box below. + c.gtk_widget_set_vexpand(notebook_widget, 1); + c.gtk_widget_set_hexpand(notebook_widget, 1); + + // Remove the background from the stack widget + const stack = c.gtk_widget_get_last_child(notebook_widget); + c.gtk_widget_add_css_class(stack, "transparent"); + + notebook.* = .{ + .gtk = .{ + .notebook = gtk_notebook, + }, + }; + + // All of our events + _ = c.g_signal_connect_data(gtk_notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); + } + + /// return the underlying widget as a generic GtkWidget + pub fn asWidget(self: *NotebookGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.notebook)); + } + + /// returns the number of pages in the notebook + pub fn nPages(self: *NotebookGtk) c_int { + return c.gtk_notebook_get_n_pages(self.notebook); + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookGtk) ?c_int { + const current = c.gtk_notebook_get_current_page(self.notebook); + return if (current == -1) null else current; + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookGtk) ?*Tab { + log.warn("currentTab", .{}); + const page = self.currentPage() orelse return null; + const child = c.gtk_notebook_get_nth_page(self.notebook, page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + /// focus the nth tab + pub fn gotoNthTab(self: *NotebookGtk, position: c_int) void { + c.gtk_notebook_set_current_page(self.notebook, position); + } + + /// get the position of the current tab + pub fn getTabPosition(self: *NotebookGtk, tab: *Tab) ?c_int { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return null; + return getNotebookPageIndex(page); + } + + pub fn reorderPage(self: *NotebookGtk, tab: *Tab, position: c_int) void { + c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position); + } + + pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void { + c.gtk_label_set_text(tab.label_text, title.ptr); + } + + pub fn setTabTooltip(_: *NotebookGtk, tab: *Tab, tooltip: [:0]const u8) void { + c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr); + } + + /// Adds a new tab with the given title to the notebook. + pub fn addTab(self: *NotebookGtk, tab: *Tab, position: c_int, title: [:0]const u8) void { + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + + // Build the tab label + const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); + const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); + const label_text_widget = c.gtk_label_new(title.ptr); + const label_text: *c.GtkLabel = @ptrCast(label_text_widget); + c.gtk_box_append(label_box, label_text_widget); + tab.label_text = label_text; + + const window = tab.window; + if (window.app.config.@"gtk-wide-tabs") { + c.gtk_widget_set_hexpand(label_box_widget, 1); + c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); + c.gtk_widget_set_hexpand(label_text_widget, 1); + c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); + + // This ensures that tabs are always equal width. If they're too + // long, they'll be truncated with an ellipsis. + c.gtk_label_set_max_width_chars(label_text, 1); + c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); + + // We need to set a minimum width so that at a certain point + // the notebook will have an arrow button rather than shrinking tabs + // to an unreadably small size. + c.gtk_widget_set_size_request(label_text_widget, 100, 1); + } + + // Build the close button for the tab + const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); + const label_close: *c.GtkButton = @ptrCast(label_close_widget); + c.gtk_button_set_has_frame(label_close, 0); + c.gtk_box_append(label_box, label_close_widget); + + const page_idx = c.gtk_notebook_insert_page( + self.notebook, + box_widget, + label_box_widget, + position, + ); + + // Clicks + const gesture_tab_click = c.gtk_gesture_click_new(); + c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); + c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); + + _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); + + // Tab settings + c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); + c.gtk_notebook_set_tab_detachable(self.notebook, box_widget, 1); + + if (self.nPages() > 1) { + c.gtk_notebook_set_show_tabs(self.notebook, 1); + } + + // Switch to the new tab + c.gtk_notebook_set_current_page(self.notebook, page_idx); + } + + pub fn closeTab(self: *NotebookGtk, tab: *Tab) void { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return; + + // Find page and tab which we're closing + const page_idx = getNotebookPageIndex(page); + + // Remove the page. This will destroy the GTK widgets in the page which + // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. + c.gtk_notebook_remove_page(self.notebook, page_idx); + + const remaining = self.nPages(); + switch (remaining) { + // If we have no more tabs we close the window + 0 => c.gtk_window_destroy(tab.window.window), + + // If we have one more tab we hide the tab bar + 1 => c.gtk_notebook_set_show_tabs(self.notebook, 0), + + else => {}, + } + + // If we have remaining tabs, we need to make sure we grab focus. + if (remaining > 0) + (self.currentTab() orelse return).window.focusCurrentTab(); + } +}; + +fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { + var value: c.GValue = std.mem.zeroes(c.GValue); + defer c.g_value_unset(&value); + _ = c.g_value_init(&value, c.G_TYPE_INT); + c.g_object_get_property( + @ptrCast(@alignCast(page)), + "position", + &value, + ); + + return c.g_value_get_int(&value); +} + +fn gtkPageAdded( + notebook: *c.GtkNotebook, + _: *c.GtkWidget, + page_idx: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud.?)); + + // The added page can come from another window with drag and drop, thus we migrate the tab + // window to be self. + const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, + )); + tab.window = self; + + // Whenever a new page is added, we always grab focus of the + // currently selected page. This was added specifically so that when + // we drag a tab out to create a new window ("create-window" event) + // we grab focus in the new window. Without this, the terminal didn't + // have focus. + self.focusCurrentTab(); +} + +fn gtkPageRemoved( + _: *c.GtkNotebook, + _: *c.GtkWidget, + _: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + log.warn("gtkPageRemoved", .{}); + const window: *Window = @ptrCast(@alignCast(ud.?)); + + // Hide the tab bar if we only have one tab after removal + const remaining = c.gtk_notebook_get_n_pages(window.notebook.gtk.notebook); + + if (remaining == 1) { + c.gtk_notebook_set_show_tabs(window.notebook.gtk.notebook, 0); + } +} + +fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + const self = &window.notebook.gtk; + const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); + const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); + const label_text = c.gtk_label_get_text(gtk_label); + c.gtk_window_set_title(window.window, label_text); +} + +fn gtkNotebookCreateWindow( + _: *c.GtkNotebook, + page: *c.GtkWidget, + ud: ?*anyopaque, +) callconv(.C) ?*c.GtkNotebook { + // The tab for the page is stored in the widget data. + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, + )); + + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const newWindow = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + + // And add it to the new window. + tab.window = newWindow; + + return newWindow.notebook.gtk.notebook; +} diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css index b56fa14f2..dcd4bcab9 100644 --- a/src/apprt/gtk/style-dark.css +++ b/src/apprt/gtk/style-dark.css @@ -2,7 +2,7 @@ background-color: transparent; } -separator { +.terminal-window .notebook separator { background-color: rgba(36, 36, 36, 1); background-clip: content-box; } diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index edafc84c7..bf0ee62f6 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -41,7 +41,7 @@ window.without-window-decoration-and-with-titlebar { background-color: transparent; } -separator { +.terminal-window .notebook separator { background-color: rgba(250, 250, 250, 1); background-clip: content-box; } diff --git a/src/apprt/gtk/wayland.zig b/src/apprt/gtk/wayland.zig new file mode 100644 index 000000000..92446cc46 --- /dev/null +++ b/src/apprt/gtk/wayland.zig @@ -0,0 +1,125 @@ +const std = @import("std"); +const c = @import("c.zig").c; +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const org = wayland.client.org; +const build_options = @import("build_options"); + +const log = std.log.scoped(.gtk_wayland); + +/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). +pub const AppState = struct { + display: *wl.Display, + blur_manager: ?*org.KdeKwinBlurManager = null, + + pub fn init(display: ?*c.GdkDisplay) ?AppState { + if (comptime !build_options.wayland) return null; + + // It should really never be null + const display_ = display orelse return null; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(display_)), + c.gdk_wayland_display_get_type(), + ) == 0) + return null; + + const wl_display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display(display_) orelse return null); + + return .{ + .display = wl_display, + }; + } + + pub fn register(self: *AppState) !void { + const registry = try self.display.getRegistry(); + + registry.setListener(*AppState, registryListener, self); + if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + log.debug("app wayland init={}", .{self}); + } +}; + +/// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface). +pub const SurfaceState = struct { + app_state: *AppState, + surface: *wl.Surface, + + /// A token that, when present, indicates that the window is blurred. + blur_token: ?*org.KdeKwinBlur = null, + + pub fn init(window: *c.GtkWindow, app_state: *AppState) ?SurfaceState { + if (comptime !build_options.wayland) return null; + + const surface = c.gtk_native_get_surface(@ptrCast(window)) orelse return null; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(surface)), + c.gdk_wayland_surface_get_type(), + ) == 0) + return null; + + const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return null); + + return .{ + .app_state = app_state, + .surface = wl_surface, + }; + } + + pub fn deinit(self: *SurfaceState) void { + if (self.blur_token) |blur| blur.release(); + } + + pub fn setBlur(self: *SurfaceState, blurred: bool) !void { + log.debug("setting blur={}", .{blurred}); + + const mgr = self.app_state.blur_manager orelse { + log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{}); + return; + }; + + if (self.blur_token) |blur| { + // Only release token when transitioning from blurred -> not blurred + if (!blurred) { + mgr.unset(self.surface); + blur.release(); + self.blur_token = null; + } + } else { + // Only acquire token when transitioning from not blurred -> blurred + if (blurred) { + const blur_token = try mgr.create(self.surface); + blur_token.commit(); + self.blur_token = blur_token; + } + } + } +}; + +fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *AppState) void { + switch (event) { + .global => |global| { + log.debug("got global interface={s}", .{global.interface}); + if (bindInterface(org.KdeKwinBlurManager, registry, global, 1)) |iface| { + state.blur_manager = iface; + return; + } + }, + .global_remove => {}, + } +} + +fn bindInterface(comptime T: type, registry: *wl.Registry, global: anytype, version: u32) ?*T { + if (std.mem.orderZ(u8, global.interface, T.interface.name) == .eq) { + return registry.bind(global.name, T, version) catch |err| { + log.warn("encountered error={} while binding interface {s}", .{ err, global.interface }); + return null; + }; + } else { + return null; + } +} diff --git a/src/build_config.zig b/src/build_config.zig index 35c429564..13131c132 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -23,6 +23,8 @@ pub const BuildConfig = struct { flatpak: bool = false, adwaita: bool = false, x11: bool = false, + wayland: bool = false, + sentry: bool = true, app_runtime: apprt.Runtime = .none, renderer: rendererpkg.Impl = .opengl, font_backend: font.Backend = .freetype, @@ -43,6 +45,8 @@ pub const BuildConfig = struct { step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "adwaita", self.adwaita); step.addOption(bool, "x11", self.x11); + step.addOption(bool, "wayland", self.wayland); + step.addOption(bool, "sentry", self.sentry); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(rendererpkg.Impl, "renderer", self.renderer); diff --git a/src/cli/args.zig b/src/cli/args.zig index be71b9096..23dcf7733 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -533,7 +533,7 @@ fn parsePackedStruct(comptime T: type, v: []const u8) !T { return result; } -fn parseBool(v: []const u8) !bool { +pub fn parseBool(v: []const u8) !bool { const t = &[_][]const u8{ "1", "t", "T", "true" }; const f = &[_][]const u8{ "0", "f", "F", "false" }; diff --git a/src/cli/help.zig b/src/cli/help.zig index e9e449550..daadc37cc 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.writeAll( \\ \\Specify `+ --help` to see the help for a specific action, - \\where `` is one of actions listed below. + \\where `` is one of actions listed above. \\ ); diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 8dbadc65a..65b9dcdad 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -20,8 +20,9 @@ pub const Options = struct { } }; -/// The `list-actions` command is used to list all the available keybind actions -/// for Ghostty. +/// The `list-actions` command is used to list all the available keybind +/// actions for Ghostty. These are distinct from the CLI Actions which can +/// be listed via `+help` /// /// The `--docs` argument will print out the documentation for each action. pub fn run(alloc: Allocator) !u8 { diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index c4dd415e7..22e22a972 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -11,6 +11,12 @@ const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); const zf = @import("zf"); +// When the number of filtered themes is less than or equal to this threshold, +// the window position will be reset to 0 to show all results from the top. +// This ensures better visibility for small result sets while maintaining +// scroll position for larger lists. +const SMALL_LIST_THRESHOLD = 10; + pub const Options = struct { /// If true, print the full path to the theme. path: bool = false, @@ -323,9 +329,15 @@ const Preview = struct { } self.current, self.window = current: { + if (selected.len == 0) break :current .{ 0, 0 }; + for (self.filtered.items, 0..) |index, i| { - if (std.mem.eql(u8, self.themes[index].theme, selected)) - break :current .{ i, i -| relative }; + if (std.mem.eql(u8, self.themes[index].theme, selected)) { + // Keep the relative position but ensure all search results are visible + const new_window = i -| relative; + // If the new window would hide some results at the top, adjust it + break :current .{ i, if (self.filtered.items.len <= SMALL_LIST_THRESHOLD) 0 else new_window }; + } } break :current .{ 0, 0 }; }; diff --git a/src/cli/version.zig b/src/cli/version.zig index 29ab7f63f..b00152589 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -3,6 +3,7 @@ const build_options = @import("build_options"); const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); +const internal_os = @import("../os/main.zig"); const xev = @import("xev"); const renderer = @import("../renderer.zig"); const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; @@ -37,6 +38,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.print(" - renderer : {}\n", .{renderer.Renderer}); try stdout.print(" - libxev : {}\n", .{xev.backend}); if (comptime build_config.app_runtime == .gtk) { + try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())}); try stdout.print(" - GTK version:\n", .{}); try stdout.print(" build : {d}.{d}.{d}\n", .{ gtk.GTK_MAJOR_VERSION, @@ -66,6 +68,14 @@ pub fn run(alloc: Allocator) !u8 { } else { try stdout.print(" - libX11 : disabled\n", .{}); } + + // We say `libwayland` since it is possible to build Ghostty without + // Wayland integration but with Wayland-enabled GTK + if (comptime build_options.wayland) { + try stdout.print(" - libwayland : enabled\n", .{}); + } else { + try stdout.print(" - libwayland : disabled\n", .{}); + } } return 0; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 91c07cc78..b0580cf20 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -147,23 +147,28 @@ const c = @cImport({ /// By default, synthetic styles are enabled. @"font-synthetic-style": FontSyntheticStyle = .{}, -/// Apply a font feature. This can be repeated multiple times to enable multiple -/// font features. You can NOT set multiple font features with a single value -/// (yet). +/// Apply a font feature. To enable multiple font features you can repeat +/// this multiple times or use a comma-separated list of feature settings. +/// +/// The syntax for feature settings is as follows, where `feat` is a feature: +/// +/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`. +/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`. +/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`. +/// * Feature names may be wrapped in quotes, meaning this config should be +/// syntactically compatible with the `font-feature-settings` CSS property. +/// +/// The syntax is fairly loose, but invalid settings will be silently ignored. /// /// The font feature will apply to all fonts rendered by Ghostty. A future /// enhancement will allow targeting specific faces. /// -/// A valid value is the name of a feature. Prefix the feature with a `-` to -/// explicitly disable it. Example: `ss20` or `-ss20`. -/// /// To disable programming ligatures, use `-calt` since this is the typical /// feature name for programming ligatures. To look into what font features /// your font has and what they do, use a font inspection tool such as /// [fontdrop.info](https://fontdrop.info). /// -/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as -/// separate repetitive entries in your config). +/// To generally disable most ligatures, use `-calt, -liga, -dlig`. @"font-feature": RepeatableString = .{}, /// Font size in points. This value can be a non-integer and the nearest integer @@ -177,6 +182,10 @@ const c = @cImport({ /// depending on your `window-inherit-font-size` setting. If that setting is /// true, only the first window will be affected by this change since all /// subsequent windows will inherit the font size of the previous window. +/// +/// On Linux with GTK, font size is scaled according to both display-wide and +/// text-specific scaling factors, which are often managed by your desktop +/// environment (e.g. the GNOME display scale and large text settings). @"font-size": f32 = switch (builtin.os.tag) { // On macOS we default a little bigger since this tends to look better. This // is purely subjective but this is easy to modify. @@ -225,10 +234,20 @@ const c = @cImport({ /// i.e. new windows, tabs, etc. @"font-codepoint-map": RepeatableCodepointMap = .{}, -/// Draw fonts with a thicker stroke, if supported. This is only supported -/// currently on macOS. +/// Draw fonts with a thicker stroke, if supported. +/// This is currently only supported on macOS. @"font-thicken": bool = false, +/// Strength of thickening when `font-thicken` is enabled. +/// +/// Valid values are integers between `0` and `255`. `0` does not correspond to +/// *no* thickening, rather it corresponds to the lightest available thickening. +/// +/// Has no effect when `font-thicken` is set to `false`. +/// +/// This is currently only supported on macOS. +@"font-thicken-strength": u8 = 255, + /// All of the configurations behavior adjust various metrics determined by the /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, /// etc.). In each case, the values represent the amount to change the original @@ -320,7 +339,7 @@ const c = @cImport({ /// FreeType load flags to enable. The format of this is a list of flags to /// enable separated by commas. If you prefix a flag with `no-` then it is -/// disabled. If you omit a flag, it's default value is used, so you must +/// disabled. If you omit a flag, its default value is used, so you must /// explicitly disable flags you don't want. You can also use `true` or `false` /// to turn all flags on or off. /// @@ -398,14 +417,17 @@ const c = @cImport({ theme: ?Theme = null, /// Background color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Foreground color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"selection-foreground": ?Color = null, @"selection-background": ?Color = null, @@ -431,15 +453,20 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, @"minimum-contrast": f64 = 1, /// Color palette for the 256 color form that many terminal applications use. -/// The syntax of this configuration is `N=HEXCODE` where `N` is 0 to 255 (for -/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB -/// color code such as `#AABBCC`. +/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for +/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB +/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. /// -/// For definitions on all the codes [see this cheat -/// sheet](https://www.ditig.com/256-colors-cheat-sheet). +/// The palette index can be in decimal, binary, octal, or hexadecimal. +/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, +/// and `0x` for hexadecimal. +/// +/// For definitions on the color indices and what they canonically map to, +/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet). palette: Palette = .{}, /// The color of the cursor. If this is not set, a default will be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"cursor-color": ?Color = null, /// Swap the foreground and background colors of the cell under the cursor. This @@ -493,6 +520,7 @@ palette: Palette = .{}, /// The color of the text under the cursor. If this is not set, a default will /// be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"cursor-text": ?Color = null, /// Enables the ability to move the cursor at prompts by using `alt+click` on @@ -548,7 +576,7 @@ palette: Palette = .{}, /// than 0.01 or greater than 10,000 will be clamped to the nearest valid /// value. /// -/// A value of "1" (default) scrolls te default amount. A value of "2" scrolls +/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls /// double the default amount. A value of "0.5" scrolls half the default amount. /// Et cetera. @"mouse-scroll-multiplier": f64 = 1.0, @@ -560,15 +588,42 @@ palette: Palette = .{}, /// On macOS, background opacity is disabled when the terminal enters native /// fullscreen. This is because the background becomes gray and it can cause /// widgets to show through which isn't generally desirable. +/// +/// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, -/// A positive value enables blurring of the background when background-opacity -/// is less than 1. The value is the blur radius to apply. A value of 20 -/// is reasonable for a good looking blur. Higher values will cause strange -/// rendering issues as well as performance issues. +/// Whether to blur the background when `background-opacity` is less than 1. /// -/// This is only supported on macOS. -@"background-blur-radius": u8 = 0, +/// Valid values are: +/// +/// * a nonnegative integer specifying the *blur intensity* +/// * `false`, equivalent to a blur intensity of 0 +/// * `true`, equivalent to the default blur intensity of 20, which is +/// reasonable for a good looking blur. Higher blur intensities may +/// cause strange rendering and performance issues. +/// +/// Supported on macOS and on some Linux desktop environments, including: +/// +/// * KDE Plasma (Wayland only) +/// +/// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting +/// this setting to either `true` or any positive blur intensity value would +/// achieve the same effect. The reason is that KWin, the window compositor +/// powering Plasma, only has one global blur setting and does not allow +/// applications to specify individual blur settings. +/// +/// To configure KWin's global blur setting, open System Settings and go to +/// "Apps & Windows" > "Window Management" > "Desktop Effects" and select the +/// "Blur" plugin. If disabled, enable it by ticking the checkbox to the left. +/// Then click on the "Configure" button and there will be two sliders that +/// allow you to set background blur and noise intensities for all apps, +/// including Ghostty. +/// +/// All other Linux desktop environments are as of now unsupported. Users may +/// need to set environment-specific settings and/or install third-party plugins +/// in order to support background blur, as there isn't a unified interface for +/// doing so. +@"background-blur-radius": BackgroundBlur = .false, /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see @@ -586,8 +641,14 @@ palette: Palette = .{}, /// that rectangle and can be used to carefully control the dimming effect. /// /// This will default to the background color. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"unfocused-split-fill": ?Color = null, +/// The color of the split divider. If this is not set, a default will be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. +@"split-divider-color": ?Color = null, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: @@ -724,7 +785,7 @@ fullscreen: bool = false, /// This configuration can be reloaded at runtime. If it is set, the title /// will update for all windows. If it is unset, the next title change escape /// sequence will be honored but previous changes will not retroactively -/// be set. This latter case may require you restart programs such as neovim +/// be set. This latter case may require you to restart programs such as Neovim /// to get the new title. title: ?[:0]const u8 = null, @@ -907,6 +968,15 @@ class: ?[:0]const u8 = null, /// Since they are not associated with a specific terminal surface, /// they're never encoded. /// +/// * `performable:` - Only consume the input if the action is able to be +/// performed. For example, the `copy_to_clipboard` action will only +/// consume the input if there is a selection to copy. If there is no +/// selection, Ghostty behaves as if the keybind was not set. This has +/// no effect with `global:` or `all:`-prefixed keybinds. For key +/// sequences, this will reset the sequence if the action is not +/// performable (acting identically to not having a keybind set at +/// all). +/// /// Keybind triggers are not unique per prefix combination. For example, /// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind /// set later will overwrite the keybind set earlier. In this case, the @@ -1042,7 +1112,10 @@ keybind: Keybinds = .{}, /// The font that will be used for the application's window and tab titles. /// -/// This is currently only supported on macOS. +/// If this setting is left unset, the system default font will be used. +/// +/// Note: any font available on the system may be used, this font is not +/// required to be a fixed-width font. @"window-title-font-family": ?[:0]const u8 = null, /// The theme to use for the windows. Valid values: @@ -1104,6 +1177,32 @@ keybind: Keybinds = .{}, @"window-height": u32 = 0, @"window-width": u32 = 0, +/// The starting window position. This position is in pixels and is relative +/// to the top-left corner of the primary monitor. Both values must be set to take +/// effect. If only one value is set, it is ignored. +/// +/// Note that the window manager may put limits on the position or override +/// the position. For example, a tiling window manager may force the window +/// to be a certain position to fit within the grid. There is nothing Ghostty +/// will do about this, but it will make an effort. +/// +/// Also note that negative values are also up to the operating system and +/// window manager. Some window managers may not allow windows to be placed +/// off-screen. +/// +/// Invalid positions are runtime-specific, but generally the positions are +/// clamped to the nearest valid position. +/// +/// On macOS, the window position is relative to the top-left corner of +/// the visible screen area. This means that if the menu bar is visible, the +/// window will be placed below the menu bar. +/// +/// Note: this is only supported on macOS and Linux GLFW builds. The GTK +/// runtime does not support setting the window position (this is a limitation +/// of GTK 4.0). +@"window-position-x": ?i16 = null, +@"window-position-y": ?i16 = null, + /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell /// integration, such as preserving working directories. See `shell-integration` @@ -1152,11 +1251,15 @@ keybind: Keybinds = .{}, /// Background color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-background": ?Color = null, /// Foreground color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-foreground": ?Color = null, /// This controls when resize overlays are shown. Resize overlays are a @@ -1772,21 +1875,19 @@ keybind: Keybinds = .{}, /// The color of the ghost in the macOS app icon. /// -/// The format of the color is the same as the `background` configuration; -/// see that for more information. -/// /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. /// /// This only has an effect when `macos-icon` is set to `custom-style`. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"macos-icon-ghost-color": ?Color = null, /// The color of the screen in the macOS app icon. /// /// The screen is a gradient so you can specify multiple colors that -/// make up the gradient. Colors should be separated by commas. The -/// format of the color is the same as the `background` configuration; -/// see that for more information. +/// make up the gradient. Comma-separated colors may be specified as +/// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors. /// /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. @@ -1905,6 +2006,29 @@ keybind: Keybinds = .{}, /// Changing this value at runtime will only affect new windows. @"adw-toolbar-style": AdwToolbarStyle = .raised, +/// Control the toasts that Ghostty shows. Toasts are small notifications +/// that appear overlaid on top of the terminal window. They are used to +/// show information that is not critical but may be important. +/// +/// Possible toasts are: +/// +/// - `clipboard-copy` (default: true) - Show a toast when text is copied +/// to the clipboard. +/// +/// To specify a toast to enable, specify the name of the toast. To specify +/// a toast to disable, prefix the name with `no-`. For example, to disable +/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`. +/// To enable the clipboard-copy toast, set this configuration to +/// `clipboard-copy`. +/// +/// Multiple toasts can be enabled or disabled by separating them with a comma. +/// +/// A value of "false" will disable all toasts. A value of "true" will +/// enable all toasts. +/// +/// This configuration only applies to GTK with Adwaita enabled. +@"adw-toast": AdwToast = .{}, + /// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs /// are the new typical Gnome style where tabs fill their available space. /// If you set this to `false` then tabs will only take up space they need, @@ -1925,6 +2049,15 @@ keybind: Keybinds = .{}, /// Adwaita support. @"gtk-adwaita": bool = true, +/// Custom CSS files to be loaded. +/// +/// This configuration can be repeated multiple times to load multiple files. +/// Prepend a ? character to the file path to suppress errors if the file does +/// not exist. If you want to include a file that begins with a literal ? +/// character, surround the file path in double quotes ("). +/// The file size limit for a single stylesheet is 5MiB. +@"gtk-custom-css": RepeatablePath = .{}, + /// If `true` (default), applications running in the terminal can show desktop /// notifications using certain escape sequences such as OSC 9 or OSC 777. @"desktop-notifications": bool = true, @@ -1963,10 +2096,11 @@ term: []const u8 = "xterm-ghostty", /// * `download` - Check for updates, automatically download the update, /// notify the user, but do not automatically install the update. /// -/// The default value is `check`. +/// If unset, we defer to Sparkle's default behavior, which respects the +/// preference stored in the standard user defaults (`defaults(1)`). /// /// Changing this value at runtime works after a small delay. -@"auto-update": AutoUpdate = .check, +@"auto-update": ?AutoUpdate = null, /// The release channel to use for auto-updates. /// @@ -2066,6 +2200,25 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { ); { + // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an + // alt keybinding for Copy and shift+ins is an alt keybinding for Paste + // + // The order of these blocks is important. The *last* added keybind for a given action is + // what will display in the menu. We want the more typical keybinds after this block to be + // the standard + if (!builtin.target.isDarwin()) { + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, + .{ .copy_to_clipboard = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .paste_from_clipboard = {} }, + ); + } + // On macOS we default to super but Linux ctrl+shift since // ctrl+c is to kill the process. const mods: inputpkg.Mods = if (builtin.target.isDarwin()) @@ -2124,45 +2277,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { ); // Expand Selection - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, .{ .adjust_selection = .left }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, .{ .adjust_selection = .right }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .adjust_selection = .home }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .adjust_selection = .end }, + .{ .performable = true }, ); // Tabs common to all platforms @@ -2247,12 +2408,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .top }, + .{ .goto_split = .up }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .bottom }, + .{ .goto_split = .down }, ); try result.keybind.set.put( alloc, @@ -2412,10 +2573,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .quit = {} }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, + .{ .performable = true }, ); try result.keybind.set.put( alloc, @@ -2516,12 +2678,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .top }, + .{ .goto_split = .up }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .bottom }, + .{ .goto_split = .down }, ); try result.keybind.set.put( alloc, @@ -2695,6 +2857,9 @@ pub fn loadOptionalFile( fn writeConfigTemplate(path: []const u8) !void { log.info("creating template config file: path={s}", .{path}); + if (std.fs.path.dirname(path)) |dir_path| { + try std.fs.makeDirAbsolute(dir_path); + } const file = try std.fs.createFileAbsolute(path, .{}); defer file.close(); try std.fmt.format( @@ -2798,7 +2963,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { self.@"config-default-files" = true; // Keep track of the replay steps up to this point so we - // can replay if we are disgarding the default files. + // can replay if we are discarding the default files. const replay_len_start = self._replay_steps.items.len; // Keep track of font families because if they are set from the CLI @@ -4036,7 +4201,7 @@ pub const Palette = struct { const eqlIdx = std.mem.indexOf(u8, value, "=") orelse return error.InvalidValue; - const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10); + const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 0); const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; } @@ -4076,6 +4241,28 @@ pub const Palette = struct { try testing.expect(p.value[0].b == 0xCC); } + test "parseCLI base" { + const testing = std.testing; + + var p: Self = .{}; + + try p.parseCLI("0b1=#014589"); + try p.parseCLI("0o7=#234567"); + try p.parseCLI("0xF=#ABCDEF"); + + try testing.expect(p.value[0b1].r == 0x01); + try testing.expect(p.value[0b1].g == 0x45); + try testing.expect(p.value[0b1].b == 0x89); + + try testing.expect(p.value[0o7].r == 0x23); + try testing.expect(p.value[0o7].g == 0x45); + try testing.expect(p.value[0o7].b == 0x67); + + try testing.expect(p.value[0xF].r == 0xAB); + try testing.expect(p.value[0xF].g == 0xCD); + try testing.expect(p.value[0xF].b == 0xEF); + } + test "parseCLI overflow" { const testing = std.testing; @@ -4340,6 +4527,45 @@ pub const RepeatablePath = struct { // If it isn't absolute, we need to make it absolute relative // to the base. var buf: [std.fs.max_path_bytes]u8 = undefined; + + // Check if the path starts with a tilde and expand it to the + // home directory on Linux/macOS. We explicitly look for "~/" + // because we don't support alternate users such as "~alice/" + if (std.mem.startsWith(u8, path, "~/")) expand: { + // Windows isn't supported yet + if (comptime builtin.os.tag == .windows) break :expand; + + const expanded: []const u8 = internal_os.expandHome( + path, + &buf, + ) catch |err| { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error expanding home directory for path {s}: {}", + .{ path, err }, + ), + }); + + // Blank this path so that we don't attempt to resolve it + // again + self.value.items[i] = .{ .required = "" }; + + continue; + }; + + log.debug( + "expanding file path from home directory: path={s}", + .{expanded}, + ); + + switch (self.value.items[i]) { + .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded), + } + + continue; + } + const abs = dir.realpath(path, &buf) catch |err| abs: { if (err == error.FileNotFound) { // The file doesn't exist. Try to resolve the relative path @@ -5348,6 +5574,11 @@ pub const AdwToolbarStyle = enum { @"raised-border", }; +/// See adw-toast +pub const AdwToast = packed struct { + @"clipboard-copy": bool = true, +}; + /// See mouse-shift-capture pub const MouseShiftCapture = enum { false, @@ -5441,6 +5672,70 @@ pub const AutoUpdate = enum { download, }; +/// See background-blur-radius +pub const BackgroundBlur = union(enum) { + false, + true, + radius: u8, + + pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { + const input_ = input orelse { + // Emulate behavior for bools + self.* = .true; + 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 }; + } + + pub fn cval(self: BackgroundBlur) u8 { + return switch (self) { + .false => 0, + .true => 20, + .radius => |v| v, + }; + } + + pub fn formatEntry( + self: BackgroundBlur, + formatter: anytype, + ) !void { + switch (self) { + .false => try formatter.formatEntry(bool, false), + .true => try formatter.formatEntry(bool, true), + .radius => |v| try formatter.formatEntry(u8, v), + } + } + + test "parse BackgroundBlur" { + const testing = std.testing; + var v: BackgroundBlur = undefined; + + try v.parseCLI(null); + try testing.expectEqual(.true, v); + + try v.parseCLI("true"); + try testing.expectEqual(.true, v); + + try v.parseCLI("false"); + try testing.expectEqual(.false, v); + + try v.parseCLI("42"); + try testing.expectEqual(42, v.radius); + + try testing.expectError(error.InvalidValue, v.parseCLI("")); + try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); + try testing.expectError(error.InvalidValue, v.parseCLI("420")); + } +}; + /// See theme pub const Theme = struct { light: []const u8, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index dd7c7cce8..6804b0ae0 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { ptr.* = @intCast(value); }, + i16 => { + const ptr: *c_short = @ptrCast(@alignCast(ptr_raw)); + ptr.* = @intCast(value); + }, + f32, f64 => |Float| { const ptr: *Float = @ptrCast(@alignCast(ptr_raw)); ptr.* = @floatCast(value); @@ -79,6 +84,17 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { ptr.* = @intCast(@as(Backing, @bitCast(value))); }, + .Union => |_| { + if (@hasDecl(T, "cval")) { + const PtrT = @typeInfo(@TypeOf(T.cval)).Fn.return_type.?; + const ptr: *PtrT = @ptrCast(@alignCast(ptr_raw)); + ptr.* = value.cval(); + return true; + } + + return false; + }, + else => return false, }, } @@ -167,3 +183,30 @@ test "c_get: optional" { try testing.expectEqual(0, cval.b); } } + +test "c_get: background-blur" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + + { + c.@"background-blur-radius" = .false; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expectEqual(0, cval); + } + { + c.@"background-blur-radius" = .true; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expectEqual(20, cval); + } + { + c.@"background-blur-radius" = .{ .radius = 42 }; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expectEqual(42, cval); + } +} diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index 14f2e484c..e9c49048c 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -3,7 +3,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const sentry = @import("sentry"); +const build_options = @import("build_options"); +const sentry = if (build_options.sentry) @import("sentry"); const internal_os = @import("../os/main.zig"); const crash = @import("main.zig"); const state = &@import("../global.zig").state; @@ -47,6 +48,8 @@ pub threadlocal var thread_state: ?ThreadState = null; /// It is up to the user to grab the logs and manually send them to us /// (or they own Sentry instance) if they want to. pub fn init(gpa: Allocator) !void { + if (comptime !build_options.sentry) return; + // Not supported on Windows currently, doesn't build. if (comptime builtin.os.tag == .windows) return; @@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void { } fn initThread(gpa: Allocator) !void { + if (comptime !build_options.sentry) return; + var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const alloc = arena.allocator(); @@ -101,7 +106,23 @@ fn initThread(gpa: Allocator) !void { sentry.c.sentry_options_set_before_send(opts, beforeSend, null); // Determine the Sentry cache directory. - const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" }); + const cache_dir = cache_dir: { + // On macOS, we prefer to use the NSCachesDirectory value to be + // a more idiomatic macOS application. But if XDG env vars are set + // we will respect them. + if (comptime builtin.os.tag == .macos) macos: { + if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos; + break :cache_dir try internal_os.macos.cacheDir( + alloc, + "sentry", + ); + } + + break :cache_dir try internal_os.xdg.cache( + alloc, + .{ .subdir = "ghostty/sentry" }, + ); + }; sentry.c.sentry_options_set_database_path_n( opts, cache_dir.ptr, @@ -129,6 +150,8 @@ fn initThread(gpa: Allocator) !void { /// Process-wide deinitialization of our Sentry client. This ensures all /// our data is flushed. pub fn deinit() void { + if (comptime !build_options.sentry) return; + if (comptime builtin.os.tag == .windows) return; // If we're still initializing then wait for init to finish. This diff --git a/src/font/discovery.zig b/src/font/discovery.zig index e73ea626f..071407d92 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -551,7 +551,7 @@ pub const CoreText = struct { for (0..result.len) |i| { result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); - // We need to retain becauseonce the list is freed it will + // We need to retain because once the list is freed it will // release all its members. result[i].retain(); } diff --git a/src/font/face.zig b/src/font/face.zig index 9f80c5637..1c74515e3 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -100,6 +100,15 @@ pub const RenderOptions = struct { /// /// This only works with CoreText currently. thicken: bool = false, + + /// "Strength" of the thickening, between `0` and `255`. + /// Only has an effect when `thicken` is enabled. + /// + /// `0` does not correspond to *no* thickening, + /// just the *lightest* thickening available. + /// + /// CoreText only. + thicken_strength: u8 = 255, }; test { diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index dd4f6432e..8da2b6a55 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -354,7 +354,7 @@ pub const Face = struct { .depth = 1, .space = try macos.graphics.ColorSpace.createDeviceGray(), .context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) & - @intFromEnum(macos.graphics.ImageAlphaInfo.none), + @intFromEnum(macos.graphics.ImageAlphaInfo.only), } else .{ .color = true, .depth = 4, @@ -398,7 +398,7 @@ pub const Face = struct { if (color.color) context.setRGBFillColor(ctx, 1, 1, 1, 0) else - context.setGrayFillColor(ctx, 0, 0); + context.setGrayFillColor(ctx, 1, 0); context.fillRect(ctx, .{ .origin = .{ .x = 0, .y = 0 }, .size = .{ @@ -421,8 +421,9 @@ pub const Face = struct { context.setRGBFillColor(ctx, 1, 1, 1, 1); context.setRGBStrokeColor(ctx, 1, 1, 1, 1); } else { - context.setGrayFillColor(ctx, 1, 1); - context.setGrayStrokeColor(ctx, 1, 1); + const strength: f64 = @floatFromInt(opts.thicken_strength); + context.setGrayFillColor(ctx, strength / 255.0, 1); + context.setGrayStrokeColor(ctx, strength / 255.0, 1); } // If we are drawing with synthetic bold then use a fill stroke diff --git a/src/font/shape.zig b/src/font/shape.zig index 3721c63a6..cc67fc7a0 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); +const feature = @import("shaper/feature.zig"); pub const noop = @import("shaper/noop.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); @@ -8,6 +9,9 @@ pub const web_canvas = @import("shaper/web_canvas.zig"); pub const Cache = @import("shaper/Cache.zig"); pub const TextRun = run.TextRun; pub const RunIterator = run.RunIterator; +pub const Feature = feature.Feature; +pub const FeatureList = feature.FeatureList; +pub const default_features = feature.default_features; /// Shaper implementation for our compile options. pub const Shaper = switch (options.backend) { @@ -49,10 +53,7 @@ pub const Cell = struct { /// Options for shapers. pub const Options = struct { - /// Font features to use when shaping. These can be in the following - /// formats: "-feat" "+feat" "feat". A "-"-prefix is used to disable - /// a feature and the others are used to enable a feature. If a feature - /// isn't supported or is invalid, it will be ignored. + /// Font features to use when shaping. /// /// Note: eventually, this will move to font.Face probably as we may /// want to support per-face feature configuration. For now, we only diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index dbc9809e3..e084a68c9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -7,6 +7,9 @@ const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); +const Feature = font.shape.Feature; +const FeatureList = font.shape.FeatureList; +const default_features = font.shape.default_features; const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -40,9 +43,10 @@ pub const Shaper = struct { /// The string used for shaping the current run. run_state: RunState, - /// The font features we want to use. The hardcoded features are always - /// set first. - features: FeatureList, + /// CoreFoundation Dictionary which represents our font feature settings. + features: *macos.foundation.Dictionary, + /// A version of the features dictionary with the default features excluded. + features_no_default: *macos.foundation.Dictionary, /// The shared memory used for shaping results. cell_buf: CellBuf, @@ -100,51 +104,17 @@ pub const Shaper = struct { } }; - /// List of font features, parsed into the data structures used by - /// the CoreText API. The CoreText API requires a pretty annoying wrapping - /// to setup font features: - /// - /// - The key parsed into a CFString - /// - The value parsed into a CFNumber - /// - The key and value are then put into a CFDictionary - /// - The CFDictionary is then put into a CFArray - /// - The CFArray is then put into another CFDictionary - /// - The CFDictionary is then passed to the CoreText API to create - /// a new font with the features set. - /// - /// This structure handles up to the point that we have a CFArray of - /// CFDictionary objects representing the font features and provides - /// functions for creating the dictionary to init the font. - const FeatureList = struct { - list: *macos.foundation.MutableArray, + /// Create a CoreFoundation Dictionary suitable for + /// settings the font features of a CoreText font. + fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary { + const list = try macos.foundation.MutableArray.create(); + errdefer list.release(); - pub fn init() !FeatureList { - var list = try macos.foundation.MutableArray.create(); - errdefer list.release(); - return .{ .list = list }; - } - - pub fn deinit(self: FeatureList) void { - self.list.release(); - } - - /// Append the given feature to the list. The feature syntax is - /// the same as Harfbuzz: "feat" enables it and "-feat" disables it. - pub fn append(self: *FeatureList, name_raw: []const u8) !void { - // If the name is `-name` then we are disabling the feature, - // otherwise we are enabling it, so we need to parse this out. - const name = if (name_raw[0] == '-') name_raw[1..] else name_raw; - const dict = try featureDict(name, name_raw[0] != '-'); - defer dict.release(); - self.list.appendValue(macos.foundation.Dictionary, dict); - } - - /// Create the dictionary for the given feature and value. - fn featureDict(name: []const u8, v: bool) !*macos.foundation.Dictionary { - const value_num: c_int = @intFromBool(v); + for (feats) |feat| { + const value_num: c_int = @intCast(feat.value); // Keys can only be ASCII. - var key = try macos.foundation.String.createWithBytes(name, .ascii, false); + var key = try macos.foundation.String.createWithBytes(&feat.tag, .ascii, false); defer key.release(); var value = try macos.foundation.Number.create(.int, &value_num); defer value.release(); @@ -154,50 +124,44 @@ pub const Shaper = struct { macos.text.c.kCTFontOpenTypeFeatureTag, macos.text.c.kCTFontOpenTypeFeatureValue, }, - &[_]?*const anyopaque{ - key, - value, - }, + &[_]?*const anyopaque{ key, value }, ); - errdefer dict.release(); - return dict; + defer dict.release(); + + list.appendValue(macos.foundation.Dictionary, dict); } - /// Returns the dictionary to use with the font API to set the - /// features. This should be released by the caller. - pub fn attrsDict( - self: FeatureList, - omit_defaults: bool, - ) !*macos.foundation.Dictionary { - // Get our feature list. If we're omitting defaults then we - // slice off the hardcoded features. - const list = if (!omit_defaults) self.list else list: { - const list = try macos.foundation.MutableArray.createCopy(@ptrCast(self.list)); - for (hardcoded_features) |_| list.removeValue(0); - break :list list; - }; - defer if (omit_defaults) list.release(); + var dict = try macos.foundation.Dictionary.create( + &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, + &[_]?*const anyopaque{list}, + ); + errdefer dict.release(); - var dict = try macos.foundation.Dictionary.create( - &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, - &[_]?*const anyopaque{list}, - ); - errdefer dict.release(); - return dict; - } - }; - - // These features are hardcoded to always be on by default. Users - // can turn them off by setting the features to "-liga" for example. - const hardcoded_features = [_][]const u8{ "dlig", "liga" }; + return dict; + } /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { - var feats = try FeatureList.init(); - errdefer feats.deinit(); - for (hardcoded_features) |name| try feats.append(name); - for (opts.features) |name| try feats.append(name); + var feature_list: FeatureList = .{}; + defer feature_list.deinit(alloc); + for (opts.features) |feature_str| { + try feature_list.appendFromString(alloc, feature_str); + } + + // We need to construct two attrs dictionaries for font features; + // one without the default features included, and one with them. + const feats = feature_list.features.items; + const feats_df = try alloc.alloc(Feature, feats.len + default_features.len); + defer alloc.free(feats_df); + + @memcpy(feats_df[0..default_features.len], &default_features); + @memcpy(feats_df[default_features.len..], feats); + + const features = try makeFeaturesDict(feats_df); + errdefer features.release(); + const features_no_default = try makeFeaturesDict(feats); + errdefer features_no_default.release(); var run_state = RunState.init(); errdefer run_state.deinit(alloc); @@ -242,7 +206,8 @@ pub const Shaper = struct { .alloc = alloc, .cell_buf = .{}, .run_state = run_state, - .features = feats, + .features = features, + .features_no_default = features_no_default, .writing_direction = writing_direction, .cached_fonts = .{}, .cached_font_grid = 0, @@ -255,7 +220,8 @@ pub const Shaper = struct { pub fn deinit(self: *Shaper) void { self.cell_buf.deinit(self.alloc); self.run_state.deinit(self.alloc); - self.features.deinit(); + self.features.release(); + self.features_no_default.release(); self.writing_direction.release(); { @@ -509,8 +475,8 @@ pub const Shaper = struct { // If we have it, return the cached attr dict. if (self.cached_fonts.items[index_int]) |cached| return cached; - // Features dictionary, font descriptor, font - try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3); + // Font descriptor, font + try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2); const run_font = font: { // The CoreText shaper relies on CoreText and CoreText claims @@ -533,8 +499,10 @@ pub const Shaper = struct { const face = try grid.resolver.collection.getFace(index); const original = face.font; - const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features); - self.cf_release_pool.appendAssumeCapacity(attrs); + const attrs = if (face.quirks_disable_default_font_features) + self.features_no_default + else + self.features; const desc = try macos.text.FontDescriptor.createWithAttributes(attrs); self.cf_release_pool.appendAssumeCapacity(desc); diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig new file mode 100644 index 000000000..8e70d51da --- /dev/null +++ b/src/font/shaper/feature.zig @@ -0,0 +1,390 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.font_shaper); + +/// Represents an OpenType font feature setting, which consists of a tag and +/// a numeric parameter >= 0. Most features are boolean, so only parameters +/// of 0 and 1 make sense for them, but some (e.g. 'cv01'..'cv99') can take +/// parameters to choose between multiple variants of a given character or +/// characters. +/// +/// Ref: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2#features-and-lookups +/// - https://harfbuzz.github.io/shaping-opentype-features.html +pub const Feature = struct { + tag: [4]u8, + value: u32, + + pub fn fromString(str: []const u8) ?Feature { + var fbs = std.io.fixedBufferStream(str); + const reader = fbs.reader(); + return Feature.fromReader(reader); + } + + /// Parse a single font feature setting from a std.io.Reader, with a version + /// of the syntax of HarfBuzz's font feature strings. Stops at end of stream + /// or when a ',' is encountered. + /// + /// This parsing aims to be as error-tolerant as possible while avoiding any + /// assumptions in ambiguous scenarios. When invalid syntax is encountered, + /// the reader is advanced to the next boundary (end-of-stream or ',') so + /// that further features may be read. + /// + /// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string + pub fn fromReader(reader: anytype) ?Feature { + var tag: [4]u8 = undefined; + var value: ?u32 = null; + + // TODO: when we move to Zig 0.14 this can be replaced with a + // labeled switch continue pattern rather than this loop. + var state: union(enum) { + /// Initial state. + start: void, + /// Parsing the tag, data is index. + tag: u2, + /// In the space between the tag and the value. + space: void, + /// Parsing an integer parameter directly in to `value`. + int: void, + /// Parsing a boolean keyword parameter ("on"/"off"). + bool: void, + /// Encountered an unrecoverable syntax error, advancing to boundary. + err: void, + /// Done parsing feature. + done: void, + } = .start; + while (true) { + // If we hit the end of the stream we just pretend it's a comma. + const byte = reader.readByte() catch ','; + switch (state) { + // If we're done then we skip whitespace until we see a ','. + .done => switch (byte) { + ' ', '\t' => continue, + ',' => break, + // If we see something other than whitespace or a ',' + // then this is an error since the intent is unclear. + else => { + state = .err; + continue; + }, + }, + + // If we're fast-forwarding from an error we just wanna + // stop at the first boundary and ignore all other bytes. + .err => if (byte == ',') return null, + + .start => switch (byte) { + // Ignore leading whitespace. + ' ', '\t' => continue, + // Empty feature string. + ',' => return null, + // '+' prefix to explicitly enable feature. + '+' => { + value = 1; + state = .{ .tag = 0 }; + continue; + }, + // '-' prefix to explicitly disable feature. + '-' => { + value = 0; + state = .{ .tag = 0 }; + continue; + }, + // Quote mark introducing a tag. + '"', '\'' => { + state = .{ .tag = 0 }; + continue; + }, + // First letter of tag. + else => { + tag[0] = byte; + state = .{ .tag = 1 }; + continue; + }, + }, + + .tag => |*i| switch (byte) { + // If the tag is interrupted by a comma it's invalid. + ',' => return null, + // Ignore quote marks. + '"', '\'' => continue, + // A prefix of '+' or '-' + // In all other cases we add the byte to our tag. + else => { + tag[i.*] = byte; + if (i.* == 3) { + state = .space; + continue; + } + i.* += 1; + }, + }, + + .space => switch (byte) { + ' ', '\t' => continue, + // Ignore quote marks since we might have a + // closing quote from the tag still ahead. + '"', '\'' => continue, + // Allow an '=' (which we can safely ignore) + // only if we don't already have a value due + // to a '+' or '-' prefix. + '=' => if (value != null) { + state = .err; + continue; + }, + ',' => { + // Specifying only a tag turns a feature on. + if (value == null) value = 1; + break; + }, + '0'...'9' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) { + state = .err; + continue; + } + value = byte - '0'; + state = .int; + continue; + }, + 'o', 'O' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) { + state = .err; + continue; + } + state = .bool; + continue; + }, + else => { + state = .err; + continue; + }, + }, + + .int => switch (byte) { + ',' => break, + '0'...'9' => { + // If our value gets too big while + // parsing we consider it an error. + value = std.math.mul(u32, value.?, 10) catch { + state = .err; + continue; + }; + value.? += byte - '0'; + }, + else => { + state = .err; + continue; + }, + }, + + .bool => switch (byte) { + ',' => return null, + 'n', 'N' => { + // "ofn" + if (value != null) { + assert(value == 0); + state = .err; + continue; + } + value = 1; + state = .done; + continue; + }, + 'f', 'F' => { + // To make sure we consume two 'f's. + if (value == null) { + value = 0; + } else { + assert(value == 0); + state = .done; + continue; + } + }, + else => { + state = .err; + continue; + }, + }, + } + } + + assert(value != null); + + return .{ + .tag = tag, + .value = value.?, + }; + } + + /// Serialize this feature to the provided buffer. + /// The string that this produces should be valid to parse. + pub fn toString(self: *const Feature, buf: []u8) !void { + var fbs = std.io.fixedBufferStream(buf); + try self.format("", .{}, fbs.writer()); + } + + /// Formatter for logging + pub fn format( + self: Feature, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + if (self.value <= 1) { + // Format boolean options as "+tag" for on and "-tag" for off. + try std.fmt.format(writer, "{c}{s}", .{ + "-+"[self.value], + self.tag, + }); + } else { + // Format non-boolean tags as "tag=value". + try std.fmt.format(writer, "{s}={d}", .{ + self.tag, + self.value, + }); + } + } +}; + +/// A list of font feature settings (see `Feature` for more documentation). +pub const FeatureList = struct { + features: std.ArrayListUnmanaged(Feature) = .{}, + + pub fn deinit(self: *FeatureList, alloc: Allocator) void { + self.features.deinit(alloc); + } + + /// Parse a comma separated list of features. + /// See `Feature.fromReader` for more docs. + pub fn fromString(alloc: Allocator, str: []const u8) !FeatureList { + var self: FeatureList = .{}; + try self.appendFromString(alloc, str); + return self; + } + + /// Append features to this list from a string with a comma separated list. + /// See `Feature.fromReader` for more docs. + pub fn appendFromString( + self: *FeatureList, + alloc: Allocator, + str: []const u8, + ) !void { + var fbs = std.io.fixedBufferStream(str); + const reader = fbs.reader(); + while (fbs.pos < fbs.buffer.len) { + const i = fbs.pos; + if (Feature.fromReader(reader)) |feature| { + try self.features.append(alloc, feature); + } else log.warn( + "failed to parse font feature setting: \"{s}\"", + .{fbs.buffer[i..fbs.pos]}, + ); + } + } + + /// Formatter for logging + pub fn format( + self: FeatureList, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + for (self.features.items, 0..) |feature, i| { + try feature.format(layout, opts, writer); + if (i != std.features.items.len - 1) try writer.writeAll(", "); + } + if (self.value <= 1) { + // Format boolean options as "+tag" for on and "-tag" for off. + try std.fmt.format(writer, "{c}{s}", .{ + "-+"[self.value], + self.tag, + }); + } else { + // Format non-boolean tags as "tag=value". + try std.fmt.format(writer, "{s}={d}", .{ + self.tag, + self.value, + }); + } + } +}; + +/// These features are hardcoded to always be on by default. Users +/// can turn them off by setting the features to "-liga" for example. +pub const default_features = [_]Feature{ + .{ .tag = "dlig".*, .value = 1 }, + .{ .tag = "liga".*, .value = 1 }, +}; + +test "Feature.fromString" { + const testing = std.testing; + + // This is not *complete* coverage of every possible + // combination of syntax, but it covers quite a few. + + // Boolean settings (on) + const kern_on = Feature{ .tag = "kern".*, .value = 1 }; + try testing.expectEqual(kern_on, Feature.fromString("kern")); + try testing.expectEqual(kern_on, Feature.fromString("kern, ")); + try testing.expectEqual(kern_on, Feature.fromString("kern on")); + try testing.expectEqual(kern_on, Feature.fromString("kern on, ")); + try testing.expectEqual(kern_on, Feature.fromString("+kern")); + try testing.expectEqual(kern_on, Feature.fromString("+kern, ")); + try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1")); + try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1, ")); + + // Boolean settings (off) + const kern_off = Feature{ .tag = "kern".*, .value = 0 }; + try testing.expectEqual(kern_off, Feature.fromString("kern off")); + try testing.expectEqual(kern_off, Feature.fromString("kern off, ")); + try testing.expectEqual(kern_off, Feature.fromString("-'kern'")); + try testing.expectEqual(kern_off, Feature.fromString("-'kern', ")); + try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0")); + try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0, ")); + + // Non-boolean settings + const aalt_2 = Feature{ .tag = "aalt".*, .value = 2 }; + try testing.expectEqual(aalt_2, Feature.fromString("aalt=2")); + try testing.expectEqual(aalt_2, Feature.fromString("aalt=2, ")); + try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2")); + try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2, ")); + + // Various ambiguous/error cases which should be null + try testing.expectEqual(null, Feature.fromString("aalt=2x")); // bad number + try testing.expectEqual(null, Feature.fromString("toolong")); // tag too long + try testing.expectEqual(null, Feature.fromString("sht")); // tag too short + try testing.expectEqual(null, Feature.fromString("-kern 1")); // redundant/conflicting + try testing.expectEqual(null, Feature.fromString("-kern on")); // redundant/conflicting + try testing.expectEqual(null, Feature.fromString("aalt=o,")); // bad keyword + try testing.expectEqual(null, Feature.fromString("aalt=ofn,")); // bad keyword +} + +test "FeatureList.fromString" { + const testing = std.testing; + + const str = + " kern, kern on , +kern, \"kern\" = 1," ++ // Boolean settings (on) + "kern off, -'kern' , \"kern\"=0," ++ // Boolean settings (off) + "aalt=2, 'aalt'\t2," ++ // Non-boolean settings + "aalt=2x, toolong, sht, -kern 1, -kern on, aalt=o, aalt=ofn," ++ // Invalid cases + "last"; // To ensure final element is included correctly. + var feats = try FeatureList.fromString(testing.allocator, str); + defer feats.deinit(testing.allocator); + try testing.expectEqualSlices( + Feature, + &(.{Feature{ .tag = "kern".*, .value = 1 }} ** 4 ++ + .{Feature{ .tag = "kern".*, .value = 0 }} ** 3 ++ + .{Feature{ .tag = "aalt".*, .value = 2 }} ** 2 ++ + .{Feature{ .tag = "last".*, .value = 1 }}), + feats.features.items, + ); +} diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index ccb422f20..97292b9b0 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -3,6 +3,10 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); +const terminal = @import("../../terminal/main.zig"); +const Feature = font.shape.Feature; +const FeatureList = font.shape.FeatureList; +const default_features = font.shape.default_features; const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -10,7 +14,6 @@ const Library = font.Library; const SharedGrid = font.SharedGrid; const Style = font.Style; const Presentation = font.Presentation; -const terminal = @import("../../terminal/main.zig"); const log = std.log.scoped(.font_shaper); @@ -27,38 +30,37 @@ pub const Shaper = struct { cell_buf: CellBuf, /// The features to use for shaping. - hb_feats: FeatureList, + hb_feats: []harfbuzz.Feature, const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); - const FeatureList = std.ArrayListUnmanaged(harfbuzz.Feature); - - // These features are hardcoded to always be on by default. Users - // can turn them off by setting the features to "-liga" for example. - const hardcoded_features = [_][]const u8{ "dlig", "liga" }; /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { - // Parse all the features we want to use. We use - var hb_feats = hb_feats: { - var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len); - errdefer list.deinit(alloc); - - for (hardcoded_features) |name| { - if (harfbuzz.Feature.fromString(name)) |feat| { - try list.append(alloc, feat); - } else log.warn("failed to parse font feature: {s}", .{name}); + // Parse all the features we want to use. + const hb_feats = hb_feats: { + var feature_list: FeatureList = .{}; + defer feature_list.deinit(alloc); + try feature_list.features.appendSlice(alloc, &default_features); + for (opts.features) |feature_str| { + try feature_list.appendFromString(alloc, feature_str); } - for (opts.features) |name| { - if (harfbuzz.Feature.fromString(name)) |feat| { - try list.append(alloc, feat); - } else log.warn("failed to parse font feature: {s}", .{name}); + var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len); + errdefer alloc.free(list); + + for (feature_list.features.items, 0..) |feature, i| { + list[i] = .{ + .tag = std.mem.nativeToBig(u32, @bitCast(feature.tag)), + .value = feature.value, + .start = harfbuzz.c.HB_FEATURE_GLOBAL_START, + .end = harfbuzz.c.HB_FEATURE_GLOBAL_END, + }; } break :hb_feats list; }; - errdefer hb_feats.deinit(alloc); + errdefer alloc.free(hb_feats); return Shaper{ .alloc = alloc, @@ -71,7 +73,7 @@ pub const Shaper = struct { pub fn deinit(self: *Shaper) void { self.hb_buf.destroy(); self.cell_buf.deinit(self.alloc); - self.hb_feats.deinit(self.alloc); + self.alloc.free(self.hb_feats); } pub fn endFrame(self: *const Shaper) void { @@ -125,10 +127,10 @@ pub const Shaper = struct { // If we are disabling default font features we just offset // our features by the hardcoded items because always // add those at the beginning. - break :i hardcoded_features.len; + break :i default_features.len; }; - harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats.items[i..]); + harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]); } // If our buffer is empty, we short-circuit the rest of the work diff --git a/src/global.zig b/src/global.zig index 7e43a9184..c00ce27a4 100644 --- a/src/global.zig +++ b/src/global.zig @@ -27,6 +27,7 @@ pub const GlobalState = struct { alloc: std.mem.Allocator, action: ?cli.Action, logging: Logging, + rlimits: ResourceLimits = .{}, /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. @@ -56,6 +57,7 @@ pub const GlobalState = struct { .alloc = undefined, .action = null, .logging = .{ .stderr = {} }, + .rlimits = .{}, .resources_dir = null, }; errdefer self.deinit(); @@ -123,8 +125,8 @@ pub const GlobalState = struct { std.log.info("renderer={}", .{renderer.Renderer}); std.log.info("libxev backend={}", .{xev.backend}); - // First things first, we fix our file descriptors - internal_os.fixMaxFiles(); + // As early as possible, initialize our resource limits. + self.rlimits = ResourceLimits.init(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { @@ -174,3 +176,21 @@ pub const GlobalState = struct { } } }; + +/// Maintains the Unix resource limits that we set for our process. This +/// can be used to restore the limits to their original values. +pub const ResourceLimits = struct { + nofile: ?internal_os.rlimit = null, + + pub fn init() ResourceLimits { + return .{ + // Maximize the number of file descriptors we can have open + // because we can consume a lot of them if we make many terminals. + .nofile = internal_os.fixMaxFiles(), + }; + } + + pub fn restore(self: *const ResourceLimits) void { + if (self.nofile) |lim| internal_os.restoreMaxFiles(lim); + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b2c03b674..e0ad6c571 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -36,6 +36,11 @@ pub const Flags = packed struct { /// and not just while Ghostty is focused. This may not work on all platforms. /// See the keybind config documentation for more information. global: bool = false, + + /// True if this binding should only be triggered if the action can be + /// performed. If the action can't be performed then the binding acts as + /// if it doesn't exist. + performable: bool = false, }; /// Full binding parser. The binding parser is implemented as an iterator @@ -90,6 +95,9 @@ pub const Parser = struct { } else if (std.mem.eql(u8, prefix, "unconsumed")) { if (!flags.consumed) return Error.InvalidFormat; flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "performable")) { + if (flags.performable) return Error.InvalidFormat; + flags.performable = true; } else { // If we don't recognize the prefix then we're done. // There are trigger-specific prefixes like "physical:" so @@ -185,10 +193,29 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { if (rhs.trigger.mods.alt) count += 1; break :blk count; }; - if (lhs_count == rhs_count) + + if (lhs_count != rhs_count) + return lhs_count > rhs_count; + + if (lhs.trigger.mods.int() != rhs.trigger.mods.int()) return lhs.trigger.mods.int() > rhs.trigger.mods.int(); - return lhs_count > rhs_count; + const lhs_key: c_int = blk: { + switch (lhs.trigger.key) { + .translated => break :blk @intFromEnum(lhs.trigger.key.translated), + .physical => break :blk @intFromEnum(lhs.trigger.key.physical), + .unicode => break :blk @intCast(lhs.trigger.key.unicode), + } + }; + const rhs_key: c_int = blk: { + switch (rhs.trigger.key) { + .translated => break :blk @intFromEnum(rhs.trigger.key.translated), + .physical => break :blk @intFromEnum(rhs.trigger.key.physical), + .unicode => break :blk @intCast(rhs.trigger.key.unicode), + } + }; + + return lhs_key < rhs_key; } /// The set of actions that a keybinding can take. @@ -203,7 +230,7 @@ pub const Action = union(enum) { unbind: void, /// Send a CSI sequence. The value should be the CSI sequence without the - /// CSI header (`ESC ]` or `\x1b]`). + /// CSI header (`ESC [` or `\x1b[`). csi: []const u8, /// Send an `ESC` sequence. @@ -302,7 +329,7 @@ pub const Action = union(enum) { goto_tab: usize, /// Moves a tab by a relative offset. - /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 for right). + /// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right. /// If the new position is out of bounds, it wraps around cyclically within the tab range. move_tab: isize, @@ -311,17 +338,18 @@ pub const Action = union(enum) { toggle_tab_overview: void, /// Create a new split in the given direction. The new split will appear in - /// the direction given. + /// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto. new_split: SplitDirection, - /// Focus on a split in a given direction. + /// Focus on a split in a given direction. For example `goto_split:up`. + /// Valid values are left, right, up, down, previous and next. goto_split: SplitFocusDirection, /// zoom/unzoom the current split. toggle_split_zoom: void, /// Resize the current split by moving the split divider in the given - /// direction + /// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right. resize_split: SplitResizeParameter, /// Equalize all splits in the current window @@ -478,10 +506,42 @@ pub const Action = union(enum) { previous, next, - top, + up, left, - bottom, + down, right, + + pub fn parse(input: []const u8) !SplitFocusDirection { + return std.meta.stringToEnum(SplitFocusDirection, input) orelse { + // For backwards compatibility we map "top" and "bottom" onto the enum + // values "up" and "down" + if (std.mem.eql(u8, input, "top")) { + return .up; + } else if (std.mem.eql(u8, input, "bottom")) { + return .down; + } else { + return Error.InvalidFormat; + } + }; + } + + test "parse" { + const testing = std.testing; + + try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous")); + try testing.expectEqual(.next, try SplitFocusDirection.parse("next")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("up")); + try testing.expectEqual(.left, try SplitFocusDirection.parse("left")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("down")); + try testing.expectEqual(.right, try SplitFocusDirection.parse("right")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("top")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom")); + + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("")); + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green")); + } }; pub const SplitResizeDirection = enum { @@ -524,7 +584,16 @@ pub const Action = union(enum) { comptime field: std.builtin.Type.UnionField, param: []const u8, ) !field.type { - return switch (@typeInfo(field.type)) { + const field_info = @typeInfo(field.type); + + // Fields can provide a custom "parse" function + if (field_info == .Struct or field_info == .Union or field_info == .Enum) { + if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) { + return field.type.parse(param); + } + } + + return switch (field_info) { .Enum => try parseEnum(field.type, param), .Int => try parseInt(field.type, param), .Float => try parseFloat(field.type, param), @@ -1647,6 +1716,16 @@ test "parse: triggers" { .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); + // performable keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .performable = true }, + }, try parseSingle("performable:shift+a=ignore")); + // invalid key try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 734885097..2e7935214 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -282,7 +282,12 @@ fn legacy( // If we match a control sequence, we output that directly. For // ctrlSeq we have to use all mods because we want it to only // match ctrl+. - if (ctrlSeq(self.event.utf8, self.event.unshifted_codepoint, all_mods)) |char| { + if (ctrlSeq( + self.event.key, + self.event.utf8, + self.event.unshifted_codepoint, + all_mods, + )) |char| { // C0 sequences support alt-as-esc prefixing. if (binding_mods.alt) { if (buf.len < 2) return error.OutOfMemory; @@ -538,19 +543,17 @@ fn pcStyleFunctionKey( /// into a C0 byte. There are many cases for this and you should read /// the source code to understand them. fn ctrlSeq( + logical_key: key.Key, utf8: []const u8, unshifted_codepoint: u21, mods: key.Mods, ) ?u8 { + const ctrl_only = comptime (key.Mods{ .ctrl = true }).int(); + // If ctrl is not pressed then we never do anything. if (!mods.ctrl) return null; - // If we don't have exactly one byte in our utf8 sequence, then - // we don't do anything, since all our ctrl keys are based on ASCII. - if (utf8.len != 1) return null; - const char, const unset_mods = unset_mods: { - var char = utf8[0]; var unset_mods = mods; // Remove alt from our modifiers because it does not impact whether @@ -558,6 +561,34 @@ fn ctrlSeq( // logic separately. unset_mods.alt = false; + var char: u8 = char: { + // If we have exactly one UTF8 byte, we assume that is the + // character we want to convert to a C0 byte. + if (utf8.len == 1) break :char utf8[0]; + + // If we have a logical key that maps to a single byte + // printable character, we use that. History to explain this: + // this was added to support cyrillic keyboard layouts such + // as Russian and Mongolian. These layouts have a `c` key that + // maps to U+0441 (cyrillic small letter "c") but every + // terminal I've tested encodes this as ctrl+c. + if (logical_key.codepoint()) |cp| { + if (std.math.cast(u8, cp)) |byte| { + // For this specific case, we only map to the key if + // we have exactly ctrl pressed. This is because shift + // would modify the key and we don't know how to do that + // properly here (don't have the layout). And we want + // to encode shift as CSIu. + if (unset_mods.int() != ctrl_only) return null; + break :char byte; + } + } + + // Otherwise we don't have a character to convert that + // we can reliably map to a C0 byte. + return null; + }; + // Remove shift if we have something outside of the US letter // range. This is so that characters such as `ctrl+shift+-` // generate the correct ctrl-seq (used by emacs). @@ -596,7 +627,6 @@ fn ctrlSeq( }; // After unsetting, we only continue if we have ONLY control set. - const ctrl_only = comptime (key.Mods{ .ctrl = true }).int(); if (unset_mods.int() != ctrl_only) return null; // From Kitty's key encoding logic. I tried to discern the exact @@ -2132,36 +2162,52 @@ test "legacy: hu layout ctrl+ő sends proper codepoint" { const actual = try enc.legacy(&buf); try testing.expectEqualStrings("[337;5u", actual[1..]); } + test "ctrlseq: normal ctrl c" { - const seq = ctrlSeq("c", 'c', .{ .ctrl = true }); + const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: normal ctrl c, right control" { - const seq = ctrlSeq("c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); + const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: alt should be allowed" { - const seq = ctrlSeq("c", 'c', .{ .alt = true, .ctrl = true }); + const seq = ctrlSeq(.invalid, "c", 'c', .{ .alt = true, .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: no ctrl does nothing" { - try testing.expect(ctrlSeq("c", 'c', .{}) == null); + try testing.expect(ctrlSeq(.invalid, "c", 'c', .{}) == null); } test "ctrlseq: shifted non-character" { - const seq = ctrlSeq("_", '-', .{ .ctrl = true, .shift = true }); + const seq = ctrlSeq(.invalid, "_", '-', .{ .ctrl = true, .shift = true }); try testing.expectEqual(@as(u8, 0x1F), seq.?); } test "ctrlseq: caps ascii letter" { - const seq = ctrlSeq("C", 'c', .{ .ctrl = true, .caps_lock = true }); + const seq = ctrlSeq(.invalid, "C", 'c', .{ .ctrl = true, .caps_lock = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: shift does not generate ctrl seq" { - try testing.expect(ctrlSeq("C", 'c', .{ .shift = true }) == null); - try testing.expect(ctrlSeq("C", 'c', .{ .shift = true, .ctrl = true }) == null); + try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true }) == null); + try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true, .ctrl = true }) == null); +} + +test "ctrlseq: russian ctrl c" { + const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: russian shifted ctrl c" { + const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .shift = true }); + try testing.expect(seq == null); +} + +test "ctrlseq: russian alt ctrl c" { + const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .alt = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); } diff --git a/src/input/key.zig b/src/input/key.zig index a875611d0..766498d54 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -295,7 +295,7 @@ pub const Key = enum(c_int) { eight, nine, - // puncuation + // punctuation semicolon, space, apostrophe, @@ -411,7 +411,7 @@ pub const Key = enum(c_int) { /// may be from the number row or the keypad, but it always maps /// to '.zero'. /// - /// This is what we want, we awnt people to create keybindings that + /// This is what we want, we want people to create keybindings that /// are independent of the physical key. pub fn fromASCII(ch: u8) ?Key { return switch (ch) { diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 7dd61c8a1..54d49b088 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -14,6 +14,7 @@ const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); +const units = @import("units.zig"); /// The window names. These are used with docking so we need to have access. const window_cell = "Cell"; @@ -440,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", kitty_images.total_bytes); + cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); } } @@ -452,7 +453,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", kitty_images.total_limit); + cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); } } @@ -518,7 +519,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", pages.page_size); + cimgui.c.igText("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); } } @@ -530,7 +531,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", pages.maxSize()); + cimgui.c.igText("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); } } @@ -724,7 +725,7 @@ fn renderSizeWindow(self: *Inspector) void { { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText( - "%d pt", + "%.2f pt", self.surface.font_size.points, ); } diff --git a/src/inspector/page.zig b/src/inspector/page.zig index d74f07b1c..bb95d59b9 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -3,6 +3,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); +const inspector = @import("main.zig"); +const units = @import("units.zig"); pub fn render(page: *const terminal.Page) void { cimgui.c.igPushID_Ptr(page); @@ -25,7 +27,7 @@ pub fn render(page: *const terminal.Page) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", page.memory.len); + cimgui.c.igText("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len)); cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size); } } diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 18a692822..1b499f9dd 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -35,7 +35,7 @@ pub const VTEvent = struct { const Kind = enum { print, execute, csi, esc, osc, dcs, apc }; const Metadata = std.StringHashMap([:0]const u8); - /// Initiaze the event information for the given parser action. + /// Initialize the event information for the given parser action. pub fn init( alloc: Allocator, surface: *Surface, @@ -208,6 +208,20 @@ pub const VTEvent = struct { ); }, + .Union => |info| { + const Tag = info.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, v)); + inline for (info.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + if (field.type == void) { + break try md.put("data", tag_name); + } else { + break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name)); + } + } + } + }, + else => { @compileLog(T); @compileError("unsupported type, see log"); diff --git a/src/inspector/units.zig b/src/inspector/units.zig new file mode 100644 index 000000000..28f928232 --- /dev/null +++ b/src/inspector/units.zig @@ -0,0 +1,3 @@ +pub fn toKibiBytes(bytes: usize) usize { + return bytes / 1024; +} diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index b3df80538..9efe8d9b0 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -49,7 +49,8 @@ pub fn main() !MainReturn { error.InvalidAction => try stderr.print( "Error: unknown CLI action specified. CLI actions are specified with\n" ++ - "the '+' character.\n", + "the '+' character.\n\n" ++ + "All valid CLI actions can be listed with `ghostty +help`\n", .{}, ), diff --git a/src/os/desktop.zig b/src/os/desktop.zig index 103127dfa..3a61e2eaa 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -59,3 +59,29 @@ pub fn launchedFromDesktop() bool { else => @compileError("unsupported platform"), }; } + +pub const DesktopEnvironment = enum { + gnome, + macos, + other, + windows, +}; + +/// Detect what desktop environment we are running under. This is mainly used on +/// Linux to enable or disable GTK client-side decorations but there may be more +/// uses in the future. +pub fn desktopEnvironment() DesktopEnvironment { + return switch (comptime builtin.os.tag) { + .macos => .macos, + .windows => .windows, + .linux => de: { + if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime."); + // use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux + // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= + const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other; + if (std.ascii.eqlIgnoreCase("gnome", de)) break :de .gnome; + break :de .other; + }, + else => .other, + }; +} diff --git a/src/os/file.zig b/src/os/file.zig index e0ec2f52c..875dd2c25 100644 --- a/src/os/file.zig +++ b/src/os/file.zig @@ -4,24 +4,27 @@ const posix = std.posix; const log = std.log.scoped(.os); +pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}; + /// This maximizes the number of file descriptors we can have open. We /// need to do this because each window consumes at least a handful of fds. /// This is extracted from the Zig compiler source code. -pub fn fixMaxFiles() void { - if (!@hasDecl(posix.system, "rlimit")) return; +pub fn fixMaxFiles() ?rlimit { + if (!@hasDecl(posix.system, "rlimit")) return null; - var lim = posix.getrlimit(.NOFILE) catch { + const old = posix.getrlimit(.NOFILE) catch { log.warn("failed to query file handle limit, may limit max windows", .{}); - return; // Oh well; we tried. + return null; // Oh well; we tried. }; // If we're already at the max, we're done. - if (lim.cur >= lim.max) { - log.debug("file handle limit already maximized value={}", .{lim.cur}); - return; + if (old.cur >= old.max) { + log.debug("file handle limit already maximized value={}", .{old.cur}); + return old; } // Do a binary search for the limit. + var lim = old; var min: posix.rlim_t = lim.cur; var max: posix.rlim_t = 1 << 20; // But if there's a defined upper bound, don't search, just set it. @@ -41,6 +44,12 @@ pub fn fixMaxFiles() void { } log.debug("file handle limit raised value={}", .{lim.cur}); + return old; +} + +pub fn restoreMaxFiles(lim: rlimit) void { + if (!@hasDecl(posix.system, "rlimit")) return; + posix.setrlimit(.NOFILE, lim) catch {}; } /// Return the recommended path for temporary files. diff --git a/src/os/homedir.zig b/src/os/homedir.zig index cf6931f22..b5629fd65 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -12,7 +12,7 @@ const Error = error{ /// Determine the home directory for the currently executing user. This /// is generally an expensive process so the value should be cached. -pub inline fn home(buf: []u8) !?[]u8 { +pub inline fn home(buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { inline .linux, .macos => try homeUnix(buf), .windows => try homeWindows(buf), @@ -24,7 +24,7 @@ pub inline fn home(buf: []u8) !?[]u8 { }; } -fn homeUnix(buf: []u8) !?[]u8 { +fn homeUnix(buf: []u8) !?[]const u8 { // First: if we have a HOME env var, then we use that. if (posix.getenv("HOME")) |result| { if (buf.len < result.len) return Error.BufferTooSmall; @@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 { return null; } -fn homeWindows(buf: []u8) !?[]u8 { +fn homeWindows(buf: []u8) !?[]const u8 { const drive_len = blk: { var fba_instance = std.heap.FixedBufferAllocator.init(buf); const fba = fba_instance.allocator(); @@ -110,6 +110,68 @@ fn trimSpace(input: []const u8) []const u8 { return std.mem.trim(u8, input, " \n\t"); } +pub const ExpandError = error{ + HomeDetectionFailed, + BufferTooSmall, +}; + +/// Expands a path that starts with a tilde (~) to the home directory of +/// the current user. +/// +/// Errors if `home` fails or if the size of the expanded path is larger +/// than `buf.len`. +pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 { + return switch (builtin.os.tag) { + .linux, .macos => try expandHomeUnix(path, buf), + .ios => return path, + else => @compileError("unimplemented"), + }; +} + +fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 { + if (!std.mem.startsWith(u8, path, "~/")) return path; + const home_dir: []const u8 = if (home(buf)) |home_| + home_ orelse return error.HomeDetectionFailed + else |_| + return error.HomeDetectionFailed; + const rest = path[1..]; // Skip the ~ + const expanded_len = home_dir.len + rest.len; + + if (expanded_len > buf.len) return Error.BufferTooSmall; + @memcpy(buf[home_dir.len..expanded_len], rest); + + return buf[0..expanded_len]; +} + +test "expandHomeUnix" { + const testing = std.testing; + const allocator = testing.allocator; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const home_dir = try expandHomeUnix("~/", &buf); + // Joining the home directory `~` with the path `/` + // the result should end with a separator here. (e.g. `/home/user/`) + try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep); + + const downloads = try expandHomeUnix("~/Downloads/shader.glsl", &buf); + const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" }); + defer allocator.free(expected_downloads); + try testing.expectEqualStrings(expected_downloads, downloads); + + try testing.expectEqualStrings("~", try expandHomeUnix("~", &buf)); + try testing.expectEqualStrings("~abc/", try expandHomeUnix("~abc/", &buf)); + try testing.expectEqualStrings("/home/user", try expandHomeUnix("/home/user", &buf)); + try testing.expectEqualStrings("", try expandHomeUnix("", &buf)); + + // Expect an error if the buffer is large enough to hold the home directory, + // but not the expanded path + var small_buf = try allocator.alloc(u8, home_dir.len); + defer allocator.free(small_buf); + try testing.expectError(error.BufferTooSmall, expandHomeUnix( + "~/Downloads", + small_buf[0..], + )); +} + test { const testing = std.testing; diff --git a/src/os/macos.zig b/src/os/macos.zig index b3d0a917c..a956d25e2 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -25,41 +25,26 @@ pub fn appSupportDir( alloc: Allocator, sub_path: []const u8, ) AppSupportDirError![]const u8 { - comptime assert(builtin.target.isDarwin()); - - const NSFileManager = objc.getClass("NSFileManager").?; - const manager = NSFileManager.msgSend( - objc.Object, - objc.sel("defaultManager"), - .{}, + return try commonDir( + alloc, + .NSApplicationSupportDirectory, + &.{ build_config.bundle_id, sub_path }, ); +} - const url = manager.msgSend( - objc.Object, - objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), - .{ - NSSearchPathDirectory.NSApplicationSupportDirectory, - NSSearchPathDomainMask.NSUserDomainMask, - @as(?*anyopaque, null), - true, - @as(?*anyopaque, null), - }, +pub const CacheDirError = Allocator.Error || error{AppleAPIFailed}; + +/// Return the path to the system cache directory with the given sub path joined. +/// This allocates the result using the given allocator. +pub fn cacheDir( + alloc: Allocator, + sub_path: []const u8, +) CacheDirError![]const u8 { + return try commonDir( + alloc, + .NSCachesDirectory, + &.{ build_config.bundle_id, sub_path }, ); - - // I don't think this is possible but just in case. - if (url.value == null) return error.AppleAPIFailed; - - // Get the UTF-8 string from the URL. - const path = url.getProperty(objc.Object, "path"); - const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse - return error.AppleAPIFailed; - const app_support_dir = std.mem.sliceTo(c_str, 0); - - return try std.fs.path.join(alloc, &.{ - app_support_dir, - build_config.bundle_id, - sub_path, - }); } pub const SetQosClassError = error{ @@ -110,9 +95,79 @@ pub const NSOperatingSystemVersion = extern struct { }; pub const NSSearchPathDirectory = enum(c_ulong) { + NSCachesDirectory = 13, NSApplicationSupportDirectory = 14, }; pub const NSSearchPathDomainMask = enum(c_ulong) { NSUserDomainMask = 1, }; + +fn commonDir( + alloc: Allocator, + directory: NSSearchPathDirectory, + sub_paths: []const []const u8, +) (error{AppleAPIFailed} || Allocator.Error)![]const u8 { + comptime assert(builtin.target.isDarwin()); + + const NSFileManager = objc.getClass("NSFileManager").?; + const manager = NSFileManager.msgSend( + objc.Object, + objc.sel("defaultManager"), + .{}, + ); + + const url = manager.msgSend( + objc.Object, + objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), + .{ + directory, + NSSearchPathDomainMask.NSUserDomainMask, + @as(?*anyopaque, null), + true, + @as(?*anyopaque, null), + }, + ); + + if (url.value == null) return error.AppleAPIFailed; + + const path = url.getProperty(objc.Object, "path"); + const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse + return error.AppleAPIFailed; + const base_dir = std.mem.sliceTo(c_str, 0); + + // Create a new array with base_dir as the first element + var paths = try alloc.alloc([]const u8, sub_paths.len + 1); + paths[0] = base_dir; + @memcpy(paths[1..], sub_paths); + defer alloc.free(paths); + + return try std.fs.path.join(alloc, paths); +} + +test "cacheDir paths" { + if (!builtin.target.isDarwin()) return; + + const testing = std.testing; + const alloc = testing.allocator; + + // Test base path + { + const cache_path = try cacheDir(alloc, ""); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + } + + // Test with subdir + { + const cache_path = try cacheDir(alloc, "test"); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + + const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id}); + defer alloc.free(bundle_path); + try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null); + } +} diff --git a/src/os/main.zig b/src/os/main.zig index 98e57b4fc..e652a7981 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -32,12 +32,16 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const desktopEnvironment = desktop.desktopEnvironment; +pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; +pub const restoreMaxFiles = file.restoreMaxFiles; pub const allocTmpDir = file.allocTmpDir; pub const freeTmpDir = file.freeTmpDir; pub const isFlatpak = flatpak.isFlatpak; pub const FlatpakHostCommand = flatpak.FlatpakHostCommand; pub const home = homedir.home; +pub const expandHome = homedir.expandHome; pub const ensureLocale = locale.ensureLocale; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 6c7655c22..1383679fe 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -58,6 +58,15 @@ fn dir( opts: Options, internal_opts: InternalOptions, ) ![]u8 { + // If we have a cached home dir, use that. + if (opts.home) |home| { + return try std.fs.path.join(alloc, &[_][]const u8{ + home, + internal_opts.default_subdir, + opts.subdir orelse "", + }); + } + // First check the env var. On Windows we have to allocate so this tracks // both whether we have the env var and whether we own it. // on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME` @@ -93,15 +102,6 @@ fn dir( return try alloc.dupe(u8, env); } - // If we have a cached home dir, use that. - if (opts.home) |home| { - return try std.fs.path.join(alloc, &[_][]const u8{ - home, - internal_opts.default_subdir, - opts.subdir orelse "", - }); - } - // Get our home dir var buf: [1024]u8 = undefined; if (try homedir.home(&buf)) |home| { @@ -143,6 +143,32 @@ test { } } +test "cache directory paths" { + const testing = std.testing; + const alloc = testing.allocator; + const mock_home = "/Users/test"; + + // Test when XDG_CACHE_HOME is not set + { + // Test base path + { + const cache_path = try cache(alloc, .{ .home = mock_home }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/.cache", cache_path); + } + + // Test with subdir + { + const cache_path = try cache(alloc, .{ + .home = mock_home, + .subdir = "ghostty", + }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/.cache/ghostty", cache_path); + } + } +} + test parseTerminalExec { const testing = std.testing; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index b37f440f4..75e61ebc0 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -360,6 +360,7 @@ pub const DerivedConfig = struct { arena: ArenaAllocator, font_thicken: bool, + font_thicken_strength: u8, font_features: std.ArrayListUnmanaged([:0]const u8), font_styles: font.CodepointResolver.StyleStatus, cursor_color: ?terminal.color.RGB, @@ -410,6 +411,7 @@ pub const DerivedConfig = struct { return .{ .background_opacity = @max(0, @min(1, config.@"background-opacity")), .font_thicken = config.@"font-thicken", + .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, .font_styles = font_styles, @@ -2837,6 +2839,7 @@ fn addGlyph( .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, }, ); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 72e0457e9..157354d1d 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -146,7 +146,7 @@ image_bg_end: u32 = 0, image_text_end: u32 = 0, image_virtual: bool = false, -/// Defererred OpenGL operation to update the screen size. +/// Deferred OpenGL operation to update the screen size. const SetScreenSize = struct { size: renderer.Size, @@ -272,6 +272,7 @@ pub const DerivedConfig = struct { arena: ArenaAllocator, font_thicken: bool, + font_thicken_strength: u8, font_features: std.ArrayListUnmanaged([:0]const u8), font_styles: font.CodepointResolver.StyleStatus, cursor_color: ?terminal.color.RGB, @@ -321,6 +322,7 @@ pub const DerivedConfig = struct { return .{ .background_opacity = @max(0, @min(1, config.@"background-opacity")), .font_thicken = config.@"font-thicken", + .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, .font_styles = font_styles, @@ -764,7 +766,7 @@ pub fn updateFrame( // We used to share terminal state, but we've since learned through // analysis that it is faster to copy the terminal state than to - // hold the lock wile rebuilding GPU cells. + // hold the lock while rebuilding GPU cells. var screen_copy = try state.terminal.screen.clone( self.alloc, .{ .viewport = .{} }, @@ -2093,6 +2095,7 @@ fn addGlyph( .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, }, ); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5fb49ea66..260733b94 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3281,7 +3281,7 @@ fn markDirty(self: *PageList, pt: point.Point) void { /// point remains valid even through scrolling without any additional work. /// /// A downside is that the pin is only valid until the pagelist is modified -/// in a way that may invalid page pointers or shuffle rows, such as resizing, +/// in a way that may invalidate page pointers or shuffle rows, such as resizing, /// erasing rows, etc. /// /// A pin can also be "tracked" which means that it will be updated as the @@ -3389,9 +3389,9 @@ pub const Pin = struct { else => {}, } - // Never extend cell that has a default background. - // A default background is if there is no background - // on the style OR the explicitly set background + // Never extend a cell that has a default background. + // A default background is applied if there is no background + // on the style or the explicitly set background // matches our default background. const s = self.style(cell); const bg = s.bg(cell, palette) orelse return true; @@ -3486,7 +3486,7 @@ pub const Pin = struct { // If our y is after the top y but we're on the same page // then we're between the top and bottom if our y is less - // than or equal to the bottom y IF its the same page. If the + // than or equal to the bottom y if its the same page. If the // bottom is another page then it means that the range is // at least the full top page and since we're the same page // we're in the range. @@ -3508,7 +3508,7 @@ pub const Pin = struct { if (self.y > bottom.y) return false; if (self.y < bottom.y) return true; - // If our y is the same then we're between if we're before + // If our y is the same, then we're between if we're before // or equal to the bottom x. assert(self.y == bottom.y); return self.x <= bottom.x; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index cc87d6c9d..25c819b10 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -382,6 +382,7 @@ fn encodeError(r: *Response, err: EncodeableError) void { error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", + error.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly", error.UnsupportedFormat => r.message = "EINVAL: unsupported format", error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index ff498cbb8..094e1622b 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -220,6 +220,9 @@ pub const LoadingImage = struct { // Temporary file logic if (medium == .temporary_file) { if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; + if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) { + return error.TemporaryFileNotNamedCorrectly; + } } defer if (medium == .temporary_file) { posix.unlink(path) catch |err| { @@ -469,6 +472,7 @@ pub const Image = struct { DimensionsTooLarge, FilePathTooLong, TemporaryFileNotInTempDir, + TemporaryFileNotNamedCorrectly, UnsupportedFormat, UnsupportedMedium, UnsupportedDepth, @@ -682,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" try testing.expect(img.compression == .none); } -test "image load: rgb, not compressed, temporary file" { +test "image load: temporary file without correct path" { const testing = std.testing; const alloc = testing.allocator; @@ -697,6 +701,39 @@ test "image load: rgb, not compressed, temporary file" { var buf: [std.fs.max_path_bytes]u8 = undefined; const path = try tmp_dir.dir.realpath("image.data", &buf); + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + + // Temporary file should still be there + try tmp_dir.dir.access(path, .{}); +} + +test "image load: rgb, not compressed, temporary file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + var cmd: command.Command = .{ .control = .{ .transmit = .{ .format = .rgb, @@ -762,12 +799,12 @@ test "image load: png, not compressed, regular file" { defer tmp_dir.deinit(); const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); try tmp_dir.dir.writeFile(.{ - .sub_path = "image.data", + .sub_path = "tty-graphics-protocol-image.data", .data = data, }); var buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("image.data", &buf); + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); var cmd: command.Command = .{ .control = .{ .transmit = .{ diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 1a336a604..e9ab5e1e3 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -163,6 +163,15 @@ pub const Command = union(enum) { duration_ms: u16, }, + /// Show GUI message Box (OSC 9;2) + show_message_box: []const u8, + + /// Change ConEmu tab (OSC 9;3) + change_conemu_tab_title: union(enum) { + reset: void, + value: []const u8, + }, + /// Set progress state (OSC 9;4) progress: struct { state: ProgressState, @@ -360,6 +369,9 @@ pub const Parser = struct { // 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, @@ -787,6 +799,12 @@ pub const Parser = struct { '1' => { self.state = .conemu_sleep; }, + '2' => { + self.state = .conemu_message_box; + }, + '3' => { + self.state = .conemu_tab; + }, '4' => { self.state = .conemu_progress_prestate; }, @@ -804,6 +822,17 @@ pub const Parser = struct { self.buf_start = self.buf_idx; self.complete = true; self.state = .conemu_sleep_value; + }, + else => self.state = .invalid, + }, + + .conemu_message_box => switch (c) { + ';' => { + self.command = .{ .show_message_box = undefined }; + self.temp_state = .{ .str = &self.command.show_message_box }; + self.buf_start = self.buf_idx; + self.complete = true; + self.prepAllocableString(); }, else => self.state = .invalid, }, @@ -812,6 +841,23 @@ pub const Parser = struct { else => self.complete = true, }, + .conemu_tab => switch (c) { + ';' => { + self.state = .conemu_tab_txt; + self.command = .{ .change_conemu_tab_title = .{ .reset = {} } }; + self.buf_start = self.buf_idx; + self.complete = true; + }, + else => self.state = .invalid, + }, + + .conemu_tab_txt => { + self.command = .{ .change_conemu_tab_title = .{ .value = undefined } }; + self.temp_state = .{ .str = &self.command.change_conemu_tab_title.value }; + self.complete = true; + self.prepAllocableString(); + }, + .conemu_progress_prestate => switch (c) { ';' => { self.command = .{ .progress = .{ @@ -1759,6 +1805,110 @@ test "OSC: show desktop notification with title" { try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); } +test "OSC: conemu message box" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2;hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_message_box); + try testing.expectEqualStrings("hello world", cmd.show_message_box); +} + +test "OSC: conemu message box invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + +test "OSC: conemu message box empty message" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_message_box); + try testing.expectEqualStrings("", cmd.show_message_box); +} + +test "OSC: conemu message box spaces only message" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_message_box); + try testing.expectEqualStrings(" ", cmd.show_message_box); +} + +test "OSC: conemu change tab title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3;foo bar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .change_conemu_tab_title); + try testing.expectEqualStrings("foo bar", cmd.change_conemu_tab_title.value); +} + +test "OSC: conemu change tab reset title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + const expected_command: Command = .{ .change_conemu_tab_title = .{ .reset = {} } }; + try testing.expectEqual(expected_command, cmd); +} + +test "OSC: conemu change tab spaces only title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .change_conemu_tab_title); + try testing.expectEqualStrings(" ", cmd.change_conemu_tab_title.value); +} + +test "OSC: conemu change tab invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + test "OSC: OSC9 progress set" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 8772050a9..f75d86c0a 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -380,109 +380,172 @@ pub fn Stream(comptime Handler: type) type { fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { switch (input.final) { // CUU - Cursor Up - 'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {}", .{input}); - return; + 'A', 'k' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {}", .{input}); + return; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + false, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI A with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUD - Cursor Down - 'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {}", .{input}); - return; + 'B' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {}", .{input}); + return; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + false, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI B with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUF - Cursor Right - 'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor right command: {}", .{input}); - return; + 'C' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor right command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI C with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUB - Cursor Left - 'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor left command: {}", .{input}); - return; + 'D', 'j' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor left command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI D with intermediates: {s}", + .{input.intermediates}, + ), + }, // CNL - Cursor Next Line - 'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {}", .{input}); - return; + 'E' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + true, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI E with intermediates: {s}", + .{input.intermediates}, + ), + }, // CPL - Cursor Previous Line - 'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {}", .{input}); - return; + 'F' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + true, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI F with intermediates: {s}", + .{input.intermediates}, + ), + }, // HPA - Cursor Horizontal Position Absolute // TODO: test - 'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { - 0 => try self.handler.setCursorCol(1), - 1 => try self.handler.setCursorCol(input.params[0]), - else => log.warn("invalid HPA command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'G', '`' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { + 0 => try self.handler.setCursorCol(1), + 1 => try self.handler.setCursorCol(input.params[0]), + else => log.warn("invalid HPA command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI G with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUP - Set Cursor Position. // TODO: test - 'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { - 0 => try self.handler.setCursorPos(1, 1), - 1 => try self.handler.setCursorPos(input.params[0], 1), - 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), - else => log.warn("invalid CUP command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'H', 'f' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { + 0 => try self.handler.setCursorPos(1, 1), + 1 => try self.handler.setCursorPos(input.params[0], 1), + 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), + else => log.warn("invalid CUP command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI H with intermediates: {s}", + .{input.intermediates}, + ), + }, // CHT - Cursor Horizontal Tabulation - 'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab command: {}", .{input}); - return; + 'I' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI I with intermediates: {s}", + .{input.intermediates}, + ), + }, // Erase Display 'J' => if (@hasDecl(T, "eraseDisplay")) { @@ -540,31 +603,52 @@ pub fn Stream(comptime Handler: type) type { // IL - Insert Lines // TODO: test - 'L' => if (@hasDecl(T, "insertLines")) switch (input.params.len) { - 0 => try self.handler.insertLines(1), - 1 => try self.handler.insertLines(input.params[0]), - else => log.warn("invalid IL command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'L' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) { + 0 => try self.handler.insertLines(1), + 1 => try self.handler.insertLines(input.params[0]), + else => log.warn("invalid IL command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI L with intermediates: {s}", + .{input.intermediates}, + ), + }, // DL - Delete Lines // TODO: test - 'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { - 0 => try self.handler.deleteLines(1), - 1 => try self.handler.deleteLines(input.params[0]), - else => log.warn("invalid DL command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'M' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { + 0 => try self.handler.deleteLines(1), + 1 => try self.handler.deleteLines(input.params[0]), + else => log.warn("invalid DL command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI M with intermediates: {s}", + .{input.intermediates}, + ), + }, // Delete Character (DCH) - 'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid delete characters command: {}", .{input}); - return; + 'P' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid delete characters command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI P with intermediates: {s}", + .{input.intermediates}, + ), + }, // Scroll Up (SD) @@ -587,38 +671,43 @@ pub fn Stream(comptime Handler: type) type { }, // Scroll Down (SD) - 'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll down command: {}", .{input}); - return; + 'T' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll down command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI T with intermediates: {s}", + .{input.intermediates}, + ), + }, // Cursor Tabulation Control - 'W' => { - switch (input.params.len) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{input}), + 'W' => switch (input.intermediates.len) { + 0 => { + if (input.params.len == 0 or + (input.params.len == 1 and input.params[0] == 0)) + { + if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{input}); - 1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') { - if (input.params[0] == 5) { - if (@hasDecl(T, "tabReset")) - try self.handler.tabReset() - else - log.warn("unimplemented tab reset callback: {}", .{input}); - } else log.warn("invalid cursor tabulation control: {}", .{input}); - } else { - switch (input.params[0]) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{input}), + return; + } + + switch (input.params.len) { + 0 => unreachable, + + 1 => switch (input.params[0]) { + 0 => unreachable, 2 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(.current) @@ -631,63 +720,103 @@ pub fn Stream(comptime Handler: type) type { log.warn("unimplemented tab clear callback: {}", .{input}), else => {}, - } - }, + }, - else => {}, - } + else => {}, + } - log.warn("invalid cursor tabulation control: {}", .{input}); - return; + log.warn("invalid cursor tabulation control: {}", .{input}); + return; + }, + + 1 => if (input.intermediates[0] == '?' and input.params[0] == 5) { + if (@hasDecl(T, "tabReset")) + try self.handler.tabReset() + else + log.warn("unimplemented tab reset callback: {}", .{input}); + } else log.warn("invalid cursor tabulation control: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI W with intermediates: {s}", + .{input.intermediates}, + ), }, // Erase Characters (ECH) - 'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid erase characters command: {}", .{input}); - return; + 'X' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid erase characters command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI X with intermediates: {s}", + .{input.intermediates}, + ), + }, // CHT - Cursor Horizontal Tabulation Back - 'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab back command: {}", .{input}); - return; + 'Z' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab back command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI Z with intermediates: {s}", + .{input.intermediates}, + ), + }, // HPR - Cursor Horizontal Position Relative - 'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid HPR command: {}", .{input}); - return; + 'a' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid HPR command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI a with intermediates: {s}", + .{input.intermediates}, + ), + }, // Repeat Previous Char (REP) - 'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid print repeat command: {}", .{input}); - return; + 'b' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid print repeat command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI b with intermediates: {s}", + .{input.intermediates}, + ), + }, // c - Device Attributes (DA1) 'c' => if (@hasDecl(T, "deviceAttributes")) { @@ -708,40 +837,61 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented CSI callback: {}", .{input}), // VPA - Cursor Vertical Position Absolute - 'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid VPA command: {}", .{input}); - return; + 'd' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid VPA command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI d with intermediates: {s}", + .{input.intermediates}, + ), + }, // VPR - Cursor Vertical Position Relative - 'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid VPR command: {}", .{input}); - return; + 'e' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid VPR command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI e with intermediates: {s}", + .{input.intermediates}, + ), + }, // TBC - Tab Clear // TODO: test - 'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( - switch (input.params.len) { - 1 => @enumFromInt(input.params[0]), - else => { - log.warn("invalid tab clear command: {}", .{input}); - return; + 'g' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( + switch (input.params.len) { + 1 => @enumFromInt(input.params[0]), + else => { + log.warn("invalid tab clear command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI g with intermediates: {s}", + .{input.intermediates}, + ), + }, // SM - Set Mode 'h' => if (@hasDecl(T, "setMode")) mode: { @@ -1455,7 +1605,7 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress, .sleep => { + .progress, .sleep, .show_message_box, .change_conemu_tab_title => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } @@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented ESC callback: {}", .{action}), // HTS - Horizontal Tab Set - 'H' => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{action}), + 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) { + 0 => try self.handler.tabSet(), + else => { + log.warn("invalid tab set command: {}", .{action}); + return; + }, + } else log.warn("unimplemented tab set callback: {}", .{action}), // RI - Reverse Index 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { @@ -1597,17 +1750,17 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {}", .{action}), // SPA - Start of Guarded Area - 'V' => if (@hasDecl(T, "setProtectedMode")) { + 'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { try self.handler.setProtectedMode(ansi.ProtectedMode.iso); } else log.warn("unimplemented ESC callback: {}", .{action}), // EPA - End of Guarded Area - 'W' => if (@hasDecl(T, "setProtectedMode")) { + 'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { try self.handler.setProtectedMode(ansi.ProtectedMode.off); } else log.warn("unimplemented ESC callback: {}", .{action}), // DECID - 'Z' => if (@hasDecl(T, "deviceAttributes")) { + 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) { try self.handler.deviceAttributes(.primary, &.{}); } else log.warn("unimplemented ESC callback: {}", .{action}), @@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {}", .{action}), // Set application keypad mode - '=' => if (@hasDecl(T, "setMode")) { + '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { try self.handler.setMode(.keypad_keys, true); } else log.warn("unimplemented setMode: {}", .{action}), // Reset application keypad mode - '>' => if (@hasDecl(T, "setMode")) { + '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { try self.handler.setMode(.keypad_keys, false); } else log.warn("unimplemented setMode: {}", .{action}), @@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" { s.handler.amount = 0; try s.nextSlice("\x1B[5;4C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); + + s.handler.amount = 0; + try s.nextSlice("\x1b[?3C"); + try testing.expectEqual(@as(u16, 0), s.handler.amount); } test "stream: dec set mode (SM) and reset mode (RM)" { @@ -1770,6 +1927,10 @@ test "stream: dec set mode (SM) and reset mode (RM)" { try s.nextSlice("\x1B[?6l"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); + + s.handler.mode = @as(modes.Mode, @enumFromInt(1)); + try s.nextSlice("\x1B[6 h"); + try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); } test "stream: ansi set mode (SM) and reset mode (RM)" { @@ -1788,6 +1949,10 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { try s.nextSlice("\x1B[4l"); try testing.expect(s.handler.mode == null); + + s.handler.mode = null; + try s.nextSlice("\x1B[>5h"); + try testing.expect(s.handler.mode == null); } test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { @@ -1937,6 +2102,12 @@ test "stream: DECED, DECSED" { try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } + { + // Invalid and ignored by the handler + for ("\x1B[>0J") |c| try s.next(c); + try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); + try testing.expect(!s.handler.protected.?); + } } test "stream: DECEL, DECSEL" { @@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" { try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } + { + // Invalid and ignored by the handler + for ("\x1B[<1K") |c| try s.next(c); + try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); + try testing.expect(!s.handler.protected.?); + } } test "stream: DECSCUSR" { @@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" { try s.nextSlice("\x1B[1 q"); try testing.expect(s.handler.style.? == .blinking_block); + + // Invalid and ignored by the handler + try s.nextSlice("\x1B[?0 q"); + try testing.expect(s.handler.style.? == .blinking_block); } test "stream: DECSCUSR without space" { @@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" { try s.nextSlice("\x1B[>1s"); try testing.expect(s.handler.escape.? == true); + + // Invalid and ignored by the handler + try s.nextSlice("\x1B[1 s"); + try testing.expect(s.handler.escape.? == true); } test "stream: change window title with invalid utf-8" { @@ -2374,6 +2559,14 @@ test "stream CSI W tab set" { s.handler.called = false; try s.nextSlice("\x1b[0W"); try testing.expect(s.handler.called); + + s.handler.called = false; + try s.nextSlice("\x1b[>W"); + try testing.expect(!s.handler.called); + + s.handler.called = false; + try s.nextSlice("\x1b[99W"); + try testing.expect(!s.handler.called); } test "stream CSI ? W reset tab stops" { @@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" { try s.nextSlice("\x1b[?5W"); try testing.expect(s.handler.reset); + + // Invalid and ignored by the handler + try s.nextSlice("\x1b[?1;2;3W"); + try testing.expect(s.handler.reset); } diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index bbcee7906..ab61ae4ca 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -466,6 +466,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // for alt screen, we do nothing. if (self.terminal.active_screen == .alternate) return; + // Clear our selection + self.terminal.screen.clearSelection(); + // Clear our scrollback if (history) self.terminal.eraseDisplay(.scrollback, false); diff --git a/typos.toml b/typos.toml index a72944e5f..87b41336b 100644 --- a/typos.toml +++ b/typos.toml @@ -42,6 +42,7 @@ wdth = "wdth" Strat = "Strat" grey = "gray" greyscale = "grayscale" +DECID = "DECID" [type.swift.extend-words] inout = "inout"