diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d5ee328e50..3339ee71c0 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,6 +1,31 @@ on: [push, pull_request] name: Nix jobs: + required: + name: "Required Checks: Nix" + runs-on: namespace-profile-ghostty-sm + needs: + - check-zig-cache-hash + steps: + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + result="failed" + else + result="success" + fi + { + echo "result=${result}" + echo "results=${results}" + } | tee -a "$GITHUB_OUTPUT" + - if: always() && steps.status.outputs.result != 'success' + name: Check for failed status + run: | + echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}" + exit 1 + check-zig-cache-hash: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-sm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81d58a1efe..0f32162a95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,45 @@ on: name: Test jobs: + required: + name: "Required Checks: Test" + runs-on: namespace-profile-ghostty-sm + needs: + - build + - build-bench + - build-linux-libghostty + - build-nix + - build-macos + - build-macos-matrix + - build-windows + - test + - test-gtk + - test-sentry-linux + - test-macos + - prettier + - alejandra + - typos + - test-pkg-linux + steps: + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + result="failed" + else + result="success" + fi + { + echo "result=${result}" + echo "results=${results}" + } | tee -a "$GITHUB_OUTPUT" + - if: always() && steps.status.outputs.result != 'success' + name: Check for failed status + run: | + echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}" + exit 1 + build: strategy: fail-fast: false @@ -247,10 +286,10 @@ jobs: run: | # Get the zig version from build.zig so that it only needs to be updated $fileContent = Get-Content -Path "build.zig" -Raw - $pattern = 'const required_zig = "(.*?)";' + $pattern = 'buildpkg\.requireZig\("(.*?)"\);' $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value - Write-Output $version $version = "zig-windows-x86_64-$zigVersion" + Write-Output $version $uri = "https://ziglang.org/download/$zigVersion/$version.zip" Invoke-WebRequest -Uri "$uri" -OutFile ".\zig-windows.zip" Expand-Archive -Path ".\zig-windows.zip" -DestinationPath ".\" -Force @@ -342,7 +381,8 @@ jobs: matrix: adwaita: ["true", "false"] x11: ["true", "false"] - name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} + wayland: ["true", "false"] + name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }} runs-on: namespace-profile-ghostty-sm needs: test env: @@ -374,7 +414,8 @@ jobs: zig build \ -Dapp-runtime=gtk \ -Dgtk-adwaita=${{ matrix.adwaita }} \ - -Dgtk-x11=${{ matrix.x11 }} + -Dgtk-x11=${{ matrix.x11 }} \ + -Dgtk-wayland=${{ matrix.wayland }} test-sentry-linux: strategy: diff --git a/.gitignore b/.gitignore index 0e301f8c41..db8457e1f8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test/cases/**/*.actual.png glad.zip /Box_test.ppm /Box_test_diff.ppm +/ghostty.qcow2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af3c30be7d..a7233b2c22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,3 +77,100 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` can be any of the VMs defined in the + `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed + with `common` or `create`. +3. The VM will build and then launch. Depending on the speed of your system, this + can take a while, but eventually you should get a new VM window. +4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending + on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be + writable by the VM user, so be careful! + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a VM. + +### Custom VMs + +To easily create a custom VM without modifying the Ghostty source, create a new +directory, then create a file called `flake.nix` with the following text in the +new directory. + +``` +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + ghostty.url = "github:ghostty-org/ghostty"; + }; + outputs = { + nixpkgs, + ghostty, + ... + }: { + nixosConfigurations.custom-vm = ghostty.create-gnome-vm { + nixpkgs = nixpkgs; + system = "x86_64-linux"; + overlay = ghostty.overlays.releasefast; + # module = ./configuration.nix # also works + module = {pkgs, ...}: { + environment.systemPackages = [ + pkgs.btop + ]; + }; + }; + }; +} +``` + +The custom VM can then be run with a command like this: + +``` +nix run .#nixosConfigurations.custom-vm.config.system.build.vm +``` + +A file named `ghostty.qcow2` will be created that is used to persist any changes +made in the VM. To "reset" the VM to default delete the file and it will be +recreated the next time you run the VM. + +### Contributing new VM definitions + +#### VM Acceptance Criteria + +We welcome the contribution of new VM definitions, as long as they meet the following criteria: + +1. The should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH daemons, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. diff --git a/build.zig b/build.zig index d92d3e719f..38d2bca6dd 100644 --- a/build.zig +++ b/build.zig @@ -1,925 +1,85 @@ const std = @import("std"); const builtin = @import("builtin"); -const fs = std.fs; -const CompileStep = std.Build.Step.Compile; -const RunStep = std.Build.Step.Run; -const ResolvedTarget = std.Build.ResolvedTarget; - -const apprt = @import("src/apprt.zig"); -const font = @import("src/font/main.zig"); -const renderer = @import("src/renderer.zig"); -const terminfo = @import("src/terminfo/main.zig"); -const config_vim = @import("src/config/vim.zig"); -const config_sublime_syntax = @import("src/config/sublime_syntax.zig"); -const fish_completions = @import("src/build/fish_completions.zig"); -const zsh_completions = @import("src/build/zsh_completions.zig"); -const bash_completions = @import("src/build/bash_completions.zig"); -const build_config = @import("src/build_config.zig"); -const BuildConfig = build_config.BuildConfig; -const WasmTarget = @import("src/os/wasm/target.zig").Target; -const LibtoolStep = @import("src/build/LibtoolStep.zig"); -const LipoStep = @import("src/build/LipoStep.zig"); -const MetallibStep = @import("src/build/MetallibStep.zig"); -const XCFrameworkStep = @import("src/build/XCFrameworkStep.zig"); -const Version = @import("src/build/Version.zig"); -const Command = @import("src/Command.zig"); +const buildpkg = @import("src/build/main.zig"); comptime { - // This is the required Zig version for building this project. We allow - // any patch version but the major and minor must match exactly. - const required_zig = "0.13.0"; - - // Fail compilation if the current Zig version doesn't meet requirements. - const current_vsn = builtin.zig_version; - const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable; - if (current_vsn.major != required_vsn.major or - current_vsn.minor != required_vsn.minor) - { - @compileError(std.fmt.comptimePrint( - "Your Zig version v{} does not meet the required build version of v{}", - .{ current_vsn, required_vsn }, - )); - } + buildpkg.requireZig("0.13.0"); } -/// The version of the next release. -const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 2 }; - pub fn build(b: *std.Build) !void { - const optimize = b.standardOptimizeOption(.{}); - const target = target: { - var result = b.standardTargetOptions(.{}); - - // If we have no minimum OS version, we set the default based on - // our tag. Not all tags have a minimum so this may be null. - if (result.query.os_version_min == null) { - result.query.os_version_min = osVersionMin(result.result.os.tag); - } - - break :target result; - }; - - // This is set to true when we're building a system package. For now - // this is trivially detected using the "system_package_mode" bool - // but we may want to make this more sophisticated in the future. - const system_package: bool = b.graph.system_package_mode; - - const wasm_target: WasmTarget = .browser; - - // We use env vars throughout the build so we grab them immediately here. - var env = try std.process.getEnvMap(b.allocator); - defer env.deinit(); - - // Our build configuration. This is all on a struct so that we can easily - // modify it for specific build types (for example, wasm we strictly - // control our backends). - var config: BuildConfig = .{}; - - config.flatpak = b.option( - bool, - "flatpak", - "Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.", - ) orelse false; - - config.font_backend = b.option( - font.Backend, - "font-backend", - "The font backend to use for discovery and rasterization.", - ) orelse font.Backend.default(target.result, wasm_target); - - config.app_runtime = b.option( - apprt.Runtime, - "app-runtime", - "The app runtime to use. Not all values supported on all platforms.", - ) orelse apprt.Runtime.default(target.result); - - config.renderer = b.option( - renderer.Impl, - "renderer", - "The app runtime to use. Not all values supported on all platforms.", - ) orelse renderer.Impl.default(target.result, wasm_target); - - config.adwaita = b.option( - bool, - "gtk-adwaita", - "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 pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator); - - pkgconfig.stdout_behavior = .Pipe; - pkgconfig.stderr_behavior = .Pipe; - - try pkgconfig.spawn(); - - const output_max_size = 50 * 1024; - - var stdout = std.ArrayList(u8).init(b.allocator); - var stderr = std.ArrayList(u8).init(b.allocator); - defer { - stdout.deinit(); - stderr.deinit(); - } - - try pkgconfig.collectOutput(&stdout, &stderr, output_max_size); - - const term = try pkgconfig.wait(); - - if (stderr.items.len > 0) { - std.log.warn("pkg-config had errors:\n{s}", .{stderr.items}); - } - - switch (term) { - .Exited => |code| { - if (code == 0) { - if (std.mem.indexOf(u8, stdout.items, "x11")) |_| break :x11 true; - break :x11 false; - } - std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - break :x11 false; - }, - inline else => |code| { - std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - return error.Unexpected; - }, - } - }; - - config.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( - bool, - "pie", - "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", - "Name of the conformance app to run with 'run' option.", - ); - - const emit_test_exe = b.option( - bool, - "emit-test-exe", - "Build and install test executables with 'build'", - ) orelse false; - - const emit_bench = b.option( - bool, - "emit-bench", - "Build and install the benchmark executables.", - ) orelse false; - - const emit_helpgen = b.option( - bool, - "emit-helpgen", - "Build and install the helpgen executable.", - ) orelse false; - - const emit_docs = b.option( - bool, - "emit-docs", - "Build and install auto-generated documentation (requires pandoc)", - ) orelse emit_docs: { - // If we are emitting any other artifacts then we default to false. - if (emit_bench or emit_test_exe or emit_helpgen) break :emit_docs false; - - // We always emit docs in system package mode. - if (system_package) break :emit_docs true; - - // We only default to true if we can find pandoc. - const path = Command.expandPath(b.allocator, "pandoc") catch - break :emit_docs false; - defer if (path) |p| b.allocator.free(p); - break :emit_docs path != null; - }; - - const emit_webdata = b.option( - bool, - "emit-webdata", - "Build the website data for the website.", - ) orelse false; - - const emit_xcframework = b.option( - bool, - "emit-xcframework", - "Build and install the xcframework for the macOS library.", - ) orelse builtin.target.isDarwin() and - target.result.os.tag == .macos and - config.app_runtime == .none and - (!emit_bench and !emit_test_exe and !emit_helpgen); - - // On NixOS, the built binary from `zig build` needs to patch the rpath - // into the built binary for it to be portable across the NixOS system - // it was built for. We default this to true if we can detect we're in - // a Nix shell and have LD_LIBRARY_PATH set. - const patch_rpath: ?[]const u8 = b.option( - []const u8, - "patch-rpath", - "Inject the LD_LIBRARY_PATH as the rpath in the built binary. " ++ - "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment on NixOS.", - ) orelse patch_rpath: { - // We only do the patching if we're targeting our own CPU and its Linux. - if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath null; - - // If we're in a nix shell we default to doing this. - // Note: we purposely never deinit envmap because we leak the strings - if (env.get("IN_NIX_SHELL") == null) break :patch_rpath null; - break :patch_rpath env.get("LD_LIBRARY_PATH"); - }; - - const version_string = b.option( - []const u8, - "version-string", - "A specific version string to use for the build. " ++ - "If not specified, git will be used. This must be a semantic version.", - ); + const config = try buildpkg.Config.init(b); - config.version = if (version_string) |v| - try std.SemanticVersion.parse(v) - else version: { - const vsn = Version.detect(b) catch |err| switch (err) { - // If Git isn't available we just make an unknown dev version. - error.GitNotFound, - error.GitNotRepository, - => break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - .pre = "dev", - .build = "0000000", - }, + // Ghostty resources like terminfo, shell integration, themes, etc. + const resources = try buildpkg.GhosttyResources.init(b, &config); - else => return err, - }; - if (vsn.tag) |tag| { - // Tip releases behave just like any other pre-release so we skip. - if (!std.mem.eql(u8, tag, "tip")) { - const expected = b.fmt("v{d}.{d}.{d}", .{ - app_version.major, - app_version.minor, - app_version.patch, - }); + // Ghostty dependencies used by many artifacts. + const deps = try buildpkg.SharedDeps.init(b, &config); + const exe = try buildpkg.GhosttyExe.init(b, &config, &deps); + if (config.emit_helpgen) deps.help_strings.install(); - if (!std.mem.eql(u8, tag, expected)) { - @panic("tagged releases must be in vX.Y.Z format matching build.zig"); - } - - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - }; - } - } - - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - .pre = vsn.branch, - .build = vsn.short_hash, - }; - }; - - // These are all our dependencies that can be used with system - // packages if they exist. We set them up here so that we can set - // their defaults early. The first call configures the integration and - // subsequent calls just return the configured value. - { - // These dependencies we want to default false if we're on macOS. - // On macOS we don't want to use system libraries because we - // generally want a fat binary. This can be overridden with the - // `-fsys` flag. - for (&[_][]const u8{ - "freetype", - "harfbuzz", - "fontconfig", - "libpng", - "zlib", - "oniguruma", - }) |dep| { - _ = b.systemIntegrationOption( - dep, - .{ - // If we're not on darwin we want to use whatever the - // default is via the system package mode - .default = if (target.result.isDarwin()) false else null, - }, - ); - } - - // These default to false because they're rarely available as - // system packages so we usually want to statically link them. - for (&[_][]const u8{ - "glslang", - "spirv-cross", - "simdutf", - }) |dep| { - _ = b.systemIntegrationOption(dep, .{ .default = false }); - } + // Ghostty docs + if (config.emit_docs) { + const docs = try buildpkg.GhosttyDocs.init(b, &deps); + docs.install(); } - // We can use wasmtime to test wasm - b.enable_wasmtime = true; - - // Help exe. This must be run before any dependent executables because - // otherwise the build will be cached without emit. That's clunky but meh. - if (emit_helpgen) try addHelp(b, null, config); - - // Add our benchmarks - try benchSteps(b, target, config, emit_bench); - - // We only build an exe if we have a runtime set. - const exe_: ?*std.Build.Step.Compile = if (config.app_runtime != .none) b.addExecutable(.{ - .name = "ghostty", - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, - .strip = strip, - }) else null; - - // Exe - if (exe_) |exe| { - // Set PIE if requested - if (pie) exe.pie = true; - - // Add the shared dependencies - _ = try addDeps(b, exe, config); - - // If we're in NixOS but not in the shell environment then we issue - // a warning because the rpath may not be setup properly. - const is_nixos = is_nixos: { - if (target.result.os.tag != .linux) break :is_nixos false; - if (!target.query.isNativeCpu()) break :is_nixos false; - if (!target.query.isNativeOs()) break :is_nixos false; - break :is_nixos if (std.fs.accessAbsolute("/etc/NIXOS", .{})) true else |_| false; - }; - if (is_nixos and env.get("IN_NIX_SHELL") == null) { - try exe.step.addError( - "\x1b[" ++ color_map.get("yellow").? ++ - "\x1b[" ++ color_map.get("d").? ++ - \\Detected building on and for NixOS outside of the Nix shell environment. - \\ - \\The resulting ghostty binary will likely fail on launch because it is - \\unable to dynamically load the windowing libs (X11, Wayland, etc.). - \\We highly recommend running only within the Nix build environment - \\and the resulting binary will be portable across your system. - \\ - \\To run in the Nix build environment, use the following command. - \\Append any additional options like (`-Doptimize` flags). The resulting - \\binary will be in zig-out as usual. - \\ - \\ nix develop -c zig build - \\ - ++ - "\x1b[0m", - .{}, - ); - } - - if (target.result.os.tag == .windows) { - exe.subsystem = .Windows; - exe.addWin32ResourceFile(.{ - .file = b.path("dist/windows/ghostty.rc"), - }); - } - - // If we're installing, we get the install step so we can add - // additional dependencies to it. - const install_step = if (config.app_runtime != .none) step: { - const step = b.addInstallArtifact(exe, .{}); - b.getInstallStep().dependOn(&step.step); - break :step step; - } else null; - - // Patch our rpath if that option is specified. - if (patch_rpath) |rpath| { - if (rpath.len > 0) { - const run = RunStep.create(b, "patchelf rpath"); - run.addArgs(&.{ "patchelf", "--set-rpath", rpath }); - run.addArtifactArg(exe); - - if (install_step) |step| { - step.step.dependOn(&run.step); - } - } - } - - // App (Mac) - if (target.result.os.tag == .macos) { - const bin_install = b.addInstallFile( - exe.getEmittedBin(), - "Ghostty.app/Contents/MacOS/ghostty", - ); - b.getInstallStep().dependOn(&bin_install.step); - b.installFile("dist/macos/Info.plist", "Ghostty.app/Contents/Info.plist"); - b.installFile("dist/macos/Ghostty.icns", "Ghostty.app/Contents/Resources/Ghostty.icns"); - } + // Ghostty webdata + if (config.emit_webdata) { + const webdata = try buildpkg.GhosttyWebdata.init(b, &deps); + webdata.install(); } - // Shell-integration - { - const install = b.addInstallDirectory(.{ - .source_dir = b.path("src/shell-integration"), - .install_dir = .{ .custom = "share" }, - .install_subdir = b.pathJoin(&.{ "ghostty", "shell-integration" }), - .exclude_extensions = &.{".md"}, - }); - b.getInstallStep().dependOn(&install.step); - - if (target.result.os.tag == .macos and exe_ != null) { - const mac_install = b.addInstallDirectory(options: { - var copy = install.options; - copy.install_dir = .{ - .custom = "Ghostty.app/Contents/Resources", - }; - break :options copy; - }); - b.getInstallStep().dependOn(&mac_install.step); - } + // Ghostty bench tools + if (config.emit_bench) { + const bench = try buildpkg.GhosttyBench.init(b, &deps); + bench.install(); } - // Themes - { - const upstream = b.dependency("iterm2_themes", .{}); - const install = b.addInstallDirectory(.{ - .source_dir = upstream.path("ghostty"), - .install_dir = .{ .custom = "share" }, - .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), - .exclude_extensions = &.{".md"}, - }); - b.getInstallStep().dependOn(&install.step); - - if (target.result.os.tag == .macos and exe_ != null) { - const mac_install = b.addInstallDirectory(options: { - var copy = install.options; - copy.install_dir = .{ - .custom = "Ghostty.app/Contents/Resources", - }; - break :options copy; - }); - b.getInstallStep().dependOn(&mac_install.step); - } + // If we're not building libghostty, then install the exe and resources. + if (config.app_runtime != .none) { + exe.install(); + resources.install(); } - // Terminfo - { - // Encode our terminfo - var str = std.ArrayList(u8).init(b.allocator); - defer str.deinit(); - try terminfo.ghostty.encode(str.writer()); + // Libghostty + // + // Note: libghostty is not stable for general purpose use. It is used + // heavily by Ghostty on macOS but it isn't built to be reusable yet. + // As such, these build steps are lacking. For example, the Darwin + // build only produces an xcframework. + if (config.app_runtime == .none) { + if (config.target.result.isDarwin()) darwin: { + if (!config.emit_xcframework) break :darwin; - // Write it - var wf = b.addWriteFiles(); - const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items); - const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo"); - b.getInstallStep().dependOn(&src_install.step); - if (target.result.os.tag == .macos and exe_ != null) { - const mac_src_install = b.addInstallFile( - src_source, - "Ghostty.app/Contents/Resources/terminfo/ghostty.terminfo", - ); - b.getInstallStep().dependOn(&mac_src_install.step); - } + // Build the xcframework + const xcframework = try buildpkg.GhosttyXCFramework.init(b, &deps); + xcframework.install(); - // Convert to termcap source format if thats helpful to people and - // install it. The resulting value here is the termcap source in case - // that is used for other commands. - if (target.result.os.tag != .windows) { - const run_step = RunStep.create(b, "infotocap"); - run_step.addArg("infotocap"); - run_step.addFileArg(src_source); - const out_source = run_step.captureStdOut(); - _ = run_step.captureStdErr(); // so we don't see stderr + // The xcframework build always installs resources because our + // macOS xcode project contains references to them. + resources.install(); - const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); - b.getInstallStep().dependOn(&cap_install.step); - - if (target.result.os.tag == .macos and exe_ != null) { - const mac_cap_install = b.addInstallFile( - out_source, - "Ghostty.app/Contents/Resources/terminfo/ghostty.termcap", - ); - b.getInstallStep().dependOn(&mac_cap_install.step); - } - } - - // Compile the terminfo source into a terminfo database - if (target.result.os.tag != .windows) { - const run_step = RunStep.create(b, "tic"); - run_step.addArgs(&.{ "tic", "-x", "-o" }); - const path = run_step.addOutputFileArg("terminfo"); - run_step.addFileArg(src_source); - _ = run_step.captureStdErr(); // so we don't see stderr - - // Depend on the terminfo source install step so that Zig build - // creates the "share" directory for us. - run_step.step.dependOn(&src_install.step); - - { - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); - b.getInstallStep().dependOn(©_step.step); + // If we aren't emitting docs we need to emit a placeholder so + // our macOS xcodeproject builds. + if (!config.emit_docs) { + var wf = b.addWriteFiles(); + const path = "share/man/.placeholder"; + const placeholder = wf.add(path, "emit-docs not true so no man pages"); + b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); } - - if (target.result.os.tag == .macos and exe_ != null) { - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg( - b.fmt("{s}/Ghostty.app/Contents/Resources", .{b.install_path}), - ); - b.getInstallStep().dependOn(©_step.step); - } - } - } - - // Fish shell completions - { - const wf = b.addWriteFiles(); - _ = wf.add("ghostty.fish", fish_completions.fish_completions); - - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/fish/vendor_completions.d", - }); - } - - // zsh shell completions - { - const wf = b.addWriteFiles(); - _ = wf.add("_ghostty", zsh_completions.zsh_completions); - - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/zsh/site-functions", - }); - } - - // bash shell completions - { - const wf = b.addWriteFiles(); - _ = wf.add("ghostty.bash", bash_completions.bash_completions); - - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/bash-completion/completions", - }); - } - - // Vim plugin - { - const wf = b.addWriteFiles(); - _ = wf.add("syntax/ghostty.vim", config_vim.syntax); - _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); - _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/vim/vimfiles", - }); - } - - // Neovim plugin - // This is just a copy-paste of the Vim plugin, but using a Neovim subdir. - // By default, Neovim doesn't look inside share/vim/vimfiles. Some distros - // configure it to do that however. Fedora, does not as a counterexample. - { - const wf = b.addWriteFiles(); - _ = wf.add("syntax/ghostty.vim", config_vim.syntax); - _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); - _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/nvim/site", - }); - } - - // Sublime syntax highlighting for bat cli tool - // NOTE: The current implementation requires symlinking the generated - // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' - // directory. The syntax then needs to be mapped to the correct language in - // the config file within the '~.config/bat' directory - // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). - { - const wf = b.addWriteFiles(); - _ = wf.add("ghostty.sublime-syntax", config_sublime_syntax.syntax); - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/bat/syntaxes", - }); - } - - // Documentation - if (emit_docs) { - try buildDocumentation(b, config); - } else { - // We need to create the zig-out/share/man directory so that - // macOS builds continue to work even if emit-docs doesn't - // work. - var wf = b.addWriteFiles(); - const path = "share/man/.placeholder"; - const placeholder = wf.add(path, "emit-docs not true so no man pages"); - b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); - } - - // Web data - if (emit_webdata) { - try buildWebData(b, config); - } - - // App (Linux) - if (target.result.os.tag == .linux and config.app_runtime != .none) { - // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html - - // Desktop file so that we have an icon and other metadata - b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); - - // Right click menu action for Plasma desktop - b.installFile("dist/linux/ghostty_dolphin.desktop", "share/kio/servicemenus/com.mitchellh.ghostty.desktop"); - - // Various icons that our application can use, including the icon - // that will be used for the desktop. - b.installFile("images/icons/icon_16.png", "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_32.png", "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png"); - 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"); - - // 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"); - b.installFile("images/icons/icon_256@2x.png", "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png"); - } - - // libghostty (non-Darwin) - if (!builtin.target.isDarwin() and config.app_runtime == .none) { - // Shared - { - const lib = b.addSharedLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .optimize = optimize, - .target = target, - .strip = strip, - }); - _ = try addDeps(b, lib, config); - - const lib_install = b.addInstallLibFile( - lib.getEmittedBin(), - "libghostty.so", - ); - b.getInstallStep().dependOn(&lib_install.step); - } - - // Static - { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .optimize = optimize, - .target = target, - .strip = strip, - }); - _ = try addDeps(b, lib, config); - - const lib_install = b.addInstallLibFile( - lib.getEmittedBin(), - "libghostty.a", - ); - b.getInstallStep().dependOn(&lib_install.step); + } else { + const libghostty_shared = try buildpkg.GhosttyLib.initShared(b, &deps); + const libghostty_static = try buildpkg.GhosttyLib.initStatic(b, &deps); + libghostty_shared.installHeader(); // Only need one header + libghostty_shared.install("libghostty.so"); + libghostty_static.install("libghostty.a"); } - - // Copy our ghostty.h to include. - const header_install = b.addInstallHeaderFile( - b.path("include/ghostty.h"), - "ghostty.h", - ); - b.getInstallStep().dependOn(&header_install.step); } - // On Mac we can build the embedding library. This only handles the macOS lib. - if (emit_xcframework) { - // Create the universal macOS lib. - const macos_lib_step, const macos_lib_path = try createMacOSLib( - b, - optimize, - config, - ); - - // Add our library to zig-out - const lib_install = b.addInstallLibFile( - macos_lib_path, - "libghostty-macos.a", - ); - b.getInstallStep().dependOn(&lib_install.step); - - // Create the universal iOS lib. - const ios_lib_step, const ios_lib_path = try createIOSLib( - b, - null, - optimize, - config, - ); - - // Add our library to zig-out - const ios_lib_install = b.addInstallLibFile( - ios_lib_path, - "libghostty-ios.a", - ); - b.getInstallStep().dependOn(&ios_lib_install.step); - - // Create the iOS simulator lib. - const ios_sim_lib_step, const ios_sim_lib_path = try createIOSLib( - b, - .simulator, - optimize, - config, - ); - - // Add our library to zig-out - const ios_sim_lib_install = b.addInstallLibFile( - ios_sim_lib_path, - "libghostty-ios-simulator.a", - ); - b.getInstallStep().dependOn(&ios_sim_lib_install.step); - - // Copy our ghostty.h to include. The header file is shared by - // all embedded targets. - const header_install = b.addInstallHeaderFile( - b.path("include/ghostty.h"), - "ghostty.h", - ); - b.getInstallStep().dependOn(&header_install.step); - - // The xcframework wraps our ghostty library so that we can link - // it to the final app built with Swift. - const xcframework = XCFrameworkStep.create(b, .{ - .name = "GhosttyKit", - .out_path = "macos/GhosttyKit.xcframework", - .libraries = &.{ - .{ - .library = macos_lib_path, - .headers = b.path("include"), - }, - .{ - .library = ios_lib_path, - .headers = b.path("include"), - }, - .{ - .library = ios_sim_lib_path, - .headers = b.path("include"), - }, - }, - }); - xcframework.step.dependOn(ios_lib_step); - xcframework.step.dependOn(ios_sim_lib_step); - xcframework.step.dependOn(macos_lib_step); - xcframework.step.dependOn(&header_install.step); - b.default_step.dependOn(xcframework.step); - } - - // wasm + // Run runs the Ghostty exe { - // Build our Wasm target. - const wasm_crosstarget: std.Target.Query = .{ - .cpu_arch = .wasm32, - .os_tag = .freestanding, - .cpu_model = .{ .explicit = &std.Target.wasm.cpu.mvp }, - .cpu_features_add = std.Target.wasm.featureSet(&.{ - // We use this to explicitly request shared memory. - .atomics, - - // Not explicitly used but compiler could use them if they want. - .bulk_memory, - .reference_types, - .sign_ext, - }), - }; - - // Whether we're using wasm shared memory. Some behaviors change. - // For now we require this but I wanted to make the code handle both - // up front. - const wasm_shared: bool = true; - - // Modify our build configuration for wasm builds. - const wasm_config: BuildConfig = config: { - var copy = config; - - // Backends that are fixed for wasm - copy.font_backend = .web_canvas; - - // Wasm-specific options - copy.wasm_shared = wasm_shared; - copy.wasm_target = wasm_target; - - break :config copy; - }; - - const wasm = b.addSharedLibrary(.{ - .name = "ghostty-wasm", - .root_source_file = b.path("src/main_wasm.zig"), - .target = b.resolveTargetQuery(wasm_crosstarget), - .optimize = optimize, - }); - - // So that we can use web workers with our wasm binary - wasm.import_memory = true; - wasm.initial_memory = 65536 * 25; - wasm.max_memory = 65536 * 65536; // Maximum number of pages in wasm32 - wasm.shared_memory = wasm_shared; - - // Stack protector adds extern requirements that we don't satisfy. - wasm.root_module.stack_protector = false; - - // Wasm-specific deps - _ = try addDeps(b, wasm, wasm_config); - - // Install - const wasm_install = b.addInstallArtifact(wasm, .{}); - wasm_install.dest_dir = .{ .prefix = {} }; - - const step = b.step("wasm", "Build the wasm library"); - step.dependOn(&wasm_install.step); - - // We support tests via wasmtime. wasmtime uses WASI so this - // isn't an exact match to our freestanding target above but - // it lets us test some basic functionality. - const test_step = b.step("test-wasm", "Run all tests for wasm"); - const main_test = b.addTest(.{ - .name = "wasm-test", - .root_source_file = b.path("src/main_wasm.zig"), - .target = b.resolveTargetQuery(wasm_crosstarget), - }); - - _ = try addDeps(b, main_test, wasm_config); - test_step.dependOn(&main_test.step); - } - - // Run - run: { - // Build our run step, which runs the main app by default, but will - // run a conformance app if `-Dconformance` is set. - const run_exe = if (conformance) |name| blk: { - var conformance_exes = try conformanceSteps(b, target, optimize); - defer conformance_exes.deinit(); - break :blk conformance_exes.get(name) orelse return error.InvalidConformance; - } else exe_ orelse break :run; - - const run_cmd = b.addRunArtifact(run_exe); - if (b.args) |args| { - run_cmd.addArgs(args); - } - + const run_cmd = b.addRunArtifact(exe.exe); + if (b.args) |args| run_cmd.addArgs(args); const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); } @@ -929,925 +89,18 @@ pub fn build(b: *std.Build) !void { const test_step = b.step("test", "Run all tests"); const test_filter = b.option([]const u8, "test-filter", "Filter for test"); - // Force all Mac builds to use a `generic` CPU. This avoids - // potential issues with `highway` compile errors due to missing - // `arm_neon` features (see for example https://github.com/mitchellh/ghostty/issues/1640). - const test_target = if (target.result.os.tag == .macos and builtin.target.isDarwin()) - genericMacOSTarget(b, null) - else - target; - - const main_test = b.addTest(.{ + const test_exe = b.addTest(.{ .name = "ghostty-test", .root_source_file = b.path("src/main.zig"), - .target = test_target, + .target = config.target, .filter = test_filter, }); { - if (emit_test_exe) b.installArtifact(main_test); - _ = try addDeps(b, main_test, config); - const test_run = b.addRunArtifact(main_test); + if (config.emit_test_exe) b.installArtifact(test_exe); + _ = try deps.add(test_exe); + const test_run = b.addRunArtifact(test_exe); test_step.dependOn(&test_run.step); } } } - -/// Returns the minimum OS version for the given OS tag. This shouldn't -/// be used generally, it should only be used for Darwin-based OS currently. -fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { - return switch (tag) { - // We support back to the earliest officially supported version - // of macOS by Apple. EOL versions are not supported. - .macos => .{ .semver = .{ - .major = 13, - .minor = 0, - .patch = 0, - } }, - - // iOS 17 picked arbitrarily - .ios => .{ .semver = .{ - .major = 17, - .minor = 0, - .patch = 0, - } }, - - // This should never happen currently. If we add a new target then - // we should add a new case here. - else => null, - }; -} - -// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`. -// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`. -fn genericMacOSTarget(b: *std.Build, arch: ?std.Target.Cpu.Arch) ResolvedTarget { - return b.resolveTargetQuery(.{ - .cpu_arch = arch orelse builtin.target.cpu.arch, - .os_tag = .macos, - .os_version_min = osVersionMin(.macos), - }); -} - -/// Creates a universal macOS libghostty library and returns the path -/// to the final library. -/// -/// The library is always a fat static library currently because this is -/// expected to be used directly with Xcode and Swift. In the future, we -/// probably want to change this because it makes it harder to use the -/// library in other contexts. -fn createMacOSLib( - b: *std.Build, - optimize: std.builtin.OptimizeMode, - config: BuildConfig, -) !struct { *std.Build.Step, std.Build.LazyPath } { - const static_lib_aarch64 = lib: { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = genericMacOSTarget(b, .aarch64), - .optimize = optimize, - }); - lib.bundle_compiler_rt = true; - lib.linkLibC(); - - // Create a single static lib with all our dependencies merged - var lib_list = try addDeps(b, lib, config); - try lib_list.append(lib.getEmittedBin()); - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-aarch64-fat.a", - .sources = lib_list.items, - }); - libtool.step.dependOn(&lib.step); - b.default_step.dependOn(libtool.step); - - break :lib libtool; - }; - - const static_lib_x86_64 = lib: { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = genericMacOSTarget(b, .x86_64), - .optimize = optimize, - }); - lib.bundle_compiler_rt = true; - lib.linkLibC(); - - // Create a single static lib with all our dependencies merged - var lib_list = try addDeps(b, lib, config); - try lib_list.append(lib.getEmittedBin()); - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-x86_64-fat.a", - .sources = lib_list.items, - }); - libtool.step.dependOn(&lib.step); - b.default_step.dependOn(libtool.step); - - break :lib libtool; - }; - - const static_lib_universal = LipoStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty.a", - .input_a = static_lib_aarch64.output, - .input_b = static_lib_x86_64.output, - }); - static_lib_universal.step.dependOn(static_lib_aarch64.step); - static_lib_universal.step.dependOn(static_lib_x86_64.step); - - return .{ - static_lib_universal.step, - static_lib_universal.output, - }; -} - -/// Create an Apple iOS/iPadOS build. -fn createIOSLib( - b: *std.Build, - abi: ?std.Target.Abi, - optimize: std.builtin.OptimizeMode, - config: BuildConfig, -) !struct { *std.Build.Step, std.Build.LazyPath } { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .optimize = optimize, - .target = b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = osVersionMin(.ios), - .abi = abi, - }), - }); - lib.bundle_compiler_rt = true; - lib.linkLibC(); - - // Create a single static lib with all our dependencies merged - var lib_list = try addDeps(b, lib, config); - try lib_list.append(lib.getEmittedBin()); - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-ios-fat.a", - .sources = lib_list.items, - }); - libtool.step.dependOn(&lib.step); - - return .{ - libtool.step, - libtool.output, - }; -} - -/// Used to keep track of a list of file sources. -const LazyPathList = std.ArrayList(std.Build.LazyPath); - -/// Adds and links all of the primary dependencies for the exe. -fn addDeps( - b: *std.Build, - step: *std.Build.Step.Compile, - config: BuildConfig, -) !LazyPathList { - // All object targets get access to a standard build_options module - const exe_options = b.addOptions(); - try config.addOptions(exe_options); - step.root_module.addOptions("build_options", exe_options); - - // We maintain a list of our static libraries and return it so that - // we can build a single fat static library for the final app. - var static_libs = LazyPathList.init(b.allocator); - errdefer static_libs.deinit(); - - const target = step.root_module.resolved_target.?; - const optimize = step.root_module.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, - }; - - // Freetype - _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help - if (config.font_backend.hasFreetype()) { - const freetype_dep = b.dependency("freetype", .{ - .target = target, - .optimize = optimize, - .@"enable-libpng" = true, - }); - step.root_module.addImport("freetype", freetype_dep.module("freetype")); - - if (b.systemIntegrationOption("freetype", .{})) { - step.linkSystemLibrary2("bzip2", dynamic_link_opts); - step.linkSystemLibrary2("freetype2", dynamic_link_opts); - } else { - step.linkLibrary(freetype_dep.artifact("freetype")); - try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin()); - } - } - - // Harfbuzz - _ = b.systemIntegrationOption("harfbuzz", .{}); // Shows it in help - if (config.font_backend.hasHarfbuzz()) { - const harfbuzz_dep = b.dependency("harfbuzz", .{ - .target = target, - .optimize = optimize, - .@"enable-freetype" = true, - .@"enable-coretext" = config.font_backend.hasCoretext(), - }); - - step.root_module.addImport( - "harfbuzz", - harfbuzz_dep.module("harfbuzz"), - ); - if (b.systemIntegrationOption("harfbuzz", .{})) { - step.linkSystemLibrary2("harfbuzz", dynamic_link_opts); - } else { - step.linkLibrary(harfbuzz_dep.artifact("harfbuzz")); - try static_libs.append(harfbuzz_dep.artifact("harfbuzz").getEmittedBin()); - } - } - - // Fontconfig - _ = b.systemIntegrationOption("fontconfig", .{}); // Shows it in help - if (config.font_backend.hasFontconfig()) { - const fontconfig_dep = b.dependency("fontconfig", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport( - "fontconfig", - fontconfig_dep.module("fontconfig"), - ); - - if (b.systemIntegrationOption("fontconfig", .{})) { - step.linkSystemLibrary2("fontconfig", dynamic_link_opts); - } else { - step.linkLibrary(fontconfig_dep.artifact("fontconfig")); - try static_libs.append(fontconfig_dep.artifact("fontconfig").getEmittedBin()); - } - } - - // Libpng - Ghostty doesn't actually use this directly, its only used - // through dependencies, so we only need to add it to our static - // libs list if we're not using system integration. The dependencies - // will handle linking it. - if (!b.systemIntegrationOption("libpng", .{})) { - const libpng_dep = b.dependency("libpng", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(libpng_dep.artifact("png")); - try static_libs.append(libpng_dep.artifact("png").getEmittedBin()); - } - - // Zlib - same as libpng, only used through dependencies. - if (!b.systemIntegrationOption("zlib", .{})) { - const zlib_dep = b.dependency("zlib", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(zlib_dep.artifact("z")); - try static_libs.append(zlib_dep.artifact("z").getEmittedBin()); - } - - // Oniguruma - const oniguruma_dep = b.dependency("oniguruma", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("oniguruma", oniguruma_dep.module("oniguruma")); - if (b.systemIntegrationOption("oniguruma", .{})) { - step.linkSystemLibrary2("oniguruma", dynamic_link_opts); - } else { - step.linkLibrary(oniguruma_dep.artifact("oniguruma")); - try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin()); - } - - // Glslang - const glslang_dep = b.dependency("glslang", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("glslang", glslang_dep.module("glslang")); - if (b.systemIntegrationOption("glslang", .{})) { - step.linkSystemLibrary2("glslang", dynamic_link_opts); - step.linkSystemLibrary2("glslang-default-resource-limits", dynamic_link_opts); - } else { - step.linkLibrary(glslang_dep.artifact("glslang")); - try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin()); - } - - // Spirv-cross - const spirv_cross_dep = b.dependency("spirv_cross", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("spirv_cross", spirv_cross_dep.module("spirv_cross")); - if (b.systemIntegrationOption("spirv-cross", .{})) { - step.linkSystemLibrary2("spirv-cross", dynamic_link_opts); - } else { - step.linkLibrary(spirv_cross_dep.artifact("spirv_cross")); - try static_libs.append(spirv_cross_dep.artifact("spirv_cross").getEmittedBin()); - } - - // Simdutf - if (b.systemIntegrationOption("simdutf", .{})) { - step.linkSystemLibrary2("simdutf", dynamic_link_opts); - } else { - const simdutf_dep = b.dependency("simdutf", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(simdutf_dep.artifact("simdutf")); - try static_libs.append(simdutf_dep.artifact("simdutf").getEmittedBin()); - } - - // Sentry - 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()); - - // We also need to include breakpad in the static libs. - const breakpad_dep = sentry_dep.builder.dependency("breakpad", .{ - .target = target, - .optimize = optimize, - }); - try static_libs.append(breakpad_dep.artifact("breakpad").getEmittedBin()); - } - - // Wasm we do manually since it is such a different build. - if (step.rootModuleTarget().cpu.arch == .wasm32) { - const js_dep = b.dependency("zig_js", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("zig-js", js_dep.module("zig-js")); - - return static_libs; - } - - // On Linux, we need to add a couple common library paths that aren't - // on the standard search list. i.e. GTK is often in /usr/lib/x86_64-linux-gnu - // on x86_64. - if (step.rootModuleTarget().os.tag == .linux) { - const triple = try step.rootModuleTarget().linuxTriple(b.allocator); - step.addLibraryPath(.{ .cwd_relative = b.fmt("/usr/lib/{s}", .{triple}) }); - } - - // C files - step.linkLibC(); - step.addIncludePath(b.path("src/stb")); - step.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} }); - if (step.rootModuleTarget().os.tag == .linux) { - step.addIncludePath(b.path("src/apprt/gtk")); - } - - // C++ files - step.linkLibCpp(); - step.addIncludePath(b.path("src")); - { - // From hwy/detect_targets.h - const HWY_AVX3_SPR: c_int = 1 << 4; - const HWY_AVX3_ZEN4: c_int = 1 << 6; - const HWY_AVX3_DL: c_int = 1 << 7; - const HWY_AVX3: c_int = 1 << 8; - - // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 - // To workaround this we just disable AVX512 support completely. - // The performance difference between AVX2 and AVX512 is not - // significant for our use case and AVX512 is very rare on consumer - // hardware anyways. - const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; - - step.addCSourceFiles(.{ - .files = &.{ - "src/simd/base64.cpp", - "src/simd/codepoint_width.cpp", - "src/simd/index_of.cpp", - "src/simd/vt.cpp", - }, - .flags = if (step.rootModuleTarget().cpu.arch == .x86_64) &.{ - b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}), - } else &.{}, - }); - } - - // We always require the system SDK so that our system headers are available. - // This makes things like `os/log.h` available for cross-compiling. - if (step.rootModuleTarget().isDarwin()) { - try @import("apple_sdk").addPaths(b, &step.root_module); - try addMetallib(b, step); - } - - // Other dependencies, mostly pure Zig - step.root_module.addImport("opengl", b.dependency( - "opengl", - .{}, - ).module("opengl")); - step.root_module.addImport("vaxis", b.dependency("vaxis", .{ - .target = target, - .optimize = optimize, - }).module("vaxis")); - step.root_module.addImport("wuffs", b.dependency("wuffs", .{ - .target = target, - .optimize = optimize, - }).module("wuffs")); - step.root_module.addImport("xev", b.dependency("libxev", .{ - .target = target, - .optimize = optimize, - }).module("xev")); - step.root_module.addImport("z2d", b.addModule("z2d", .{ - .root_source_file = b.dependency("z2d", .{}).path("src/z2d.zig"), - .target = target, - .optimize = optimize, - })); - step.root_module.addImport("ziglyph", b.dependency("ziglyph", .{ - .target = target, - .optimize = optimize, - }).module("ziglyph")); - step.root_module.addImport("zf", b.dependency("zf", .{ - .target = target, - .optimize = optimize, - .with_tui = false, - }).module("zf")); - - // Mac Stuff - if (step.rootModuleTarget().isDarwin()) { - const objc_dep = b.dependency("zig_objc", .{ - .target = target, - .optimize = optimize, - }); - const macos_dep = b.dependency("macos", .{ - .target = target, - .optimize = optimize, - }); - - step.root_module.addImport("objc", objc_dep.module("objc")); - step.root_module.addImport("macos", macos_dep.module("macos")); - step.linkLibrary(macos_dep.artifact("macos")); - try static_libs.append(macos_dep.artifact("macos").getEmittedBin()); - - if (config.renderer == .opengl) { - step.linkFramework("OpenGL"); - } - } - - // cimgui - const cimgui_dep = b.dependency("cimgui", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("cimgui", cimgui_dep.module("cimgui")); - step.linkLibrary(cimgui_dep.artifact("cimgui")); - try static_libs.append(cimgui_dep.artifact("cimgui").getEmittedBin()); - - // Highway - const highway_dep = b.dependency("highway", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(highway_dep.artifact("highway")); - try static_libs.append(highway_dep.artifact("highway").getEmittedBin()); - - // utfcpp - This is used as a dependency on our hand-written C++ code - const utfcpp_dep = b.dependency("utfcpp", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(utfcpp_dep.artifact("utfcpp")); - try static_libs.append(utfcpp_dep.artifact("utfcpp").getEmittedBin()); - - // If we're building an exe then we have additional dependencies. - if (step.kind != .lib) { - // We always statically compile glad - step.addIncludePath(b.path("vendor/glad/include/")); - step.addCSourceFile(.{ - .file = b.path("vendor/glad/src/gl.c"), - .flags = &.{}, - }); - - // When we're targeting flatpak we ALWAYS link GTK so we - // get access to glib for dbus. - if (config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts); - - switch (config.app_runtime) { - .none => {}, - - .glfw => glfw: { - const mach_glfw_dep = b.lazyDependency("mach_glfw", .{ - .target = target, - .optimize = optimize, - }) orelse break :glfw; - step.root_module.addImport("glfw", mach_glfw_dep.module("mach-glfw")); - }, - - .gtk => { - step.linkSystemLibrary2("gtk4", dynamic_link_opts); - if (config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); - if (config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); - - { - const gresource = @import("src/apprt/gtk/gresource.zig"); - - const wf = b.addWriteFiles(); - const gresource_xml = wf.add("gresource.xml", gresource.gresource_xml); - - const generate_resources_c = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-source", - "--target", - }); - const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c"); - generate_resources_c.addFileArg(gresource_xml); - generate_resources_c.extra_file_dependencies = &gresource.dependencies; - step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} }); - - const generate_resources_h = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-header", - "--target", - }); - const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h"); - generate_resources_h.addFileArg(gresource_xml); - generate_resources_h.extra_file_dependencies = &gresource.dependencies; - step.addIncludePath(ghostty_resources_h.dirname()); - } - }, - } - } - - try addHelp(b, step, config); - try addUnicodeTables(b, step); - - return static_libs; -} - -/// Generate Metal shader library -fn addMetallib( - b: *std.Build, - step: *std.Build.Step.Compile, -) !void { - const metal_step = MetallibStep.create(b, .{ - .name = "Ghostty", - .target = step.root_module.resolved_target.?, - .sources = &.{b.path("src/renderer/shaders/cell.metal")}, - }); - - metal_step.output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("ghostty_metallib", .{ - .root_source_file = metal_step.output, - }); -} - -/// Generate help files -fn addHelp( - b: *std.Build, - step_: ?*std.Build.Step.Compile, - config: BuildConfig, -) !void { - // Our static state between runs. We memoize our help strings - // so that we only execute the help generation once. - const HelpState = struct { - var generated: ?std.Build.LazyPath = null; - }; - - const help_output = HelpState.generated orelse strings: { - const help_exe = b.addExecutable(.{ - .name = "helpgen", - .root_source_file = b.path("src/helpgen.zig"), - .target = b.host, - }); - if (step_ == null) b.installArtifact(help_exe); - - const help_config = config: { - var copy = config; - copy.exe_entrypoint = .helpgen; - break :config copy; - }; - const options = b.addOptions(); - try help_config.addOptions(options); - help_exe.root_module.addOptions("build_options", options); - - const help_run = b.addRunArtifact(help_exe); - HelpState.generated = help_run.captureStdOut(); - break :strings HelpState.generated.?; - }; - - if (step_) |step| { - help_output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("help_strings", .{ - .root_source_file = help_output, - }); - } -} - -/// Generate unicode fast lookup tables -fn addUnicodeTables( - b: *std.Build, - step_: ?*std.Build.Step.Compile, -) !void { - // Our static state between runs. We memoize our output to gen once - const State = struct { - var generated: ?std.Build.LazyPath = null; - }; - - const output = State.generated orelse strings: { - const exe = b.addExecutable(.{ - .name = "unigen", - .root_source_file = b.path("src/unicode/props.zig"), - .target = b.host, - }); - exe.linkLibC(); - if (step_ == null) b.installArtifact(exe); - - const ziglyph_dep = b.dependency("ziglyph", .{ - .target = b.host, - }); - exe.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph")); - - const help_run = b.addRunArtifact(exe); - State.generated = help_run.captureStdOut(); - break :strings State.generated.?; - }; - - if (step_) |step| { - output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("unicode_tables", .{ - .root_source_file = output, - }); - } -} - -/// Generate documentation (manpages, etc.) from help strings -fn buildDocumentation( - b: *std.Build, - config: BuildConfig, -) !void { - const manpages = [_]struct { - name: []const u8, - section: []const u8, - }{ - .{ .name = "ghostty", .section = "1" }, - .{ .name = "ghostty", .section = "5" }, - }; - - inline for (manpages) |manpage| { - const generate_markdown = b.addExecutable(.{ - .name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, - .root_source_file = b.path("src/main.zig"), - .target = b.host, - }); - try addHelp(b, generate_markdown, config); - - const gen_config = config: { - var copy = config; - copy.exe_entrypoint = @field( - build_config.ExeEntrypoint, - "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, - ); - break :config copy; - }; - - const generate_markdown_options = b.addOptions(); - try gen_config.addOptions(generate_markdown_options); - generate_markdown.root_module.addOptions("build_options", generate_markdown_options); - - const generate_markdown_step = b.addRunArtifact(generate_markdown); - const markdown_output = generate_markdown_step.captureStdOut(); - - b.getInstallStep().dependOn(&b.addInstallFile( - markdown_output, - "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".md", - ).step); - - const generate_html = b.addSystemCommand(&.{"pandoc"}); - generate_html.addArgs(&.{ - "--standalone", - "--from", - "markdown", - "--to", - "html", - }); - generate_html.addFileArg(markdown_output); - - b.getInstallStep().dependOn(&b.addInstallFile( - generate_html.captureStdOut(), - "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".html", - ).step); - - const generate_manpage = b.addSystemCommand(&.{"pandoc"}); - generate_manpage.addArgs(&.{ - "--standalone", - "--from", - "markdown", - "--to", - "man", - }); - generate_manpage.addFileArg(markdown_output); - - b.getInstallStep().dependOn(&b.addInstallFile( - generate_manpage.captureStdOut(), - "share/man/man" ++ manpage.section ++ "/" ++ manpage.name ++ "." ++ manpage.section, - ).step); - } -} - -/// Generate the website reference data that we merge into the -/// official Ghostty website. This isn't meant to be part of any -/// actual build. -fn buildWebData( - b: *std.Build, - config: BuildConfig, -) !void { - { - const webgen_config = b.addExecutable(.{ - .name = "webgen_config", - .root_source_file = b.path("src/main.zig"), - .target = b.host, - }); - try addHelp(b, webgen_config, config); - - { - const buildconfig = config: { - var copy = config; - copy.exe_entrypoint = .webgen_config; - break :config copy; - }; - - const options = b.addOptions(); - try buildconfig.addOptions(options); - webgen_config.root_module.addOptions("build_options", options); - } - - const webgen_config_step = b.addRunArtifact(webgen_config); - const webgen_config_out = webgen_config_step.captureStdOut(); - - b.getInstallStep().dependOn(&b.addInstallFile( - webgen_config_out, - "share/ghostty/webdata/config.mdx", - ).step); - } - - { - const webgen_actions = b.addExecutable(.{ - .name = "webgen_actions", - .root_source_file = b.path("src/main.zig"), - .target = b.host, - }); - try addHelp(b, webgen_actions, config); - - { - const buildconfig = config: { - var copy = config; - copy.exe_entrypoint = .webgen_actions; - break :config copy; - }; - - const options = b.addOptions(); - try buildconfig.addOptions(options); - webgen_actions.root_module.addOptions("build_options", options); - } - - const webgen_actions_step = b.addRunArtifact(webgen_actions); - const webgen_actions_out = webgen_actions_step.captureStdOut(); - - b.getInstallStep().dependOn(&b.addInstallFile( - webgen_actions_out, - "share/ghostty/webdata/actions.mdx", - ).step); - } -} - -fn benchSteps( - b: *std.Build, - target: std.Build.ResolvedTarget, - config: BuildConfig, - install: bool, -) !void { - // Open the directory ./src/bench - const c_dir_path = (comptime root()) ++ "/src/bench"; - var c_dir = try fs.cwd().openDir(c_dir_path, .{ .iterate = true }); - defer c_dir.close(); - - // Go through and add each as a step - var c_dir_it = c_dir.iterate(); - while (try c_dir_it.next()) |entry| { - // Get the index of the last '.' so we can strip the extension. - const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; - if (index == 0) continue; - - // If it doesn't end in 'zig' then ignore - if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue; - - // Name of the conformance app and full path to the entrypoint. - const name = entry.name[0..index]; - - // Executable builder. - const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); - const c_exe = b.addExecutable(.{ - .name = bin_name, - .root_source_file = b.path("src/main.zig"), - .target = target, - - // We always want our benchmarks to be in release mode. - .optimize = .ReleaseFast, - }); - c_exe.linkLibC(); - if (install) b.installArtifact(c_exe); - _ = try addDeps(b, c_exe, config: { - var copy = config; - var enum_name: [64]u8 = undefined; - @memcpy(enum_name[0..name.len], name); - std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_'); - - var buf: [64]u8 = undefined; - copy.exe_entrypoint = std.meta.stringToEnum( - build_config.ExeEntrypoint, - try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}), - ).?; - - break :config copy; - }); - } -} - -fn conformanceSteps( - b: *std.Build, - target: std.Build.ResolvedTarget, - optimize: std.builtin.Mode, -) !std.StringHashMap(*CompileStep) { - var map = std.StringHashMap(*CompileStep).init(b.allocator); - - // Open the directory ./conformance - const c_dir_path = (comptime root()) ++ "/conformance"; - var c_dir = try fs.cwd().openDir(c_dir_path, .{ .iterate = true }); - defer c_dir.close(); - - // Go through and add each as a step - var c_dir_it = c_dir.iterate(); - while (try c_dir_it.next()) |entry| { - // Get the index of the last '.' so we can strip the extension. - const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; - if (index == 0) continue; - - // Name of the conformance app and full path to the entrypoint. - const name = try b.allocator.dupe(u8, entry.name[0..index]); - const path = try fs.path.join(b.allocator, &[_][]const u8{ - c_dir_path, - entry.name, - }); - - // Executable builder. - const c_exe = b.addExecutable(.{ - .name = name, - .root_source_file = b.path(path), - .target = target, - .optimize = optimize, - }); - - const install = b.addInstallArtifact(c_exe, .{}); - install.dest_sub_path = "conformance"; - b.getInstallStep().dependOn(&install.step); - - // Store the mapping - try map.put(name, c_exe); - } - - return map; -} - -/// Path to the directory with the build.zig. -fn root() []const u8 { - return std.fs.path.dirname(@src().file) orelse unreachable; -} - -/// ANSI escape codes for colored log output -const color_map = std.StaticStringMap([]const u8).initComptime(.{ - &.{ "black", "30m" }, - &.{ "blue", "34m" }, - &.{ "b", "1m" }, - &.{ "d", "2m" }, - &.{ "cyan", "36m" }, - &.{ "green", "32m" }, - &.{ "magenta", "35m" }, - &.{ "red", "31m" }, - &.{ "white", "37m" }, - &.{ "yellow", "33m" }, -}); diff --git a/build.zig.zon b/build.zig.zon index 5c202e9cdb..a8f45e6ead 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,14 +5,22 @@ .dependencies = .{ // Zig libs .libxev = .{ - .url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz", - .hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf", + .url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz", + .hash = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c", }, .mach_glfw = .{ .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", .hash = "12206ed982e709e565d536ce930701a8c07edfd2cfdce428683f3f2a601d37696a62", .lazy = true, }, + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", + .hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f", + }, + .z2d = .{ + .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a", + .hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", + }, .zig_objc = .{ .url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz", .hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634", @@ -25,6 +33,14 @@ .url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz", .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", }, + .zig_wayland = .{ + .url = "https://codeberg.org/ifreund/zig-wayland/archive/fbfe3b4ac0b472a27b1f1a67405436c58cbee12d.tar.gz", + .hash = "12209ca054cb1919fa276e328967f10b253f7537c4136eb48f3332b0f7cf661cad38", + }, + .zf = .{ + .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", + .hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8", + }, // C libs .cimgui = .{ .path = "./pkg/cimgui" }, @@ -46,23 +62,25 @@ .glslang = .{ .path = "./pkg/glslang" }, .spirv_cross = .{ .path = "./pkg/spirv-cross" }, - // Other - .apple_sdk = .{ .path = "./pkg/apple-sdk" }, - .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", - .hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "12202cdac858abc52413a6c6711d5026d2d3c8e13f95ca2c327eade0736298bb021f", }, - .vaxis = .{ - .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", - .hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f", + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "12201a57c6ce0001aa034fa80fba3e1cd2253c560a45748f4f4dd21ff23b491cddef", }, - .zf = .{ - .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", - .hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8", + .plasma_wayland_protocols = .{ + .url = "git+https://github.com/KDE/plasma-wayland-protocols?ref=main#db525e8f9da548cffa2ac77618dd0fbe7f511b86", + .hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566", }, - .z2d = .{ - .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a", - .hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", + + // Other + .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/0e23daf59234fc892cba949562d7bf69204594bb.tar.gz", + .hash = "12204fc99743d8232e691ac22e058519bfc6ea92de4a11c6dba59b117531c847cd6a", }, }, } diff --git a/conformance/ansi_ri.zig b/conformance/ansi_ri.zig deleted file mode 100644 index 9918042953..0000000000 --- a/conformance/ansi_ri.zig +++ /dev/null @@ -1,12 +0,0 @@ -//! Reverse Index (RI) - ESC M -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\nC", .{}); - try stdout.print("\x1BM", .{}); - try stdout.print("D\n\n", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/ansi_ri_top.zig b/conformance/ansi_ri_top.zig deleted file mode 100644 index b2258f069c..0000000000 --- a/conformance/ansi_ri_top.zig +++ /dev/null @@ -1,23 +0,0 @@ -//! Reverse Index (RI) - ESC M -//! Case: test that if the cursor is at the top, it scrolls down. -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\n\n", .{}); - - try stdout.print("\x1B[H", .{}); // Top-left - try stdout.print("\x1BM", .{}); // Reverse-Index - try stdout.print("D", .{}); - - try stdout.print("\x0D", .{}); // CR - try stdout.print("\x0A", .{}); // LF - try stdout.print("\x1B[H", .{}); // Top-left - try stdout.print("\x1BM", .{}); // Reverse-Index - try stdout.print("E", .{}); - - try stdout.print("\n", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/blocks.zig b/conformance/blocks.zig deleted file mode 100644 index a977ca4ea7..0000000000 --- a/conformance/blocks.zig +++ /dev/null @@ -1,99 +0,0 @@ -//! Outputs various box glyphs for testing. -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - - // Box Drawing - { - try stdout.print("\x1b[4mBox Drawing\x1b[0m\n", .{}); - var i: usize = 0x2500; - const step: usize = 32; - while (i <= 0x257F) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - try stdout.print("{u} ", .{@as(u21, @intCast(i + j))}); - } - - try stdout.print("\n\n", .{}); - } - } - - // Block Elements - { - try stdout.print("\x1b[4mBlock Elements\x1b[0m\n", .{}); - var i: usize = 0x2580; - const step: usize = 32; - while (i <= 0x259f) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - try stdout.print("{u} ", .{@as(u21, @intCast(i + j))}); - } - - try stdout.print("\n\n", .{}); - } - } - - // Braille Elements - { - try stdout.print("\x1b[4mBraille\x1b[0m\n", .{}); - var i: usize = 0x2800; - const step: usize = 32; - while (i <= 0x28FF) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - try stdout.print("{u} ", .{@as(u21, @intCast(i + j))}); - } - - try stdout.print("\n\n", .{}); - } - } - - { - try stdout.print("\x1b[4mSextants\x1b[0m\n", .{}); - var i: usize = 0x1FB00; - const step: usize = 32; - const end = 0x1FB3B; - while (i <= end) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - const v = i + j; - if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))}); - } - - try stdout.print("\n\n", .{}); - } - } - - { - try stdout.print("\x1b[4mWedge Triangles\x1b[0m\n", .{}); - var i: usize = 0x1FB3C; - const step: usize = 32; - const end = 0x1FB6B; - while (i <= end) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - const v = i + j; - if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))}); - } - - try stdout.print("\n\n", .{}); - } - } - - { - try stdout.print("\x1b[4mOther\x1b[0m\n", .{}); - var i: usize = 0x1FB70; - const step: usize = 32; - const end = 0x1FB8B; - while (i <= end) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - const v = i + j; - if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))}); - } - - try stdout.print("\n\n", .{}); - } - } -} diff --git a/conformance/csi_decstbm.zig b/conformance/csi_decstbm.zig deleted file mode 100644 index f8b6524274..0000000000 --- a/conformance/csi_decstbm.zig +++ /dev/null @@ -1,15 +0,0 @@ -//! Set Top and Bottom Margins (DECSTBM) - ESC [ r -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\nC\nD", .{}); - try stdout.print("\x1B[1;3r", .{}); // cursor up - try stdout.print("\x1B[1;1H", .{}); // top-left - try stdout.print("\x1B[M", .{}); // delete line - try stdout.print("E\n", .{}); - try stdout.print("\x1B[7;1H", .{}); // cursor up - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/csi_dl.zig b/conformance/csi_dl.zig deleted file mode 100644 index 175637d9b2..0000000000 --- a/conformance/csi_dl.zig +++ /dev/null @@ -1,14 +0,0 @@ -//! Delete Line (DL) - Esc [ M -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\nC\nD", .{}); - try stdout.print("\x1B[2A", .{}); // cursor up - try stdout.print("\x1B[M", .{}); - try stdout.print("E\n", .{}); - try stdout.print("\x1B[B", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/csi_il.zig b/conformance/csi_il.zig deleted file mode 100644 index 52d2c392fa..0000000000 --- a/conformance/csi_il.zig +++ /dev/null @@ -1,17 +0,0 @@ -//! Insert Line (IL) - Esc [ L -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("\x1B[2J", .{}); // clear screen - try stdout.print("\x1B[1;1H", .{}); // set cursor position - try stdout.print("A\nB\nC\nD\nE", .{}); - try stdout.print("\x1B[1;2r", .{}); // set scroll region - try stdout.print("\x1B[1;1H", .{}); // set cursor position - try stdout.print("\x1B[1L", .{}); // insert lines - try stdout.print("X", .{}); - try stdout.print("\x1B[7;1H", .{}); // set cursor position - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/esc_decaln.zig b/conformance/esc_decaln.zig deleted file mode 100644 index aeb1887a48..0000000000 --- a/conformance/esc_decaln.zig +++ /dev/null @@ -1,10 +0,0 @@ -//! DECALN - ESC # 8 -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("\x1B#8", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index 6fc43d4708..6e464ea87c 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -7,6 +7,7 @@ Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true +StartupWMClass=com.mitchellh.ghostty Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop old mode 100644 new mode 100755 diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py new file mode 100644 index 0000000000..42c3976428 --- /dev/null +++ b/dist/linux/ghostty_nautilus.py @@ -0,0 +1,97 @@ +# Adapted from wezterm: https://github.com/wez/wezterm/blob/main/assets/wezterm-nautilus.py +# original copyright notice: +# +# Copyright (C) 2022 Sebastian Wiesner +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from os.path import isdir +from gi import require_version +from gi.repository import Nautilus, GObject, Gio, GLib + + +class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): + def __init__(self): + super().__init__() + session = Gio.bus_get_sync(Gio.BusType.SESSION, None) + self._systemd = None + # Check if the this system runs under systemd, per sd_booted(3) + if isdir('/run/systemd/system/'): + self._systemd = Gio.DBusProxy.new_sync(session, + Gio.DBusProxyFlags.NONE, + None, + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", None) + + def _open_terminal(self, path): + cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] + child = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) + if self._systemd: + # Move new terminal into a dedicated systemd scope to make systemd + # track the terminal separately; in particular this makes systemd + # keep a separate CPU and memory account for the terminal which in turn + # ensures that oomd doesn't take nautilus down if a process in + # ghostty consumes a lot of memory. + pid = int(child.get_identifier()) + props = [("PIDs", GLib.Variant('au', [pid])), + ('CollectMode', GLib.Variant('s', 'inactive-or-failed'))] + name = 'app-nautilus-com.mitchellh.ghostty-{}.scope'.format(pid) + args = GLib.Variant('(ssa(sv)a(sa(sv)))', (name, 'fail', props, [])) + self._systemd.call_sync('StartTransientUnit', args, + Gio.DBusCallFlags.NO_AUTO_START, 500, None) + + def _menu_item_activated(self, _menu, paths): + for path in paths: + self._open_terminal(path) + + def _make_item(self, name, paths): + item = Nautilus.MenuItem(name=name, label='Open in Ghostty', + icon='com.mitchellh.ghostty') + item.connect('activate', self._menu_item_activated, paths) + return item + + def _paths_to_open(self, files): + paths = [] + for file in files: + location = file.get_location() if file.is_directory() else file.get_parent_location() + path = location.get_path() + if path and path not in paths: + paths.append(path) + if 10 < len(paths): + # Let's not open anything if the user selected a lot of directories, + # to avoid accidentally spamming their desktop with dozends of + # new windows or tabs. Ten is a totally arbitrary limit :) + return [] + else: + return paths + + def get_file_items(self, *args): + # Nautilus 3.0 API passes args (window, files), 4.0 API just passes files + files = args[0] if len(args) == 1 else args[1] + paths = self._paths_to_open(files) + if paths: + return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)] + else: + return [] + + def get_background_items(self, *args): + # Nautilus 3.0 API passes args (window, file), 4.0 API just passes file + file = args[0] if len(args) == 1 else args[1] + paths = self._paths_to_open([file]) + if paths: + return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)] + else: + return [] diff --git a/flake.nix b/flake.nix index 83d4af4144..3256c7c15d 100644 --- a/flake.nix +++ b/flake.nix @@ -31,38 +31,81 @@ zig, ... }: - builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let - pkgs-stable = nixpkgs-stable.legacyPackages.${system}; - pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; - in { - devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.13.0"; - wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; - }; + builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} ( + builtins.map ( + system: let + pkgs-stable = nixpkgs-stable.legacyPackages.${system}; + pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; + in { + devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.13.0"; + wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; + }; - packages.${system} = let - mkArgs = optimize: { - inherit optimize; + packages.${system} = let + mkArgs = optimize: { + inherit optimize; - revision = self.shortRev or self.dirtyShortRev or "dirty"; - }; - in rec { - ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); - ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); - ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); + revision = self.shortRev or self.dirtyShortRev or "dirty"; + }; + in rec { + ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); + ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); + ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); - ghostty = ghostty-releasefast; - default = ghostty; - }; + ghostty = ghostty-releasefast; + default = ghostty; + }; - formatter.${system} = pkgs-stable.alejandra; + formatter.${system} = pkgs-stable.alejandra; - # Our supported systems are the same supported systems as the Zig binaries. - }) (builtins.attrNames zig.packages)) + apps.${system} = let + runVM = ( + module: let + vm = import ./nix/vm/create.nix { + inherit system module; + nixpkgs = nixpkgs-stable; + overlay = self.overlays.debug; + }; + program = pkgs-stable.writeShellScript "run-ghostty-vm" '' + SHARED_DIR=$(pwd) + export SHARED_DIR + + ${pkgs-stable.lib.getExe vm.config.system.build.vm} "$@" + ''; + in { + type = "app"; + program = "${program}"; + } + ); + in { + wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix; + wayland-gnome = runVM ./nix/vm/wayland-gnome.nix; + wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix; + x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix; + x11-gnome = runVM ./nix/vm/x11-gnome.nix; + x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix; + x11-xfce = runVM ./nix/vm/x11-xfce.nix; + }; + } + # Our supported systems are the same supported systems as the Zig binaries. + ) (builtins.attrNames zig.packages) + ) // { - overlays.default = final: prev: { - ghostty = self.packages.${prev.system}.default; + overlays = { + default = self.overlays.releasefast; + releasefast = final: prev: { + ghostty = self.packages.${prev.system}.ghostty-releasefast; + }; + debug = final: prev: { + ghostty = self.packages.${prev.system}.ghostty-debug; + }; }; + create-vm = import ./nix/vm/create.nix; + create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; + create-gnome-vm = import ./nix/vm/create-gnome.nix; + create-plasma6-vm = import ./nix/vm/create-plasma6.nix; + create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { diff --git a/include/ghostty.h b/include/ghostty.h index 4b8d409e91..246fb9ed35 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -159,7 +159,7 @@ typedef enum { GHOSTTY_KEY_EQUAL, GHOSTTY_KEY_LEFT_BRACKET, // [ GHOSTTY_KEY_RIGHT_BRACKET, // ] - GHOSTTY_KEY_BACKSLASH, // / + GHOSTTY_KEY_BACKSLASH, // \ // control GHOSTTY_KEY_UP, @@ -559,10 +559,13 @@ typedef struct { // apprt.Action.Key typedef enum { + GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_CLOSE_TAB, GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, + GHOSTTY_ACTION_TOGGLE_MAXIMIZE, GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, @@ -681,10 +684,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 +716,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 42479f0b3a..02c8258cba 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 */; }; @@ -69,8 +69,12 @@ A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; + A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; }; + A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; }; A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -87,6 +91,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 */; }; @@ -99,6 +105,7 @@ C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; }; + CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; }; FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; }; FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; }; /* End PBXBuildFile section */ @@ -108,8 +115,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 = ""; }; @@ -158,10 +165,14 @@ A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; + A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; 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 = ""; }; + A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.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 +188,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; }; @@ -192,6 +205,7 @@ C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = ""; }; C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = ""; }; C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = ""; }; + CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = ""; }; FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = ""; }; FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -261,18 +275,22 @@ A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A5A2A3C92D4445E20033CF96 /* Dock.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, + A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -351,12 +369,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 +419,13 @@ children = ( FC9ABA9B2D0F538D0020D4C8 /* bash-completion */, 29C15B1C2CDC3B2000520DD4 /* bat */, - 55154BDF2B33911F001622DC /* ghostty */, - 552964E52B34A9B400030505 /* vim */, A586167B2B7703CC009BDB1D /* fish */, + 55154BDF2B33911F001622DC /* ghostty */, A5985CE52C33060F00C57AD3 /* man */, + 9351BE8E2D22937F003B3499 /* nvim */, A5A1F8842A489D6800D1E8BC /* terminfo */, + 552964E52B34A9B400030505 /* vim */, FC5218F92D10FFC7004C93E0 /* zsh */, - 9351BE8E2D22937F003B3499 /* nvim */, ); name = Resources; sourceTree = ""; @@ -439,6 +459,7 @@ children = ( A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, + CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, @@ -607,16 +628,20 @@ A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, + CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, 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 */, + A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, @@ -632,12 +657,14 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, + A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, @@ -647,6 +674,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 */, @@ -765,21 +793,22 @@ INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; - INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; - INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; - INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar."; + INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera."; + INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; - INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; - INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network."; + INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily."; + INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information."; INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; - INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; - INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; - INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; - INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; + INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library."; + INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; + INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -934,21 +963,22 @@ INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; - INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; - INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; - INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar."; + INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera."; + INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; - INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; - INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network."; + INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily."; + INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information."; INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; - INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; - INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; - INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; - INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; + INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library."; + INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; + INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -987,21 +1017,22 @@ INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; - INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; - INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; - INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar."; + INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera."; + INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; - INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; - INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network."; + INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily."; + INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information."; INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; - INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; - INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; - INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; - INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; + INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library."; + INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; + INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8564bbb1e4..4b11b68aa6 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -30,11 +30,13 @@ class AppDelegate: NSObject, @IBOutlet private var menuSplitRight: NSMenuItem? @IBOutlet private var menuSplitDown: NSMenuItem? @IBOutlet private var menuClose: NSMenuItem? + @IBOutlet private var menuCloseTab: NSMenuItem? @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? + @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @@ -90,10 +92,8 @@ class AppDelegate: NSObject, return ProcessInfo.processInfo.systemUptime - applicationLaunchTime } - /// Tracks whether the application is currently visible. This can be gamed, i.e. if a user manually - /// brings each window one by one to the front. But at worst its off by one set of toggles and this - /// makes our logic very easy. - private var isVisible: Bool = true + /// Tracks the windows that we hid for toggleVisibility. + private var hiddenWindows: [Weak] = [] /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? = nil @@ -217,15 +217,20 @@ class AppDelegate: NSObject, } func applicationDidBecomeActive(_ notification: Notification) { - guard !applicationHasBecomeActive else { return } - applicationHasBecomeActive = true - - // Let's launch our first window. We only do this if we have no other windows. It - // is possible to have other windows in a few scenarios: - // - if we're opening a URL since `application(_:openFile:)` is called before this. - // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + // If we're back then clear the hidden windows + self.hiddenWindows = [] + + // First launch stuff + if (!applicationHasBecomeActive) { + applicationHasBecomeActive = true + + // Let's launch our first window. We only do this if we have no other windows. It + // is possible to have other windows in a few scenarios: + // - if we're opening a URL since `application(_:openFile:)` is called before this. + // - if we're restoring from persisted state + if terminalManager.windows.count == 0 && derivedConfig.initialWindow { + terminalManager.newWindow() + } } } @@ -346,6 +351,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) @@ -353,6 +359,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) @@ -424,32 +431,42 @@ class AppDelegate: NSObject, // If we have a main window then we don't process any of the keys // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } - + + // If this event as-is would result in a key binding then we send it. + if let app = ghostty.app, + ghostty_app_key_is_binding( + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + // If the key was handled by Ghostty we stop the event chain. If + // the key wasn't handled then we let it fall through and continue + // processing. This is important because some bindings may have no + // affect at this scope. + if (ghostty_app_key( + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { + return nil + } + } + // If this event would be handled by our menu then we do nothing. if let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { return nil } - + // If we reach this point then we try to process the key event // through the Ghostty key mechanism. - + // Ghostty must be loaded 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 } - + return event } @@ -692,21 +709,23 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - // We only care about terminal windows. - for window in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - window.orderOut(nil) - } else { - window.makeKeyAndOrderFront(nil) - } + // If we have focus, then we hide all windows. + if NSApp.isActive { + // We need to keep track of the windows that were visible because we only + // want to bring back these windows if we remove the toggle. + self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) } + NSApp.hide(nil) + return } - // After bringing them all to front we make sure our app is active too. - if !isVisible { - NSApp.activate(ignoringOtherApps: true) - } + // If we're not active, we want to become active + NSApp.activate(ignoringOtherApps: true) - isVisible.toggle() + // Bring all windows to the front. Note: we don't use NSApp.unhide because + // that will unhide ALL hidden windows. We want to only bring forward the + // ones that we hid. + self.hiddenWindows.forEach { $0.value?.orderFrontRegardless() } + self.hiddenWindows = [] } private struct DerivedConfig { diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 7a8e0d894b..4a01d5c62e 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -17,6 +17,7 @@ + @@ -31,6 +32,7 @@ + @@ -154,6 +156,12 @@ + + + + + + @@ -185,6 +193,12 @@ + + + + + + diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 47ee2dfd95..05c8677a76 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -3,6 +3,12 @@ import Cocoa import SwiftUI import GhosttyKit +// This is a Apple's private function that we need to call to get the active space. +@_silgen_name("CGSGetActiveSpace") +func CGSGetActiveSpace(_ cid: Int) -> size_t +@_silgen_name("CGSMainConnectionID") +func CGSMainConnectionID() -> Int + /// Controller for the "quick" terminal. class QuickTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "QuickTerminal" } @@ -18,6 +24,13 @@ class QuickTerminalController: BaseTerminalController { /// application to the front. private var previousApp: NSRunningApplication? = nil + // The active space when the quick terminal was last shown. + private var previousActiveSpace: size_t = 0 + + /// This is set to true of the dock was autohid when the terminal animated in. This lets us + /// know if we have to unhide when the terminal is animated out. + private var hidDock: Bool = false + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig @@ -107,8 +120,28 @@ class QuickTerminalController: BaseTerminalController { self.previousApp = nil } - if (derivedConfig.quickTerminalAutoHide) { - animateOut() + if derivedConfig.quickTerminalAutoHide { + switch derivedConfig.quickTerminalSpaceBehavior { + case .remain: + // If we lose focus on the active space, then we can animate out + animateOut() + + case .move: + let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + if previousActiveSpace == currentActiveSpace { + // We haven't moved spaces. We lost focus to another app on the + // current space. Animate out. + animateOut() + } else { + // We've moved to a different space. Bring the quick terminal back + // into view. + DispatchQueue.main.async { + self.window?.makeKeyAndOrderFront(nil) + } + + self.previousActiveSpace = currentActiveSpace + } + } } } @@ -163,6 +196,9 @@ class QuickTerminalController: BaseTerminalController { } } + // Set previous active space + self.previousActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + // Animate the window in animateWindowIn(window: window, from: position) @@ -192,14 +228,39 @@ class QuickTerminalController: BaseTerminalController { animateWindowOut(window: window, to: position) } + private func hideDock() { + guard !hidDock else { return } + NSApp.acquirePresentationOption(.autoHideDock) + hidDock = true + } + + private func unhideDock() { + guard hidDock else { return } + NSApp.releasePresentationOption(.autoHideDock) + hidDock = false + } + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } // Move our window off screen to the top position.setInitial(in: window, on: screen) + // We need to set our window level to a high value. In testing, only + // popUpMenu and above do what we want. This gets it above the menu bar + // and lets us render off screen. + window.level = .popUpMenu + // Move it to the visible position since animation requires this - window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.makeKeyAndOrderFront(nil) + } + + // If our dock position would conflict with our target location then + // we autohide the dock. + if position.conflictsWithDock(on: screen) { + hideDock() + } // Run the animation that moves our window into the proper place and makes // it visible. @@ -211,8 +272,16 @@ class QuickTerminalController: BaseTerminalController { // There is a very minor delay here so waiting at least an event loop tick // keeps us safe from the view not being on the window. DispatchQueue.main.async { - // If we canceled our animation in we do nothing - guard self.visible else { return } + // If we canceled our animation clean up some state. + guard self.visible else { + self.unhideDock() + return + } + + // After animating in, we reset the window level to a value that + // is above other windows but not as high as popUpMenu. This allows + // things like IME dropdowns to appear properly. + window.level = .floating // Now that the window is visible, sync our appearance. This function // requires the window is visible. @@ -276,6 +345,17 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // If we hid the dock then we unhide it. + unhideDock() + + // If the window isn't on our active space then we don't animate, we just + // hide it. + if !window.isOnActiveSpace { + self.previousApp = nil + window.orderOut(self) + return + } + // We always animate out to whatever screen the window is actually on. guard let screen = window.screen ?? NSScreen.main else { return } @@ -297,6 +377,11 @@ class QuickTerminalController: BaseTerminalController { } } + // We need to set our window level to a high value. In testing, only + // popUpMenu and above do what we want. This gets it above the menu bar + // and lets us render off screen. + window.level = .popUpMenu + NSAnimationContext.runAnimationGroup({ context in context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) @@ -311,23 +396,13 @@ class QuickTerminalController: BaseTerminalController { private func syncAppearance() { guard let window else { return } + // Change the collection behavior of the window depending on the configuration. + window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior + // If our window is not visible, then no need to sync the appearance yet. // Some APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { return } - // Terminals typically operate in sRGB color space and macOS defaults - // to "native" which is typically P3. There is a lot more resources - // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 - // Ghostty defaults to sRGB but this can be overridden. - switch (self.derivedConfig.windowColorspace) { - case "display-p3": - window.colorSpace = .displayP3 - case "srgb": - fallthrough - default: - window.colorSpace = .sRGB - } - // If we have window transparency then set it transparent. Otherwise set it opaque. if (self.derivedConfig.backgroundOpacity < 1) { window.isOpaque = false @@ -396,14 +471,14 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool - let windowColorspace: String + let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let backgroundOpacity: Double init() { self.quickTerminalScreen = .main self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true - self.windowColorspace = "" + self.quickTerminalSpaceBehavior = .move self.backgroundOpacity = 1.0 } @@ -411,7 +486,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide - self.windowColorspace = config.windowColorspace + self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.backgroundOpacity = config.backgroundOpacity } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 3d2a2a0459..7ba124a309 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -69,7 +69,7 @@ enum QuickTerminalPosition : String { finalSize.width = screen.frame.width case .left, .right: - finalSize.height = screen.frame.height + finalSize.height = screen.visibleFrame.height case .center: finalSize.width = screen.frame.width / 2 @@ -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,25 @@ 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) + } + } + + func conflictsWithDock(on screen: NSScreen) -> Bool { + // Screen must have a dock for it to conflict + guard screen.hasDock else { return false } + + // Get the dock orientation for this screen + guard let orientation = Dock.orientation else { return false } + + // Depending on the orientation of the dock, we conflict if our quick terminal + // would potentially "hit" the dock. In the future we should probably consider + // the frame of the quick terminal. + return switch (orientation) { + case .top: self == .top || self == .left || self == .right + case .bottom: self == .bottom || self == .left || self == .right + case .left: self == .top || self == .bottom + case .right: self == .top || self == .bottom } } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift new file mode 100644 index 0000000000..0561aaa188 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift @@ -0,0 +1,36 @@ +import Foundation +import Cocoa + +enum QuickTerminalSpaceBehavior { + case remain + case move + + init?(fromGhosttyConfig string: String) { + switch (string) { + case "move": + self = .move + + case "remain": + self = .remain + + default: + return nil + } + } + + var collectionBehavior: NSWindow.CollectionBehavior { + let commonBehavior: [NSWindow.CollectionBehavior] = [ + .ignoresCycle, + .fullScreenAuxiliary + ] + + switch (self) { + case .move: + // We want this to move the window to the active space. + return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) + case .remain: + // We want this to remain the window in the current space. + return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) + } + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index ed3a7f781c..005808a23d 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -1,6 +1,6 @@ import Cocoa -class QuickTerminalWindow: NSWindow { +class QuickTerminalWindow: NSPanel { // Both of these must be true for windows without decorations to be able to // still become key/main and receive events. override var canBecomeKey: Bool { return true } @@ -26,22 +26,7 @@ class QuickTerminalWindow: NSWindow { // window remains resizable. self.styleMask.remove(.titled) - // We need to set our window level to a high value. In testing, only - // popUpMenu and above do what we want. This gets it above the menu bar - // and lets us render off screen. - self.level = .popUpMenu - - // This plus the level above was what was needed for the animation to work, - // because it gets the window off screen properly. Plus we add some fields - // we just want the behavior of. - self.collectionBehavior = [ - // We want this to be part of every space because it is a singleton. - .canJoinAllSpaces, - - // We don't want to be part of command-tilde - .ignoresCycle, - - // We never support fullscreen - .fullScreenNone] + // We don't want to activate the owning app when quick terminal is triggered. + self.styleMask.insert(.nonactivatingPanel) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 393c6ef4dd..bace20f052 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -389,9 +389,9 @@ class BaseTerminalController: NSWindowController, } switch (request) { - case .osc_52_write: + case let .osc_52_write(pasteboard): guard case .confirm = action else { break } - let pb = NSPasteboard.general + let pb = pasteboard ?? NSPasteboard.general pb.declareTypes([.string], owner: nil) pb.setString(cc.contents, forType: .string) case .osc_52_read, .paste: @@ -452,6 +452,7 @@ class BaseTerminalController: NSWindowController, self.alert = nil switch (response) { case .alertFirstButtonReturn: + alert.window.orderOut(nil) window.close() default: diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2da498e3ac..f24261b9b1 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -22,7 +22,7 @@ class TerminalController: BaseTerminalController { private var restorable: Bool = true /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig + private(set) var derivedConfig: DerivedConfig /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -60,6 +60,11 @@ class TerminalController: BaseTerminalController { selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTab), + name: .ghosttyCloseTab, + object: nil) center.addObserver( self, selector: #selector(ghosttyConfigDidChange(_:)), @@ -310,28 +315,28 @@ class TerminalController: BaseTerminalController { 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, + .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. @@ -340,7 +345,7 @@ class TerminalController: BaseTerminalController { titleBarContainer.isHidden = true } } - + override func windowDidLoad() { super.windowDidLoad() guard let window = window as? TerminalWindow else { return } @@ -361,33 +366,31 @@ class TerminalController: BaseTerminalController { // If window decorations are disabled, remove our title if (!config.windowDecorations) { window.styleMask.remove(.titled) } - // Terminals typically operate in sRGB color space and macOS defaults - // to "native" which is typically P3. There is a lot more resources - // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 - // Ghostty defaults to sRGB but this can be overridden. - switch (config.windowColorspace) { - case "display-p3": - window.colorSpace = .displayP3 - case "srgb": - fallthrough - default: - window.colorSpace = .sRGB - } - // If we have only a single surface (no splits) and that surface requested // an initial size then we set it here now. if case let .leaf(leaf) = surfaceTree { if let initialSize = leaf.surface.initialSize, let screen = window.screen ?? NSScreen.main { - // Setup our frame. We need to first subtract the views frame so that we can - // just get the chrome frame so that we only affect the surface view size. + // Get the current frame of the window var frame = window.frame - frame.size.width -= leaf.surface.frame.size.width - frame.size.height -= leaf.surface.frame.size.height - frame.size.width += min(initialSize.width, screen.frame.width) - frame.size.height += min(initialSize.height, screen.frame.height) - // We have no tabs and we are not a split, so set the initial size of the window. + // Calculate the chrome size (window size minus view size) + let chromeWidth = frame.size.width - leaf.surface.frame.size.width + let chromeHeight = frame.size.height - leaf.surface.frame.size.height + + // Calculate the new width and height, clamping to the screen's size + let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width) + let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height) + + // Update the frame size while keeping the window's position intact + frame.size.width = newWidth + frame.size.height = newHeight + + // Ensure the window doesn't go outside the screen boundaries + frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) + frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) + + // Set the updated frame to the window window.setFrame(frame, display: true) } } @@ -508,7 +511,50 @@ class TerminalController: BaseTerminalController { ghostty.newTab(surface: surface) } - @IBAction override func closeWindow(_ sender: Any) { + private func confirmClose( + window: NSWindow, + messageText: String, + informativeText: String, + completion: @escaping () -> Void + ) { + // If we need confirmation by any, show one confirmation for all windows + // in the tab group. + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) { response in + if response == .alertFirstButtonReturn { + completion() + } + } + } + + @IBAction func closeTab(_ sender: Any?) { + guard let window = window else { return } + guard window.tabGroup != nil else { + // No tabs, no tab group, just perform a normal close. + window.performClose(sender) + return + } + + if surfaceTree?.needsConfirmQuit() ?? false { + confirmClose( + window: window, + messageText: "Close Tab?", + informativeText: "The terminal still has a running process. If you close the tab the process will be killed." + ) { + window.close() + } + return + } + + window.close() + } + + @IBAction override func closeWindow(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. @@ -523,47 +569,34 @@ class TerminalController: BaseTerminalController { } // Check if any windows require close confirmation. - var needsConfirm: Bool = false - for tabWindow in tabGroup.windows { - guard let c = tabWindow.windowController as? TerminalController else { continue } - if (c.surfaceTree?.needsConfirmQuit() ?? false) { - needsConfirm = true - break + let needsConfirm = tabGroup.windows.contains { tabWindow in + guard let controller = tabWindow.windowController as? TerminalController else { + return false } + return controller.surfaceTree?.needsConfirmQuit() ?? false } // If none need confirmation then we can just close all the windows. - if (!needsConfirm) { - for tabWindow in tabGroup.windows { - tabWindow.close() - } - + if !needsConfirm { + tabGroup.windows.forEach { $0.close() } return } - // If we need confirmation by any, show one confirmation for all windows - // in the tab group. - let alert = NSAlert() - alert.messageText = "Close Window?" - alert.informativeText = "All terminal sessions in this window will be terminated." - alert.addButton(withTitle: "Close Window") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for tabWindow in tabGroup.windows { - tabWindow.close() - } - } - }) + confirmClose( + window: window, + messageText: "Close Window?", + informativeText: "All terminal sessions in this window will be terminated." + ) { + tabGroup.windows.forEach { $0.close() } + } } - @IBAction func toggleGhosttyFullScreen(_ sender: Any) { + @IBAction func toggleGhosttyFullScreen(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) } - @IBAction func toggleTerminalInspector(_ sender: Any) { + @IBAction func toggleTerminalInspector(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleTerminalInspector(surface: surface) } @@ -720,6 +753,12 @@ class TerminalController: BaseTerminalController { targetWindow.makeKeyAndOrderFront(nil) } + @objc private func onCloseTab(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree?.contains(view: target) ?? false else { return } + closeTab(self) + } + @objc private func onToggleFullscreen(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } @@ -737,7 +776,7 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - private struct DerivedConfig { + struct DerivedConfig { let backgroundColor: Color let macosTitlebarStyle: String diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 15b5048750..3d4165e915 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -10,7 +10,7 @@ protocol TerminalViewDelegate: AnyObject { /// The title of the terminal should change. func titleDidChange(to: String) - + /// The URL of the pwd should change. func pwdDidChange(to: URL?) @@ -56,15 +56,10 @@ struct TerminalView: View { // The title for our window private var title: String { - var title = "👻" - - if let surfaceTitle = surfaceTitle { - if (surfaceTitle.count > 0) { - title = surfaceTitle - } + if let surfaceTitle, !surfaceTitle.isEmpty { + return surfaceTitle } - - return title + return "👻" } // The pwd of the focused surface as a URL @@ -72,7 +67,7 @@ struct TerminalView: View { guard let surfacePwd, surfacePwd != "" else { return nil } return URL(fileURLWithPath: surfacePwd) } - + var body: some View { switch ghostty.readiness { case .loading: diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 35f629bfda..9d29c193f5 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -115,6 +115,21 @@ class TerminalWindow: NSWindow { } } + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + + // If we are using a hidden titlebar style, the content layout is the + // full frame making it so that it is not draggable. + if let controller = windowController as? TerminalController, + controller.derivedConfig.macosTitlebarStyle == "hidden" { + rect.origin.y = 0 + rect.size.height = self.frame.height + } + return rect + } + // The window theme configuration from Ghostty. This is used to control some // behaviors that don't look quite right in certain situations. var windowTheme: TerminalWindowTheme? @@ -667,12 +682,16 @@ fileprivate class WindowDragView: NSView { // A view that matches the color of selected and unselected tabs in the adjacent tab bar. fileprivate class WindowButtonsBackdropView: NSView { - private let terminalWindow: TerminalWindow + // This must be weak because the window has this view. Otherwise + // a retain cycle occurs. + private weak var terminalWindow: TerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() var isHighlighted: Bool = true { didSet { + guard let terminalWindow else { return } + if isLightTheme { overlayLayer.isHidden = isHighlighted layer?.backgroundColor = .clear diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2d9822d6e5..43c0f245aa 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -62,7 +62,7 @@ extension Ghostty { // uses to interface with the application runtime environment. var runtime_cfg = ghostty_runtime_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), - supports_selection_clipboard: false, + supports_selection_clipboard: true, wakeup_cb: { userdata in App.wakeup(userdata) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, @@ -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() { @@ -336,13 +320,13 @@ extension Ghostty { let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { return } - // We only support the standard clipboard - if (location != GHOSTTY_CLIPBOARD_STANDARD) { + // Get our pasteboard + guard let pasteboard = NSPasteboard.ghostty(location) else { return completeClipboardRequest(surface, data: "", state: state) } // Get our string - let str = NSPasteboard.general.getOpinionatedStringContents() ?? "" + let str = pasteboard.getOpinionatedStringContents() ?? "" completeClipboardRequest(surface, data: str, state: state) } @@ -380,14 +364,12 @@ extension Ghostty { static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { let surface = self.surfaceUserdata(from: userdata) - // We only support the standard clipboard - if (location != GHOSTTY_CLIPBOARD_STANDARD) { return } + guard let pasteboard = NSPasteboard.ghostty(location) else { return } guard let valueStr = String(cString: string!, encoding: .utf8) else { return } if !confirm { - let pb = NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(valueStr, forType: .string) + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(valueStr, forType: .string) return } @@ -396,7 +378,7 @@ extension Ghostty { object: surface, userInfo: [ Notification.ConfirmClipboardStrKey: valueStr, - Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write, + Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard), ] ) } @@ -454,6 +436,9 @@ extension Ghostty { // Action dispatch switch (action.tag) { + case GHOSTTY_ACTION_QUIT: + quit(app) + case GHOSTTY_ACTION_NEW_WINDOW: newWindow(app, target: target) @@ -463,6 +448,9 @@ extension Ghostty { case GHOSTTY_ACTION_NEW_SPLIT: newSplit(app, target: target, direction: action.action.new_split) + case GHOSTTY_ACTION_CLOSE_TAB: + closeTab(app, target: target) + case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen) @@ -559,6 +547,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: @@ -651,6 +654,27 @@ extension Ghostty { } } + private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("close tab does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + NotificationCenter.default.post( + name: .ghosttyCloseTab, + object: surfaceView + ) + + + default: + assertionFailure() + } + } + private static func toggleFullscreen( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b6da076127..9c8042c633 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -132,15 +132,6 @@ extension Ghostty { return v } - var windowColorspace: String { - guard let config = self.config else { return "" } - var v: UnsafePointer? = nil - let key = "window-colorspace" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } - guard let ptr = v else { return "" } - return String(cString: ptr) - } - var windowSaveState: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil @@ -174,11 +165,14 @@ extension Ghostty { } var windowDecorations: Bool { - guard let config = self.config else { return true } - var v = false; + let defaultValue = true + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil let key = "window-decoration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) - return v; + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue } var windowTheme: String? { @@ -345,7 +339,7 @@ extension Ghostty { var backgroundBlurRadius: Int { guard let config = self.config else { return 1 } var v: Int = 0 - let key = "background-blur-radius" + let key = "background-blur" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v; } @@ -375,13 +369,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) @@ -420,6 +425,16 @@ extension Ghostty { _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } + + var quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior { + guard let config = self.config else { return .move } + var v: UnsafePointer? = nil + let key = "quick-terminal-space-behavior" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move } + guard let ptr = v else { return .move } + let str = String(cString: ptr) + return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move + } #endif var resizeOverlay: ResizeOverlay { @@ -542,4 +557,18 @@ extension Ghostty.Config { } } } + + enum WindowDecoration: String { + case none + case client + case server + case auto + + func enabled() -> Bool { + switch self { + case .client, .server, .auto: return true + case .none: return false + } + } + } } diff --git a/macos/Sources/Ghostty/Ghostty.Event.swift b/macos/Sources/Ghostty/Ghostty.Event.swift new file mode 100644 index 0000000000..1cde50ee79 --- /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.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index cc3bef1492..cec1782459 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -205,6 +205,7 @@ extension Ghostty { alert.beginSheetModal(for: window, completionHandler: { response in switch (response) { case .alertFirstButtonReturn: + alert.window.orderOut(nil) node = nil default: diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift new file mode 100644 index 0000000000..4118cd94d1 --- /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 d091002123..71fac4a993 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -159,7 +159,7 @@ extension Ghostty { case osc_52_read /// An application is attempting to write to the clipboard using OSC 52 - case osc_52_write + case osc_52_write(OSPasteboard?) /// The text to show in the clipboard confirmation prompt for a given request type func text() -> String { @@ -188,7 +188,7 @@ extension Ghostty { case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: return .osc_52_read case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: - return .osc_52_write + return .osc_52_write(nil) default: return nil } @@ -236,6 +236,9 @@ extension Notification.Name { /// Goto tab. Has tab index in the userinfo. static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue + + /// Close tab + static let ghosttyCloseTab = Notification.Name("com.mitchellh.ghostty.closeTab") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4abf87c7fa..beae503314 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -92,22 +92,6 @@ extension Ghostty { windowFocus = false } } - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - providers.forEach { provider in - _ = provider.loadObject(ofClass: URL.self) { url, _ in - guard let url = url else { return } - let path = Shell.escape(url.path) - DispatchQueue.main.async { - surfaceView.insertText( - path, - replacementRange: NSMakeRange(0, 0) - ) - } - } - } - - return true - } #endif // If our geo size changed then we show the resize overlay as configured. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2cac4a0dd8..f5cb93580d 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import CoreText import UserNotifications @@ -12,7 +13,14 @@ extension Ghostty { // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go // to the app level and it is set from there. - @Published private(set) var title: String = "👻" + @Published private(set) var title: String = "" { + didSet { + if !title.isEmpty { + titleFallbackTimer?.invalidate() + titleFallbackTimer = nil + } + } + } // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. @@ -113,6 +121,12 @@ extension Ghostty { // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? + // A timer to fallback to ghost emoji if no title is set within the grace period + private var titleFallbackTimer: 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 } @@ -136,6 +150,13 @@ extension Ghostty { // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) + // Set a timer to show the ghost emoji after 500ms if no title is set + titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + if let self = self, self.title.isEmpty { + self.title = "👻" + } + } + // Before we initialize the surface we want to register our notifications // so there is no window where we can't receive them. let center = NotificationCenter.default @@ -170,6 +191,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) @@ -201,6 +231,9 @@ extension Ghostty { ghostty_surface_set_color_scheme(surface, scheme) } + + // The UTTypes that can be dragged onto this view. + registerForDraggedTypes(Array(Self.dropTypes)) } required init?(coder: NSCoder) { @@ -212,6 +245,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 +394,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,16 +826,51 @@ 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. var handled: Bool = false if let list = keyTextAccumulator, list.count > 0 { handled = true - for text in list { - keyAction(action, event: event, text: text) + + // This is a hack. libghostty on macOS treats ctrl input as not having + // text because some keyboard layouts generate bogus characters for + // ctrl+key. libghostty can't tell this is from an IM keyboard giving + // us direct values. So, we just remove control. + var modifierFlags = event.modifierFlags + modifierFlags.remove(.control) + if let keyTextEvent = NSEvent.keyEvent( + with: .keyDown, + location: event.locationInWindow, + modifierFlags: modifierFlags, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) { + for text in list { + _ = keyAction(action, event: keyTextEvent, text: text) + } } } @@ -783,38 +880,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 +940,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 +971,7 @@ extension Ghostty { keyCode: event.keyCode ) - self.keyDown(with: newEvent!) + self.keyDown(with: finalEvent!) return true } @@ -867,6 +986,9 @@ extension Ghostty { default: return } + // If we're in the middle of a preedit, don't do anything with mods. + if hasMarkedText() { return } + // The keyAction function will do this AGAIN below which sucks to repeat // but this is super cheap and flagsChanged isn't that common. let mods = Ghostty.ghosttyMods(event.modifierFlags) @@ -897,45 +1019,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) } } @@ -1053,6 +1168,14 @@ extension Ghostty { } } + @IBAction func pasteSelection(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "paste_from_selection" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" @@ -1374,3 +1497,78 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { return true } } + +// MARK: NSMenuItemValidation + +extension Ghostty.SurfaceView: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(pasteSelection): + let pb = NSPasteboard.ghosttySelection + guard let str = pb.getOpinionatedStringContents() else { return false } + return !str.isEmpty + + default: + return true + } + } +} + +// MARK: NSDraggingDestination + +extension Ghostty.SurfaceView { + static let dropTypes: Set = [ + .string, + .fileURL, + .URL + ] + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard let types = sender.draggingPasteboard.types else { return [] } + + // If the dragging object contains none of our types then we return none. + // This shouldn't happen because AppKit should guarantee that we only + // receive types we registered for but its good to check. + if Set(types).isDisjoint(with: Self.dropTypes) { + return [] + } + + // We use copy to get the proper icon + return .copy + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let pb = sender.draggingPasteboard + + let content: String? + if let url = pb.string(forType: .URL) { + // URLs first, they get escaped as-is. + content = Ghostty.Shell.escape(url) + } else if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL], + urls.count > 0 { + // File URLs next. They get escaped individually and then joined by a + // space if there are multiple. + content = urls + .map { Ghostty.Shell.escape($0.path) } + .joined(separator: " ") + } else if let str = pb.string(forType: .string) { + // Strings are not escaped because they may be copy/pasting a + // command they want to execute. + content = str + } else { + content = nil + } + + if let content { + DispatchQueue.main.async { + self.insertText( + content, + replacementRange: NSMakeRange(0, 0) + ) + } + return true + } + + return false + } +} diff --git a/macos/Sources/Helpers/CrossKit.swift b/macos/Sources/Helpers/CrossKit.swift index 5a69b45a37..690e811bb5 100644 --- a/macos/Sources/Helpers/CrossKit.swift +++ b/macos/Sources/Helpers/CrossKit.swift @@ -10,6 +10,7 @@ import AppKit typealias OSView = NSView typealias OSColor = NSColor typealias OSSize = NSSize +typealias OSPasteboard = NSPasteboard protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { associatedtype OSViewType: NSView @@ -34,6 +35,7 @@ import UIKit typealias OSView = UIView typealias OSColor = UIColor typealias OSSize = CGSize +typealias OSPasteboard = UIPasteboard protocol OSViewRepresentable: UIViewRepresentable { associatedtype OSViewType: UIView diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Dock.swift new file mode 100644 index 0000000000..70fb904d95 --- /dev/null +++ b/macos/Sources/Helpers/Dock.swift @@ -0,0 +1,33 @@ +import Cocoa + +// Private API to get Dock location +@_silgen_name("CoreDockGetOrientationAndPinning") +func CoreDockGetOrientationAndPinning( + _ outOrientation: UnsafeMutablePointer, + _ outPinning: UnsafeMutablePointer) + +// Private API to get the current Dock auto-hide state +@_silgen_name("CoreDockGetAutoHideEnabled") +func CoreDockGetAutoHideEnabled() -> Bool + +enum DockOrientation: Int { + case top = 1 + case bottom = 2 + case left = 3 + case right = 4 +} + +class Dock { + /// Returns the orientation of the dock or nil if it can't be determined. + static var orientation: DockOrientation? { + var orientation: Int32 = 0 + var pinning: Int32 = 0 + CoreDockGetOrientationAndPinning(&orientation, &pinning) + return .init(rawValue: Int(orientation)) ?? nil + } + + /// Returns true if the dock has auto-hide enabled. + static var autoHideEnabled: Bool { + return CoreDockGetAutoHideEnabled() + } +} diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index a16f329f88..320eca0134 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -307,21 +307,21 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // MARK: Dock private func hideDock() { - NSApp.presentationOptions.insert(.autoHideDock) + NSApp.acquirePresentationOption(.autoHideDock) } private func unhideDock() { - NSApp.presentationOptions.remove(.autoHideDock) + NSApp.releasePresentationOption(.autoHideDock) } // MARK: Menu func hideMenu() { - NSApp.presentationOptions.insert(.autoHideMenuBar) + NSApp.acquirePresentationOption(.autoHideMenuBar) } func unhideMenu() { - NSApp.presentationOptions.remove(.autoHideMenuBar) + NSApp.releasePresentationOption(.autoHideMenuBar) } /// The state that must be saved for non-native fullscreen to exit fullscreen. diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift new file mode 100644 index 0000000000..8e573f4953 --- /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/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift new file mode 100644 index 0000000000..0580cd5fc7 --- /dev/null +++ b/macos/Sources/Helpers/NSApplication+Extension.swift @@ -0,0 +1,31 @@ +import Cocoa + +extension NSApplication { + private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:] + + /// Add a presentation option to the application and main a reference count so that and equal + /// number of pops is required to disable it. This is useful so that multiple classes can affect global + /// app state without overriding others. + func acquirePresentationOption(_ option: NSApplication.PresentationOptions.Element) { + Self.presentationOptionCounts[option, default: 0] += 1 + presentationOptions.insert(option) + } + + /// See acquirePresentationOption + func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) { + guard let value = Self.presentationOptionCounts[option] else { return } + guard value > 0 else { return } + if (value == 1) { + presentationOptions.remove(option) + Self.presentationOptionCounts.removeValue(forKey: option) + } else { + Self.presentationOptionCounts[option] = value - 1 + } + } +} + +extension NSApplication.PresentationOptions.Element: @retroactive Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +} diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index b1755fea01..11815fbc87 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -1,17 +1,39 @@ import AppKit +import GhosttyKit extension NSPasteboard { + /// The pasteboard to used for Ghostty selection. + static var ghosttySelection: NSPasteboard = { + NSPasteboard(name: .init("com.mitchellh.ghostty.selection")) + }() + /// Gets the contents of the pasteboard as a string following a specific set of semantics. /// Does these things in order: - /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one. + /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped. /// - Tries to get any string from the pasteboard. /// If all of the above fail, returns None. func getOpinionatedStringContents() -> String? { - if let file = self.string(forType: .fileURL) { - if let path = NSURL(string: file)?.path { - return path - } + if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], + urls.count > 0 { + return urls + .map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString } + .joined(separator: " ") } + return self.string(forType: .string) } + + /// The pasteboard for the Ghostty enum type. + static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { + switch (clipboard) { + case GHOSTTY_CLIPBOARD_STANDARD: + return Self.general + + case GHOSTTY_CLIPBOARD_SELECTION: + return Self.ghosttySelection + + default: + return nil + } + } } diff --git a/macos/Sources/Helpers/Weak.swift b/macos/Sources/Helpers/Weak.swift new file mode 100644 index 0000000000..d5f784844f --- /dev/null +++ b/macos/Sources/Helpers/Weak.swift @@ -0,0 +1,9 @@ +/// A wrapper that holds a weak reference to an object. This lets us create native containers +/// of weak references. +class Weak { + weak var value: T? + + init(_ value: T) { + self.value = value + } +} diff --git a/nix/devShell.nix b/nix/devShell.nix index 5e86427fec..c52afb6c0c 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 78d2e2fddf..2f7825a562 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. @@ -49,7 +53,6 @@ fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( lib.fileset.unions [ ../dist/linux - ../conformance ../images ../include ../pkg @@ -114,14 +117,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 +150,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/vm/common-cinnamon.nix b/nix/vm/common-cinnamon.nix new file mode 100644 index 0000000000..dabe5e7018 --- /dev/null +++ b/nix/vm/common-cinnamon.nix @@ -0,0 +1,18 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services.xserver = { + displayManager = { + lightdm = { + enable = true; + }; + }; + desktopManager = { + cinnamon = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix new file mode 100644 index 0000000000..0c2bef150c --- /dev/null +++ b/nix/vm/common-gnome.nix @@ -0,0 +1,136 @@ +{ + config, + lib, + pkgs, + ... +}: { + imports = [ + ./common.nix + ]; + + services.xserver = { + displayManager = { + gdm = { + enable = true; + autoSuspend = false; + }; + }; + desktopManager = { + gnome = { + enable = true; + }; + }; + }; + + environment.systemPackages = [ + pkgs.gnomeExtensions.no-overview + ]; + + environment.gnome.excludePackages = with pkgs; [ + atomix + baobab + cheese + epiphany + evince + file-roller + geary + gnome-backgrounds + gnome-calculator + gnome-calendar + gnome-clocks + gnome-connections + gnome-contacts + gnome-disk-utility + gnome-extension-manager + gnome-logs + gnome-maps + gnome-music + gnome-photos + gnome-software + gnome-system-monitor + gnome-text-editor + gnome-themes-extra + gnome-tour + gnome-user-docs + gnome-weather + hitori + iagno + loupe + nautilus + orca + seahorse + simple-scan + snapshot + sushi + tali + totem + yelp + ]; + + programs.dconf = { + enable = true; + profiles.user.databases = [ + { + settings = with lib.gvariant; { + "org/gnome/desktop/background" = { + picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-options = "centered"; + primary-color = "#000000000000"; + secondary-color = "#000000000000"; + }; + "org/gnome/desktop/interface" = { + color-scheme = "prefer-dark"; + }; + "org/gnome/desktop/notifications" = { + show-in-lock-screen = false; + }; + "org/gnome/desktop/screensaver" = { + lock-enabled = false; + picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-options = "centered"; + primary-color = "#000000000000"; + secondary-color = "#000000000000"; + }; + "org/gnome/desktop/session" = { + idle-delay = mkUint32 0; + }; + "org/gnome/shell" = { + disable-user-extensions = false; + enabled-extensions = builtins.map (x: x.extensionUuid) ( + lib.filter (p: p ? extensionUuid) config.environment.systemPackages + ); + }; + }; + } + ]; + }; + + programs.geary.enable = false; + + services.gnome = { + gnome-browser-connector.enable = false; + gnome-initial-setup.enable = false; + gnome-online-accounts.enable = false; + gnome-remote-desktop.enable = false; + rygel.enable = false; + }; + + system.activationScripts = { + face = { + text = '' + mkdir -p /var/lib/AccountsService/{icons,users} + + cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty + + echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/users/ghostty + chmod 0600 /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/icons/ghostty + chmod 0444 /var/lib/AccountsService/icons/ghostty + ''; + }; + }; +} diff --git a/nix/vm/common-plasma6.nix b/nix/vm/common-plasma6.nix new file mode 100644 index 0000000000..e5c9bd4d87 --- /dev/null +++ b/nix/vm/common-plasma6.nix @@ -0,0 +1,21 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services = { + displayManager = { + sddm = { + enable = true; + wayland = { + enable = true; + }; + }; + }; + desktopManager = { + plasma6 = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common-xfce.nix b/nix/vm/common-xfce.nix new file mode 100644 index 0000000000..12a20d8d86 --- /dev/null +++ b/nix/vm/common-xfce.nix @@ -0,0 +1,18 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services.xserver = { + displayManager = { + lightdm = { + enable = true; + }; + }; + desktopManager = { + xfce = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common.nix b/nix/vm/common.nix new file mode 100644 index 0000000000..eefd7c1c03 --- /dev/null +++ b/nix/vm/common.nix @@ -0,0 +1,83 @@ +{pkgs, ...}: { + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + + documentation.nixos.enable = false; + + networking.hostName = "ghostty"; + networking.domain = "mitchellh.com"; + + virtualisation.vmVariant = { + virtualisation.memorySize = 2048; + }; + + nix = { + settings = { + trusted-users = [ + "root" + "ghostty" + ]; + }; + extraOptions = '' + experimental-features = nix-command flakes + ''; + }; + + users.mutableUsers = false; + + users.groups.ghostty = {}; + + users.users.ghostty = { + description = "Ghostty"; + group = "ghostty"; + extraGroups = ["wheel"]; + isNormalUser = true; + initialPassword = "ghostty"; + }; + + environment.etc = { + "xdg/autostart/com.mitchellh.ghostty.desktop" = { + source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; + }; + }; + + environment.systemPackages = [ + pkgs.kitty + pkgs.fish + pkgs.ghostty + pkgs.helix + pkgs.neovim + pkgs.xterm + pkgs.zsh + ]; + + security.polkit = { + enable = true; + }; + + services.dbus = { + enable = true; + }; + + services.displayManager = { + autoLogin = { + user = "ghostty"; + }; + }; + + services.libinput = { + enable = true; + }; + + services.qemuGuest = { + enable = true; + }; + + services.spice-vdagentd = { + enable = true; + }; + + services.xserver = { + enable = true; + }; +} diff --git a/nix/vm/create-cinnamon.nix b/nix/vm/create-cinnamon.nix new file mode 100644 index 0000000000..a9d9e44d77 --- /dev/null +++ b/nix/vm/create-cinnamon.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + module, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay module uid gid; + common = ./common-cinnamon.nix; +} diff --git a/nix/vm/create-gnome.nix b/nix/vm/create-gnome.nix new file mode 100644 index 0000000000..bcd31f2b63 --- /dev/null +++ b/nix/vm/create-gnome.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + module, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay module uid gid; + common = ./common-gnome.nix; +} diff --git a/nix/vm/create-plasma6.nix b/nix/vm/create-plasma6.nix new file mode 100644 index 0000000000..ede5371f34 --- /dev/null +++ b/nix/vm/create-plasma6.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + module, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay module uid gid; + common = ./common-plasma6.nix; +} diff --git a/nix/vm/create-xfce.nix b/nix/vm/create-xfce.nix new file mode 100644 index 0000000000..d1789472d4 --- /dev/null +++ b/nix/vm/create-xfce.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + module, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay module uid gid; + common = ./common-xfce.nix; +} diff --git a/nix/vm/create.nix b/nix/vm/create.nix new file mode 100644 index 0000000000..f8fe8500da --- /dev/null +++ b/nix/vm/create.nix @@ -0,0 +1,42 @@ +{ + system, + nixpkgs, + overlay, + module, + common ? ./common.nix, + uid ? 1000, + gid ? 1000, +}: let + pkgs = import nixpkgs { + inherit system; + overlays = [ + overlay + ]; + }; +in + nixpkgs.lib.nixosSystem { + system = builtins.replaceStrings ["darwin"] ["linux"] system; + modules = [ + { + virtualisation.vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + + nixpkgs.overlays = [ + overlay + ]; + + users.groups.ghostty = { + gid = gid; + }; + + users.users.ghostty = { + uid = uid; + }; + + system.stateVersion = nixpkgs.lib.trivial.release; + } + common + module + ]; + } diff --git a/nix/vm/wayland-cinnamon.nix b/nix/vm/wayland-cinnamon.nix new file mode 100644 index 0000000000..531c882b64 --- /dev/null +++ b/nix/vm/wayland-cinnamon.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-cinnamon.nix + ]; + + services.displayManager.defaultSession = "cinnamon-wayland"; +} diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix new file mode 100644 index 0000000000..eb277d5d1b --- /dev/null +++ b/nix/vm/wayland-gnome.nix @@ -0,0 +1,9 @@ +{...}: { + imports = [ + ./common-gnome.nix + ]; + + services.displayManager = { + defaultSession = "gnome"; + }; +} diff --git a/nix/vm/wayland-plasma6.nix b/nix/vm/wayland-plasma6.nix new file mode 100644 index 0000000000..6e5a253b89 --- /dev/null +++ b/nix/vm/wayland-plasma6.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./common-plasma6.nix + ]; + services.displayManager.defaultSession = "plasma"; +} diff --git a/nix/vm/x11-cinnamon.nix b/nix/vm/x11-cinnamon.nix new file mode 100644 index 0000000000..636f235a2c --- /dev/null +++ b/nix/vm/x11-cinnamon.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-cinnamon.nix + ]; + + services.displayManager.defaultSession = "cinnamon"; +} diff --git a/nix/vm/x11-gnome.nix b/nix/vm/x11-gnome.nix new file mode 100644 index 0000000000..1994aea82f --- /dev/null +++ b/nix/vm/x11-gnome.nix @@ -0,0 +1,9 @@ +{...}: { + imports = [ + ./common-gnome.nix + ]; + + services.displayManager = { + defaultSession = "gnome-xorg"; + }; +} diff --git a/nix/vm/x11-plasma6.nix b/nix/vm/x11-plasma6.nix new file mode 100644 index 0000000000..7818a80ca1 --- /dev/null +++ b/nix/vm/x11-plasma6.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./common-plasma6.nix + ]; + services.displayManager.defaultSession = "plasmax11"; +} diff --git a/nix/vm/x11-xfce.nix b/nix/vm/x11-xfce.nix new file mode 100644 index 0000000000..71eb87f2fb --- /dev/null +++ b/nix/vm/x11-xfce.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-xfce.nix + ]; + + services.displayManager.defaultSession = "xfce"; +} diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 60e9e58a40..66b8eb8b6d 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY=" +"sha256-Bjy31evaKgpRX1mGwAFkai44eiiorTV1gW3VdP9Ins8=" diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig index fb4dbfb362..96c4ffe4a6 100644 --- a/pkg/fontconfig/build.zig +++ b/pkg/fontconfig/build.zig @@ -56,7 +56,7 @@ pub fn build(b: *std.Build) !void { } } -pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { const target = options.target; const optimize = options.optimize; @@ -186,7 +186,7 @@ pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*st _ = b.systemIntegrationOption("freetype", .{}); // So it shows up in help if (freetype_enabled) { if (b.systemIntegrationOption("freetype", .{})) { - lib.linkSystemLibrary2("freetype", dynamic_link_opts); + lib.linkSystemLibrary2("freetype2", dynamic_link_opts); } else { const freetype_dep = b.dependency( "freetype", diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index 46458c15c3..6ff4f4340d 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 983ec9ffca..5b7d864081 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -43,7 +43,11 @@ pub fn build(b: *std.Build) !void { { var it = module.import_table.iterator(); while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); - test_exe.linkLibrary(freetype.artifact("freetype")); + if (b.systemIntegrationOption("freetype", .{})) { + test_exe.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + test_exe.linkLibrary(freetype.artifact("freetype")); + } const tests_run = b.addRunArtifact(test_exe); const test_step = b.step("test", "Run tests"); test_step.dependOn(&tests_run.step); @@ -67,7 +71,7 @@ pub fn build(b: *std.Build) !void { } } -pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { const target = options.target; const optimize = options.optimize; diff --git a/pkg/macos/graphics/color_space.zig b/pkg/macos/graphics/color_space.zig index 459f063029..16960591b2 100644 --- a/pkg/macos/graphics/color_space.zig +++ b/pkg/macos/graphics/color_space.zig @@ -18,9 +18,72 @@ pub const ColorSpace = opaque { ) orelse Allocator.Error.OutOfMemory; } + pub fn createNamed(name: Name) Allocator.Error!*ColorSpace { + return @as( + ?*ColorSpace, + @ptrFromInt(@intFromPtr(c.CGColorSpaceCreateWithName(name.cfstring()))), + ) orelse Allocator.Error.OutOfMemory; + } + pub fn release(self: *ColorSpace) void { c.CGColorSpaceRelease(@ptrCast(self)); } + + pub const Name = enum { + /// This color space uses the DCI P3 primaries, a D65 white point, and + /// the sRGB transfer function. + displayP3, + /// The Display P3 color space with a linear transfer function and + /// extended-range values. + extendedLinearDisplayP3, + /// The sRGB colorimetry and non-linear transfer function are specified + /// in IEC 61966-2-1. + sRGB, + /// This color space has the same colorimetry as `sRGB`, but uses a + /// linear transfer function. + linearSRGB, + /// This color space has the same colorimetry as `sRGB`, but you can + /// encode component values below `0.0` and above `1.0`. Negative values + /// are encoded as the signed reflection of the original encoding + /// function, as shown in the formula below: + /// ``` + /// extendedTransferFunction(x) = sign(x) * sRGBTransferFunction(abs(x)) + /// ``` + extendedSRGB, + /// This color space has the same colorimetry as `sRGB`; in addition, + /// you may encode component values below `0.0` and above `1.0`. + extendedLinearSRGB, + /// ... + genericGrayGamma2_2, + /// ... + linearGray, + /// This color space has the same colorimetry as `genericGrayGamma2_2`, + /// but you can encode component values below `0.0` and above `1.0`. + /// Negative values are encoded as the signed reflection of the + /// original encoding function, as shown in the formula below: + /// ``` + /// extendedGrayTransferFunction(x) = sign(x) * gamma22Function(abs(x)) + /// ``` + extendedGray, + /// This color space has the same colorimetry as `linearGray`; in + /// addition, you may encode component values below `0.0` and above `1.0`. + extendedLinearGray, + + fn cfstring(self: Name) c.CFStringRef { + return switch (self) { + .displayP3 => c.kCGColorSpaceDisplayP3, + .extendedLinearDisplayP3 => c.kCGColorSpaceExtendedLinearDisplayP3, + .sRGB => c.kCGColorSpaceSRGB, + .extendedSRGB => c.kCGColorSpaceExtendedSRGB, + .linearSRGB => c.kCGColorSpaceLinearSRGB, + .extendedLinearSRGB => c.kCGColorSpaceExtendedLinearSRGB, + .genericGrayGamma2_2 => c.kCGColorSpaceGenericGrayGamma2_2, + .extendedGray => c.kCGColorSpaceExtendedGray, + .linearGray => c.kCGColorSpaceLinearGray, + .extendedLinearGray => c.kCGColorSpaceExtendedLinearGray, + }; + } + }; }; test { diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index 886bfc5bd2..da7c906744 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -53,7 +53,7 @@ pub fn build(b: *std.Build) !void { } } -pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { const target = options.target; const optimize = options.optimize; diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 4cd1cf9f9b..a9fa5d4fe3 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -162,4 +162,26 @@ pub const Binding = struct { data, ); } + + pub fn copySubImage2D( + b: Binding, + level: c.GLint, + xoffset: c.GLint, + yoffset: c.GLint, + x: c.GLint, + y: c.GLint, + width: c.GLsizei, + height: c.GLsizei, + ) !void { + glad.context.CopyTexSubImage2D.?( + @intFromEnum(b.target), + level, + xoffset, + yoffset, + x, + y, + width, + height + ); + } }; diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig index 69628f582f..c07278eed3 100644 --- a/pkg/wuffs/src/jpeg.zig +++ b/pkg/wuffs/src/jpeg.zig @@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { c.wuffs_base__pixel_config__set( &image_config.pixcfg, - c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL, c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, width, height, @@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { 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, diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index b85e4d7474..1f37bb375a 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { c.wuffs_base__pixel_config__set( &image_config.pixcfg, - c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL, c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, width, height, @@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { try check(log, &status); } - var frame_config: c.wuffs_base__frame_config = undefined; - { - const status = c.wuffs_png__decoder__decode_frame_config( - decoder, - &frame_config, - &source_buffer, - ); - try check(log, &status); - } - { const status = c.wuffs_png__decoder__decode_frame( decoder, diff --git a/src/App.zig b/src/App.zig index 279c4e497d..a6b54db232 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/Surface.zig b/src/Surface.zig index 389e7f7e46..d9a985aa79 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -569,12 +569,16 @@ pub fn init( // Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app // but is otherwise somewhat arbitrary. + + const min_window_width_cells: u32 = 10; + const min_window_height_cells: u32 = 4; + try rt_app.performAction( .{ .surface = self }, .size_limit, .{ - .min_width = size.cell.width * 10, - .min_height = size.cell.height * 4, + .min_width = size.cell.width * min_window_width_cells, + .min_height = size.cell.height * min_window_height_cells, // No max: .max_width = 0, .max_height = 0, @@ -617,8 +621,8 @@ pub fn init( // start messing with the window. if (config.@"window-height" > 0 and config.@"window-width" > 0) init: { const scale = rt_surface.getContentScale() catch break :init; - const height = @max(config.@"window-height" * cell_size.height, 480); - const width = @max(config.@"window-width" * cell_size.width, 640); + const height = @max(config.@"window-height", min_window_height_cells) * cell_size.height; + const width = @max(config.@"window-width", min_window_width_cells) * cell_size.width; const width_f32: f32 = @floatFromInt(width); const height_f32: f32 = @floatFromInt(height); @@ -1037,6 +1041,9 @@ fn mouseRefreshLinks( pos_vp: terminal.point.Coordinate, over_link: bool, ) !void { + // If the position is outside our viewport, do nothing + if (pos.x < 0 or pos.y < 0) return; + self.mouse.link_point = pos_vp; if (try self.linkAtPos(pos)) |link| { @@ -1312,8 +1319,8 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; const x: f64 = x: { - // Simple x * cell width gives the top-left corner - var x: f64 = @floatFromInt(cursor.x * self.size.cell.width); + // Simple x * cell width gives the top-left corner, then add padding offset + var x: f64 = @floatFromInt(cursor.x * self.size.cell.width + self.size.padding.left); // We want the midpoint x += @as(f64, @floatFromInt(self.size.cell.width)) / 2; @@ -1325,8 +1332,8 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { }; const y: f64 = y: { - // Simple x * cell width gives the top-left corner - var y: f64 = @floatFromInt(cursor.y * self.size.cell.height); + // Simple y * cell height gives the top-left corner, then add padding offset + var y: f64 = @floatFromInt(cursor.y * self.size.cell.height + self.size.padding.top); // We want the bottom y += @floatFromInt(self.size.cell.height); @@ -1587,6 +1594,15 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + // We clear our selection when ANY OF: + // 1. We have an existing preedit + // 2. We have preedit text + if (self.renderer_state.preedit != null or + preedit_ != null) + { + self.setSelection(null) catch {}; + } + // We always clear our prior preedit if (self.renderer_state.preedit) |p| { self.alloc.free(p.codepoints); @@ -1637,6 +1653,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( @@ -3525,22 +3566,21 @@ fn dragLeftClickTriple( const screen = &self.io.terminal.screen; const click_pin = self.mouse.left_click_pin.?.*; - // Get the word under our current point. If there isn't a word, do nothing. - const word = screen.selectLine(.{ .pin = drag_pin }) orelse return; + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. + const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; - // Get our selection to grow it. If we don't have a selection, start it now. - // We may not have a selection if we started our dbl-click in an area - // that had no data, then we dragged our mouse into an area with data. - var sel = screen.selectLine(.{ .pin = click_pin }) orelse { - try self.setSelection(word); - return; - }; + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then + // we select the blank line. + const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse + screen.selectLine(.{ .pin = click_pin, .whitespace = null }); - // Grow our selection + var sel = sel_ orelse return; if (drag_pin.before(click_pin)) { - sel.startPtr().* = word.start(); + sel.startPtr().* = line.start(); } else { - sel.endPtr().* = word.end(); + sel.endPtr().* = line.end(); } try self.setSelection(sel); } @@ -3907,6 +3947,33 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool return false; }, + .copy_url_to_clipboard => { + // If the mouse isn't over a link, nothing we can do. + if (!self.mouse.over_link) return false; + + const pos = try self.rt_surface.getCursorPos(); + if (try self.linkAtPos(pos)) |link_info| { + // Get the URL text from selection + const url_text = (self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = link_info[1], + .trim = self.config.clipboard_trim_trailing_spaces, + })) catch |err| { + log.err("error reading url string err={}", .{err}); + return false; + }; + defer self.alloc.free(url_text); + + self.rt_surface.setClipboardString(url_text, .standard, false) catch |err| { + log.err("error copying url to clipboard err={}", .{err}); + return true; + }; + + return true; + } + + return false; + }, + .paste_from_clipboard => try self.startClipboardRequest( .standard, .{ .paste = {} }, @@ -4032,6 +4099,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .close_tab => try self.rt_app.performAction( + .{ .surface = self }, + .close_tab, + {}, + ), + inline .previous_tab, .next_tab, .last_tab, @@ -4106,6 +4179,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_maximize => try self.rt_app.performAction( + .{ .surface = self }, + .toggle_maximize, + {}, + ), + .toggle_fullscreen => try self.rt_app.performAction( .{ .surface = self }, .toggle_fullscreen, @@ -4231,6 +4310,7 @@ fn closingAction(action: input.Binding.Action) bool { return switch (action) { .close_surface, .close_window, + .close_tab, => true, else => false, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index de6758d6c9..fe2039e523 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, @@ -79,6 +82,9 @@ pub const Action = union(Key) { /// the tab should be opened in a new window. new_tab, + /// Closes the tab belonging to the currently focused split. + close_tab, + /// Create a new split. The value determines the location of the split /// relative to the target. new_split: SplitDirection, @@ -86,6 +92,9 @@ pub const Action = union(Key) { /// Close all open windows. close_all_windows, + /// Toggle maximized window state. + toggle_maximize, + /// Toggle fullscreen mode. toggle_fullscreen: Fullscreen, @@ -219,10 +228,13 @@ pub const Action = union(Key) { /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { + quit, new_window, new_tab, + close_tab, new_split, close_all_windows, + toggle_maximize, toggle_fullscreen, toggle_tab_overview, toggle_window_decorations, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b42225906a..3c3723d4f6 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; @@ -199,6 +199,11 @@ pub const App = struct { // This logic only applies to macOS. if (comptime builtin.os.tag != .macos) break :event_text event.text; + // If we're in a preedit state then we allow it through. This + // allows ctrl sequences that affect IME to work. For example, + // Ctrl+H deletes a character with Japanese input. + if (event.composing) break :event_text event.text; + // If the modifiers are ONLY "control" then we never process // the event text because we want to do our own translation so // we can handle ctrl+c, ctrl+z, etc. @@ -243,7 +248,7 @@ pub const App = struct { result.text, ) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } } else { @@ -251,7 +256,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 +340,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 +350,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. @@ -618,7 +638,7 @@ pub const Surface = struct { .y = @floatCast(opts.scale_factor), }, .size = .{ .width = 800, .height = 600 }, - .cursor_pos = .{ .x = 0, .y = 0 }, + .cursor_pos = .{ .x = -1, .y = -1 }, .keymap_state = .{}, }; @@ -1332,10 +1352,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 +1391,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,14 +1633,36 @@ 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 @@ -1895,7 +1958,7 @@ pub const CAPI = struct { _ = CGSSetWindowBackgroundBlurRadius( CGSDefaultConnectionForThread(), nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), - @intCast(config.@"background-blur-radius"), + @intCast(config.@"background-blur".cval()), ); } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 3fbef0f229..686a70ddb6 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, @@ -210,6 +218,7 @@ pub const App = struct { .toggle_split_zoom, .present_terminal, .close_all_windows, + .close_tab, .toggle_tab_overview, .toggle_window_decorations, .toggle_quick_terminal, @@ -228,6 +237,7 @@ pub const App = struct { .color_change, .pwd, .config_change, + .toggle_maximize, => log.info("unimplemented action={}", .{action}), } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b36c5b96a7..df74cefb29 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -36,7 +36,7 @@ const c = @import("c.zig").c; const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); -const x11 = @import("x11.zig"); +const winproto = @import("winproto.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -49,6 +49,9 @@ config: Config, app: *c.GtkApplication, ctx: *c.GMainContext, +/// State and logic for the underlying windowing protocol. +winproto: winproto.App, + /// True if the app was launched with single instance mode. single_instance: bool, @@ -70,8 +73,10 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, /// This is set to false when the main loop should exit. running: bool = true, -/// Xkb state (X11 only). Will be null on Wayland. -x11_xkb: ?x11.Xkb = null, +/// If we should retry querying D-Bus for the color scheme with the deprecated +/// Read method, instead of the recommended ReadOne method. This is kind of +/// nasty to have as struct state but its just a byte... +dbus_color_scheme_retry: bool = true, /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled @@ -104,42 +109,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { c.gtk_get_micro_version(), }); - // Disabling Vulkan can improve startup times by hundreds of - // milliseconds on some systems. We don't use Vulkan so we can just - // disable it. - if (version.atLeast(4, 16, 0)) { - // 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,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 - // to always set this. Forwards versions are uncertain so we'll have to - // reassess... - // - // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 - // - // Specific details about values: - // - "opengl" - output OpenGL debug information - // - "gl-disable-gles" - disable GLES, Ghostty can't use GLES - // - "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,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 - // good understanding of what we may need to do. - _ = internal_os.setenv("GDK_DEBUG", "vulkan-disable"); - } - - if (version.atLeast(4, 14, 0)) { - // We need to export GSK_RENDERER to opengl because GTK uses ngl by - // default after 4.14 - _ = internal_os.setenv("GSK_RENDERER", "opengl"); - } - // Load our configuration var config = try Config.load(core_app.alloc); errdefer config.deinit(); @@ -161,8 +130,111 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } + var gdk_debug: struct { + /// output OpenGL debug information + opengl: bool = false, + /// disable GLES, Ghostty can't use GLES + @"gl-disable-gles": bool = false, + @"gl-no-fractional": bool = false, + /// Disabling Vulkan can improve startup times by hundreds of + /// milliseconds on some systems. We don't use Vulkan so we can just + /// disable it. + @"vulkan-disable": bool = false, + } = .{ + .opengl = config.@"gtk-opengl-debug", + }; + + var gdk_disable: struct { + @"gles-api": bool = false, + /// Disabling Vulkan can improve startup times by hundreds of + /// milliseconds on some systems. We don't use Vulkan so we can just + /// disable it. + vulkan: bool = false, + } = .{}; + + environment: { + if (version.runtimeAtLeast(4, 16, 0)) { + // 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. + gdk_disable.@"gles-api" = true; + gdk_disable.vulkan = true; + gdk_debug.@"gl-no-fractional" = true; + break :environment; + } + if (version.runtimeAtLeast(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 + // to always set this. Forwards versions are uncertain so we'll have + // to reassess... + // + // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 + gdk_debug.@"gl-disable-gles" = true; + gdk_debug.@"gl-no-fractional" = true; + gdk_debug.@"vulkan-disable" = true; + break :environment; + } + // 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 + // good understanding of what we may need to do. + gdk_debug.@"vulkan-disable" = true; + } + + { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + const writer = fmt.writer(); + var first: bool = true; + inline for (@typeInfo(@TypeOf(gdk_debug)).Struct.fields) |field| { + if (@field(gdk_debug, field.name)) { + if (!first) try writer.writeAll(","); + try writer.writeAll(field.name); + first = false; + } + } + try writer.writeByte(0); + const value = fmt.getWritten(); + log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]}); + _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]); + } + + { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + const writer = fmt.writer(); + var first: bool = true; + inline for (@typeInfo(@TypeOf(gdk_disable)).Struct.fields) |field| { + if (@field(gdk_disable, field.name)) { + if (!first) try writer.writeAll(","); + try writer.writeAll(field.name); + first = false; + } + } + try writer.writeByte(0); + const value = fmt.getWritten(); + log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]}); + _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]); + } + + if (version.runtimeAtLeast(4, 14, 0)) { + switch (config.@"gtk-gsk-renderer") { + .default => {}, + else => |renderer| { + // Force the GSK renderer to a specific value. After GTK 4.14 the + // `ngl` renderer is used by default which causes artifacts when + // used with Ghostty so it should be avoided. + log.warn("setting GSK_RENDERER={s}", .{@tagName(renderer)}); + _ = internal_os.setenv("GSK_RENDERER", @tagName(renderer)); + }, + } + } + c.gtk_init(); - const display = c.gdk_display_get_default(); + const display: *c.GdkDisplay = c.gdk_display_get_default() orelse { + // I'm unsure of any scenario where this happens. Because we don't + // want to litter null checks everywhere, we just exit here. + log.warn("gdk display is null, exiting", .{}); + std.posix.exit(1); + }; // If we're using libadwaita, log the version if (adwaita.enabled(&config)) { @@ -360,42 +432,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { return error.GtkApplicationRegisterFailed; } - // Perform all X11 initialization. This ultimately returns the X11 - // keyboard state but the block does more than that (i.e. setting up - // WM_CLASS). - const x11_xkb: ?x11.Xkb = x11_xkb: { - if (comptime !build_options.x11) break :x11_xkb null; - if (!x11.is_display(display)) break :x11_xkb null; - - // Set the X11 window class property (WM_CLASS) if are are on an X11 - // display. - // - // Note that we also set the program name here using g_set_prgname. - // This is how the instance name field for WM_CLASS is derived when - // calling gdk_x11_display_set_program_class; there does not seem to be - // a way to set it directly. It does not look like this is being set by - // our other app initialization routines currently, but since we're - // currently deriving its value from x11-instance-name effectively, I - // feel like gating it behind an X11 check is better intent. - // - // This makes the property show up like so when using xprop: - // - // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" - // - // Append "-debug" on both when using the debug build. - // - const prgname = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty"; - c.g_set_prgname(prgname); - c.gdk_x11_display_set_program_class(display, app_id); - - // Set up Xkb - break :x11_xkb try x11.Xkb.init(display); - }; + // Setup our windowing protocol logic + var winproto_app = try winproto.App.init( + core_app.alloc, + display, + app_id, + &config, + ); + errdefer winproto_app.deinit(core_app.alloc); + log.debug("windowing protocol={s}", .{@tagName(winproto_app)}); // 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 @@ -421,7 +466,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .config = config, .ctx = ctx, .cursor_none = cursor_none, - .x11_xkb = x11_xkb, + .winproto = winproto_app, .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 @@ -449,6 +494,8 @@ pub fn terminate(self: *App) void { } self.custom_css_providers.deinit(self.core_app.alloc); + self.winproto.deinit(self.core_app.alloc); + self.config.deinit(); } @@ -460,13 +507,16 @@ 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, }), + .toggle_maximize => self.toggleMaximize(target), .toggle_fullscreen => self.toggleFullscreen(target, value), .new_tab => try self.newTab(target), + .close_tab => try self.closeTab(target), .goto_tab => self.gotoTab(target, value), .move_tab => self.moveTab(target, value), .new_split => try self.newSplit(target, value), @@ -482,6 +532,7 @@ pub fn performAction( .pwd => try self.setPwd(target, value), .present_terminal => self.presentTerminal(target), .initial_size => try self.setInitialSize(target, value), + .size_limit => try self.setSizeLimit(target, value), .mouse_visibility => self.setMouseVisibility(target, value), .mouse_shape => try self.setMouseShape(target, value), .mouse_over_link => self.setMouseOverLink(target, value), @@ -494,7 +545,6 @@ pub fn performAction( .close_all_windows, .toggle_quick_terminal, .toggle_visibility, - .size_limit, .cell_size, .secure_input, .key_sequence, @@ -522,6 +572,23 @@ fn newTab(_: *App, target: apprt.Target) !void { } } +fn closeTab(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |v| { + const tab = v.rt_surface.container.tab() orelse { + log.info( + "close_tab invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + tab.closeWithConfirmation(); + }, + } +} + fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { switch (target) { .app => {}, @@ -648,6 +715,22 @@ fn controlInspector( surface.controlInspector(mode); } +fn toggleMaximize(_: *App, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "toggleMaximize invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + window.toggleMaximize(); + }, + } +} + fn toggleFullscreen( _: *App, target: apprt.Target, @@ -795,6 +878,23 @@ fn setInitialSize( } } +fn setSizeLimit( + _: *App, + target: apprt.Target, + value: apprt.action.SizeLimit, +) !void { + switch (target) { + .app => {}, + .surface => |v| try v.rt_surface.setSizeLimits(.{ + .width = value.min_width, + .height = value.min_height, + }, if (value.max_width > 0) .{ + .width = value.max_width, + .height = value.max_height, + } else null), + } +} + fn showDesktopNotification( self: *App, target: apprt.Target, @@ -837,9 +937,12 @@ 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| surface: { + const window = surface.rt_surface.container.window() orelse break :surface; + window.updateConfig(new_config) catch |err| { + log.warn("error updating config for window err={}", .{err}); + }; + }, .app => { // We clone (to take ownership) and update our configuration. @@ -995,7 +1098,28 @@ fn loadRuntimeCss( unfocused_fill.b, }); - if (version.atLeast(4, 16, 0)) { + 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.runtimeAtLeast(4, 16, 0)) { switch (window_theme) { .ghostty => try writer.print( \\:root {{ @@ -1008,6 +1132,8 @@ fn loadRuntimeCss( \\ --overview-bg-color: var(--ghostty-bg); \\ --popover-fg-color: var(--ghostty-fg); \\ --popover-bg-color: var(--ghostty-bg); + \\ --window-fg-color: var(--ghostty-fg); + \\ --window-bg-color: var(--ghostty-bg); \\}} \\windowhandle {{ \\ background-color: var(--headerbar-bg-color); @@ -1150,7 +1276,8 @@ pub fn run(self: *App) !void { self.transient_cgroup_base = path; } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); - // Setup our D-Bus connection for listening to settings changes. + // Setup our D-Bus connection for listening to settings changes, + // and asynchronously request the initial color scheme self.initDbus(); // Setup our menu items @@ -1158,9 +1285,6 @@ pub fn run(self: *App) !void { self.initMenu(); self.initContextMenu(); - // Setup our initial color scheme - self.colorSchemeEvent(self.getColorScheme()); - // On startup, we want to check for configuration errors right away // so we can show our error window. We also need to setup other initial // state. @@ -1172,14 +1296,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; @@ -1212,6 +1332,22 @@ fn initDbus(self: *App) void { self, null, ); + + // Request the initial color scheme asynchronously. + c.g_dbus_connection_call( + dbus, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "ReadOne", + c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), + c.G_VARIANT_TYPE("(v)"), + c.G_DBUS_CALL_FLAGS_NONE, + -1, + null, + dbusColorSchemeCallback, + self, + ); } // This timeout function is started when no surfaces are open. It can be @@ -1283,6 +1419,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) { @@ -1446,93 +1585,58 @@ fn gtkWindowIsActive( core_app.focusEvent(false); } -/// Call a D-Bus method to determine the current color scheme. If there -/// is any error at any point we'll log the error and return "light" -pub fn getColorScheme(self: *App) apprt.ColorScheme { - const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); +fn dbusColorSchemeCallback( + source_object: [*c]c.GObject, + res: ?*c.GAsyncResult, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *App = @ptrCast(@alignCast(ud.?)); + const dbus: *c.GDBusConnection = @ptrCast(source_object); var err: ?*c.GError = null; defer if (err) |e| c.g_error_free(e); - const value = c.g_dbus_connection_call_sync( - dbus_connection, - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "ReadOne", - c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), - c.G_VARIANT_TYPE("(v)"), - c.G_DBUS_CALL_FLAGS_NONE, - -1, - null, - &err, - ) orelse { - if (err) |e| { - // If ReadOne is not yet implemented, fall back to deprecated "Read" method - // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” - if (e.code == 19) { - return self.getColorSchemeDeprecated(); + if (c.g_dbus_connection_call_finish(dbus, res, &err)) |value| { + if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { + var inner: ?*c.GVariant = null; + c.g_variant_get(value, "(v)", &inner); + defer c.g_variant_unref(inner); + if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) { + self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1) + .dark + else + .light); + return; } - // Otherwise, log the error and return .light - log.err("unable to get current color scheme: {s}", .{e.message}); } - return .light; - }; - defer c.g_variant_unref(value); - - if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { - var inner: ?*c.GVariant = null; - c.g_variant_get(value, "(v)", &inner); - defer c.g_variant_unref(inner); - if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) { - return if (c.g_variant_get_uint32(inner) == 1) .dark else .light; + } else if (err) |e| { + // If ReadOne is not yet implemented, fall back to deprecated "Read" method + // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” + if (self.dbus_color_scheme_retry and e.code == 19) { + self.dbus_color_scheme_retry = false; + c.g_dbus_connection_call( + dbus, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "Read", + c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), + c.G_VARIANT_TYPE("(v)"), + c.G_DBUS_CALL_FLAGS_NONE, + -1, + null, + dbusColorSchemeCallback, + self, + ); + return; } - } - - return .light; -} - -/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If -/// there is any error at any point we'll log the error and return "light" -fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme { - const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); - var err: ?*c.GError = null; - defer if (err) |e| c.g_error_free(e); - const value = c.g_dbus_connection_call_sync( - dbus_connection, - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "Read", - c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), - c.G_VARIANT_TYPE("(v)"), - c.G_DBUS_CALL_FLAGS_NONE, - -1, - null, - &err, - ) orelse { - if (err) |e| log.err("Read method failed: {s}", .{e.message}); - return .light; - }; - defer c.g_variant_unref(value); - - if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { - var inner: ?*c.GVariant = null; - c.g_variant_get(value, "(v)", &inner); - defer if (inner) |i| c.g_variant_unref(i); - - if (inner) |i| { - const child = c.g_variant_get_child_value(i, 0) orelse { - return .light; - }; - defer c.g_variant_unref(child); - - const val = c.g_variant_get_uint32(child); - return if (val == 1) .dark else .light; - } + // Otherwise, log the error and return .light + log.warn("unable to get current color scheme: {s}", .{e.message}); } - return .light; + + // Fall back + self.colorSchemeEvent(.light); } /// This will be called by D-Bus when the style changes between light & dark. @@ -1623,7 +1727,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 @@ -1695,18 +1801,17 @@ fn initActions(self: *App) void { } } -/// This sets the self.menu property to the application menu that can be -/// shared by all application windows. -fn initMenu(self: *App) void { - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - +/// Initializes and populates the provided GMenu with sections and actions. +/// This function is used to set up the application's menu structure, either for +/// the main menu button or as a context menu when window decorations are disabled. +fn initMenuContent(menu: *c.GMenu) void { { const section = c.g_menu_new(); defer c.g_object_unref(section); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); c.g_menu_append(section, "New Window", "win.new_window"); c.g_menu_append(section, "New Tab", "win.new_tab"); + c.g_menu_append(section, "Close Tab", "win.close_tab"); c.g_menu_append(section, "Split Right", "win.split_right"); c.g_menu_append(section, "Split Down", "win.split_down"); c.g_menu_append(section, "Close Window", "win.close"); @@ -1721,13 +1826,14 @@ fn initMenu(self: *App) void { c.g_menu_append(section, "Reload Configuration", "app.reload-config"); c.g_menu_append(section, "About Ghostty", "win.about"); } +} - // { - // const section = c.g_menu_new(); - // defer c.g_object_unref(section); - // c.g_menu_append_submenu(menu, "File", @ptrCast(@alignCast(section))); - // } - +/// This sets the self.menu property to the application menu that can be +/// shared by all application windows. +fn initMenu(self: *App) void { + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + initMenuContent(@ptrCast(menu)); self.menu = menu; } @@ -1735,7 +1841,13 @@ fn initContextMenu(self: *App) void { const menu = c.g_menu_new(); errdefer c.g_object_unref(menu); - createContextMenuCopyPasteSection(menu, false); + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Copy", "win.copy"); + c.g_menu_append(section, "Paste", "win.paste"); + } { const section = c.g_menu_new(); @@ -1753,21 +1865,21 @@ fn initContextMenu(self: *App) void { c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); } - self.context_menu = menu; -} - -fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void { const section = c.g_menu_new(); defer c.g_object_unref(section); - c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section))); - // FIXME: Feels really hackish, but disabling sensitivity on this doesn't seems to work(?) - c.g_menu_append(section, "Copy", if (has_selection) "win.copy" else "noop"); - c.g_menu_append(section, "Paste", "win.paste"); + const submenu = c.g_menu_new(); + defer c.g_object_unref(submenu); + + initMenuContent(@ptrCast(submenu)); + c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + + self.context_menu = menu; } -pub fn refreshContextMenu(self: *App, has_selection: bool) void { - c.g_menu_remove(self.context_menu, 0); - createContextMenuCopyPasteSection(self.context_menu, has_selection); +pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void { + const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy")); + c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0); } fn isValidAppId(app_id: [:0]const u8) bool { diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index b6db7c5ef2..cf417b6684 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -64,6 +64,7 @@ fn init( c.gtk_window_set_title(gtk_window, titleText(request)); c.gtk_window_set_default_size(gtk_window, 550, 275); c.gtk_window_set_resizable(gtk_window, 0); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "clipboard-confirmation-window"); _ = c.g_signal_connect_data( window, @@ -88,6 +89,8 @@ fn init( const view = try PrimaryView.init(self, data); self.view = view; c.gtk_window_set_child(@ptrCast(window), view.root); + _ = c.gtk_widget_grab_focus(view.buttons.cancel_button); + c.gtk_widget_show(window); // Block the main window from input. @@ -103,6 +106,7 @@ fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { const PrimaryView = struct { root: *c.GtkWidget, text: *c.GtkTextView, + buttons: ButtonsView, pub fn init(root: *ClipboardConfirmation, data: []const u8) !PrimaryView { // All our widgets @@ -134,7 +138,7 @@ const PrimaryView = struct { c.gtk_text_view_set_right_margin(@ptrCast(text), 8); c.gtk_text_view_set_monospace(@ptrCast(text), 1); - return .{ .root = view.root, .text = @ptrCast(text) }; + return .{ .root = view.root, .text = @ptrCast(text), .buttons = buttons }; } /// Returns the GtkTextBuffer for the data that was unsafe. @@ -157,6 +161,8 @@ const PrimaryView = struct { const ButtonsView = struct { root: *c.GtkWidget, + confirm_button: *c.GtkWidget, + cancel_button: *c.GtkWidget, pub fn init(root: *ClipboardConfirmation) !ButtonsView { const cancel_text, const confirm_text = switch (root.pending_req) { @@ -170,8 +176,8 @@ const ButtonsView = struct { const confirm_button = c.gtk_button_new_with_label(confirm_text); errdefer c.g_object_unref(confirm_button); - // TODO: Focus on the paste button - // c.gtk_widget_grab_focus(confirm_button); + c.gtk_widget_add_css_class(confirm_button, "destructive-action"); + c.gtk_widget_add_css_class(cancel_button, "suggested-action"); // Create our view const view = try View.init(&.{ @@ -197,7 +203,7 @@ const ButtonsView = struct { c.G_CONNECT_DEFAULT, ); - return .{ .root = view.root }; + return .{ .root = view.root, .confirm_button = confirm_button, .cancel_button = cancel_button }; } fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index ff27919977..5fbf8e8352 100644 --- a/src/apprt/gtk/ConfigErrorsWindow.zig +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -55,6 +55,7 @@ fn init(self: *ConfigErrors, app: *App) !void { c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "config-errors-window"); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c53190ccc0..3677c5e8d7 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -25,7 +25,6 @@ const ResizeOverlay = @import("ResizeOverlay.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; -const x11 = @import("x11.zig"); const log = std.log.scoped(.gtk_surface); @@ -347,6 +346,11 @@ cursor: ?*c.GdkCursor = null, /// pass it to GTK. title_text: ?[:0]const u8 = null, +/// Our current working directory. We use this value for setting tooltips in +/// the headerbar subtitle if we have focus. When set, the text in this buf +/// will be null-terminated because we need to pass it to GTK. +pwd: ?[:0]const u8 = null, + /// The timer used to delay title updates in order to prevent flickering. update_title_timer: ?c.guint = null, @@ -364,10 +368,9 @@ cursor_pos: apprt.CursorPos, inspector: ?*inspector.Inspector = null, /// Key input states. See gtkKeyPressed for detailed descriptions. -in_keypress: bool = false, +in_keyevent: bool = false, im_context: *c.GtkIMContext, im_composing: bool = false, -im_commit_buffered: bool = false, im_buf: [128]u8 = undefined, im_len: u7 = 0, @@ -492,6 +495,17 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { c.gtk_widget_set_focusable(gl_area, 1); c.gtk_widget_set_focus_on_click(gl_area, 1); + // Set up to handle items being dropped on our surface. Files can be dropped + // from Nautilus and strings can be dropped from many programs. + const drop_target = c.gtk_drop_target_new(c.G_TYPE_INVALID, c.GDK_ACTION_COPY); + errdefer c.g_object_unref(drop_target); + var drop_target_types = [_]c.GType{ + c.gdk_file_list_get_type(), + c.G_TYPE_STRING, + }; + c.gtk_drop_target_set_gtypes(drop_target, @ptrCast(&drop_target_types), drop_target_types.len); + c.gtk_widget_add_controller(@ptrCast(overlay), @ptrCast(drop_target)); + // Inherit the parent's font size if we have a parent. const font_size: ?font.face.DesiredSize = font_size: { if (!app.config.@"window-inherit-font-size") break :font_size null; @@ -545,7 +559,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { .font_size = font_size, .init_config = init_config, .size = .{ .width = 800, .height = 600 }, - .cursor_pos = .{ .x = 0, .y = 0 }, + .cursor_pos = .{ .x = -1, .y = -1 }, .im_context = im_context, .cgroup_path = cgroup_path, }; @@ -574,6 +588,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { _ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(>kInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(>kInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(>kInputCommit), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(drop_target, "drop", c.G_CALLBACK(>kDrop), self, null, c.G_CONNECT_DEFAULT); } fn realize(self: *Surface) !void { @@ -618,9 +633,6 @@ fn realize(self: *Surface) !void { try self.core_surface.setFontSize(size); } - // Set the initial color scheme - try self.core_surface.colorSchemeCallback(self.app.getColorScheme()); - // Note we're realized self.realized = true; } @@ -628,6 +640,7 @@ fn realize(self: *Surface) !void { pub fn deinit(self: *Surface) void { self.init_config.deinit(self.app.core_app.alloc); if (self.title_text) |title| self.app.core_app.alloc.free(title); + if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd); // We don't allocate anything if we aren't realized. if (!self.realized) return; @@ -840,6 +853,28 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void ); } +pub fn setSizeLimits(self: *const Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { + + // There's no support for setting max size at the moment. + _ = max_; + + // If we are within a split, do not set the size. + if (self.container.split() != null) return; + + // This operation only makes sense if we're within a window view + // hierarchy and we're the first tab in the window. + const window = self.container.window() orelse return; + if (window.notebook.nPages() > 1) return; + + // Note: this doesn't properly take into account the window decorations. + // I'm not currently sure how to do that. + c.gtk_widget_set_size_request( + @ptrCast(window.window), + @intCast(min.width), + @intCast(min.height), + ); +} + pub fn grabFocus(self: *Surface) void { if (self.container.tab()) |tab| { // If any other surface was focused and zoomed in, set it to non zoomed in @@ -876,7 +911,7 @@ fn updateTitleLabels(self: *Surface) void { // I don't know a way around this yet. I've tried re-hiding the // cursor after setting the title but it doesn't work, I think // due to some gtk event loop things... - c.gtk_window_set_title(window.window, title.ptr); + window.setTitle(title); } } } @@ -929,11 +964,27 @@ pub fn getTitle(self: *Surface) ?[:0]const u8 { return null; } +/// Set the current working directory of the surface. +/// +/// In addition, update the tab's tooltip text, and if we are the focused child, +/// update the subtitle of the containing window. pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void { - // If we have a tab and are the focused child, then we have to update the tab if (self.container.tab()) |tab| { tab.setTooltipText(pwd); + + if (tab.focus_child == self) { + if (self.container.window()) |window| { + if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd); + } + } } + + const alloc = self.app.core_app.alloc; + + // Failing to set the surface's current working directory is not a big + // deal since we just used our slice parameter which is the same value. + if (self.pwd) |old| alloc.free(old); + self.pwd = alloc.dupeZ(u8, pwd) catch null; } pub fn setMouseShape( @@ -1080,6 +1131,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.@"app-notifications".@"clipboard-copy") + { + if (self.container.window()) |window| + window.sendToast("Copied to clipboard"); + } return; } @@ -1217,7 +1275,7 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void { }; c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect); - self.app.refreshContextMenu(self.core_surface.hasSelection()); + self.app.refreshContextMenu(window.window, self.core_surface.hasSelection()); c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu))); } @@ -1321,6 +1379,12 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) return; }; + if (self.container.window()) |window| { + window.winproto.resizeEvent() catch |err| { + log.warn("failed to notify window protocol of resize={}", .{err}); + }; + } + self.resize_overlay.maybeShow(); } } @@ -1426,31 +1490,37 @@ fn gtkMouseMotion( .y = @floatCast(scaled.y), }; - // 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. + // There seem to be at least two cases where GTK issues a mouse motion + // event without the cursor actually moving: + // 1. GLArea is resized under the mouse. This has the unfortunate + // side effect of causing focus to potentially change when + // `focus-follows-mouse` is enabled. + // 2. The window title is updated. This can cause the mouse to unhide + // incorrectly when hide-mouse-when-typing is enabled. + // To prevent incorrect behavior, we'll only grab focus and + // continue with callback logic if the cursor has actually 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 (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { - self.grabFocus(); - } + if (!is_cursor_still) { + // 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") { + self.grabFocus(); + } - // Our pos changed, update - self.cursor_pos = pos; + // 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); + // Get our modifiers + const gtk_mods = c.gdk_event_get_modifier_state(event); + const mods = gtk_key.translateMods(gtk_mods); - self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { - log.err("error in cursor pos callback err={}", .{err}); - return; - }; + self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { + log.err("error in cursor pos callback err={}", .{err}); + return; + }; + } } fn gtkMouseLeave( @@ -1530,30 +1600,36 @@ fn gtkKeyReleased( )) 1 else 0; } -/// Key press event. This is where we do ALL of our key handling, -/// translation to keyboard layouts, dead key handling, etc. Key handling -/// is complicated so this comment will explain what's going on. +/// Key press event (press or release). /// /// At a high level, we want to construct an `input.KeyEvent` and /// pass that to `keyCallback`. At a low level, this is more complicated /// than it appears because we need to construct all of this information /// and its not given to us. /// -/// For press events, we run the keypress through the input method context -/// in order to determine if we're in a dead key state, completed unicode -/// char, etc. This all happens through various callbacks: preedit, commit, -/// etc. These inspect "in_keypress" if they have to and set some instance -/// state. +/// For all events, we run the GdkEvent through the input method context. +/// This allows the input method to capture the event and trigger +/// callbacks such as preedit, commit, etc. +/// +/// There are a couple important aspects to the prior paragraph: we must +/// send ALL events through the input method context. This is because +/// input methods use both key press and key release events to determine +/// the state of the input method. For example, fcitx uses key release +/// events on modifiers (i.e. ctrl+shift) to switch the input method. +/// +/// We set some state to note we're in a key event (self.in_keyevent) +/// because some of the input method callbacks change behavior based on +/// this state. For example, we don't want to send character events +/// like "a" via the input "commit" event if we're actively processing +/// a keypress because we'd lose access to the keycode information. +/// However, a "commit" event may still happen outside of a keypress +/// event from e.g. a tablet or on-screen keyboard. /// -/// We then take all of the information in order to determine if we have +/// Finally, we take all of the information in order to determine if we have /// a unicode character or if we have to map the keyval to a code to /// get the underlying logical key, etc. /// -/// Finally, we can emit the keyCallback. -/// -/// Note we ALSO have an IMContext attached directly to the widget -/// which can emit preedit and commit callbacks. But, if we're not -/// in a keypress, we let those automatically work. +/// Then we can emit the keyCallback. pub fn keyEvent( self: *Surface, action: input.Action, @@ -1562,26 +1638,15 @@ pub fn keyEvent( keycode: c.guint, gtk_mods: c.GdkModifierType, ) bool { + // log.warn("GTKIM: keyEvent action={}", .{action}); const event = c.gtk_event_controller_get_current_event( @ptrCast(ec_key), ) orelse return false; - const keyval_unicode = c.gdk_keyval_to_unicode(keyval); - - // Get the unshifted unicode value of the keyval. This is used - // by the Kitty keyboard protocol. - const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( - @ptrCast(self.gl_area), - event, - keycode, - ); - - // We always reset our committed text when ending a keypress so that - // future keypresses don't think we have a commit event. - defer self.im_len = 0; - - // We only want to send the event through the IM context if we're a press - if (action == .press or action == .repeat) { + // The block below is all related to input method handling. See the function + // comment for some high level details and then the comments within + // the block for more specifics. + { // This can trigger an input method so we need to notify the im context // where the cursor is so it can render the dropdowns in the correct // place. @@ -1593,41 +1658,94 @@ pub fn keyEvent( .height = 1, }); - // We mark that we're in a keypress event. We use this in our - // IM commit callback to determine if we need to send a char callback - // to the core surface or not. - self.in_keypress = true; - defer self.in_keypress = false; - - // Pass the event through the IM controller to handle dead key states. - // Filter is true if the event was handled by the IM controller. - const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0; - // log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing }); - - // If this is a dead key, then we're composing a character and - // we need to set our proper preedit state. - if (self.im_composing) preedit: { - const text = self.im_buf[0..self.im_len]; - self.core_surface.preeditCallback(text) catch |err| { - log.err("error in preedit callback err={}", .{err}); - break :preedit; - }; - - // If we're composing then we don't want to send the key - // event to the core surface so we always return immediately. - if (im_handled) return true; - } else { - // If we aren't composing, then we set our preedit to - // empty no matter what. - self.core_surface.preeditCallback(null) catch {}; - - // If the IM handled this and we have no text, then we just - // return because this probably just changed the input method - // or something. - if (im_handled and self.im_len == 0) return true; + // Pass the event through the IM controller. This will return true + // if the input method handled the event. + // + // Confusingly, not all events handled by the input method result + // in this returning true so we have to maintain some local state to + // find those and in one case we simply lose information. + // + // - If we change the input method via keypress while we have preedit + // text, the input method will commit the pending text but will not + // mark it as handled. We use the `was_composing` variable to detect + // this case. + // + // - If we switch input methods (i.e. via ctrl+shift with fcitx), + // the input method will handle the key release event but will not + // mark it as handled. I don't know any way to detect this case so + // it will result in a key event being sent to the key callback. + // For Kitty text encoding, this will result in modifiers being + // triggered despite being technically consumed. At the time of + // writing, both Kitty and Alacritty have the same behavior. I + // know of no way to fix this. + const was_composing = self.im_composing; + const im_handled = filter: { + // We note that we're in a keypress because we want some logic to + // depend on this. For example, we don't want to send character events + // like "a" via the input "commit" event if we're actively processing + // a keypress because we'd lose access to the keycode information. + self.in_keyevent = true; + defer self.in_keyevent = false; + break :filter c.gtk_im_context_filter_keypress( + self.im_context, + event, + ) != 0; + }; + // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ + // im_handled, + // self.im_len, + // self.im_composing, + // }); + + // If the input method handled the event, you would think we would + // never proceed with key encoding for Ghostty but that is not the + // case. Input methods will handle basic character encoding like + // typing "a" and we want to associate that with the key event. + // So we have to check additional state to determine if we exit. + if (im_handled) { + // If we are composing then we're in a preedit state and do + // not want to encode any keys. For example: type a deadkey + // such as single quote on a US international keyboard layout. + if (self.im_composing) return true; + + // If we were composing and now we're not it means that we committed + // the text. We also don't want to encode a key event for this. + // Example: enable Japanese input method, press "konn" and then + // press enter. The final enter should not be encoded and "konn" + // (in hiragana) should be written as "こん". + if (was_composing) return true; + + // Not composing and our input method buffer is empty. This could + // mean that the input method reacted to this event by activating + // an onscreen keyboard or something equivalent. We don't know. + // But the input method handled it and didn't give us text so + // we will just assume we should not encode this. This handles a + // real scenario when ibus starts the emoji input method + // (super+.). + if (self.im_len == 0) return true; } + + // At this point, for the sake of explanation of internal state: + // it is possible that im_len > 0 and im_composing == false. This + // means that we received a commit event from the input method that + // we want associated with the key event. This is common: its how + // basic character translation for simple inputs like "a" work. } + // We always reset the length of the im buffer. There's only one scenario + // we reach this point with im_len > 0 and that's if we received a commit + // event from the input method. We don't want to keep that state around + // since we've handled it here. + defer self.im_len = 0; + + // Get the keyvals for this event. + const keyval_unicode = c.gdk_keyval_to_unicode(keyval); + const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( + @ptrCast(self.gl_area), + event, + keycode, + ); + // We want to get the physical unmapped key to process physical keybinds. // (These are keybinds explicitly marked as requesting physical mapping). const physical_key = keycode: for (input.keycodes.entries) |entry| { @@ -1636,11 +1754,10 @@ pub fn keyEvent( // Get our modifier for the event const mods: input.Mods = gtk_key.eventMods( - @ptrCast(self.gl_area), event, physical_key, gtk_mods, - if (self.app.x11_xkb) |*xkb| xkb else null, + &self.app.winproto, ); // Get our consumed modifiers @@ -1761,12 +1878,11 @@ fn gtkInputPreeditStart( _: *c.GtkIMContext, ud: ?*anyopaque, ) callconv(.C) void { - //log.debug("preedit start", .{}); + // log.warn("GTKIM: preedit start", .{}); const self = userdataSelf(ud.?); - if (!self.in_keypress) return; - // Mark that we are now composing a string with a dead key state. - // We'll record the string in the preedit-changed callback. + // Start our composing state for the input method and reset our + // input buffer to empty. self.im_composing = true; self.im_len = 0; } @@ -1775,54 +1891,35 @@ fn gtkInputPreeditChanged( ctx: *c.GtkIMContext, ud: ?*anyopaque, ) callconv(.C) void { + // log.warn("GTKIM: preedit change", .{}); const self = userdataSelf(ud.?); - // If there's buffered character, send the characters directly to the surface. - if (self.im_composing and self.im_commit_buffered) { - defer self.im_commit_buffered = false; - defer self.im_len = 0; - _ = self.core_surface.keyCallback(.{ - .action = .press, - .key = .invalid, - .physical_key = .invalid, - .mods = .{}, - .consumed_mods = .{}, - .composing = false, - .utf8 = self.im_buf[0..self.im_len], - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - } - - if (!self.in_keypress) return; - // Get our pre-edit string that we'll use to show the user. var buf: [*c]u8 = undefined; _ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null); defer c.g_free(buf); const str = std.mem.sliceTo(buf, 0); - // If our string becomes empty we ignore this. This can happen after - // a commit event when the preedit is being cleared and we don't want - // to set im_len to zero. This is safe because preeditstart always sets - // im_len to zero. - if (str.len == 0) return; - - // Copy the preedit string into the im_buf. This is safe because - // commit will always overwrite this. - self.im_len = @intCast(@min(self.im_buf.len, str.len)); - @memcpy(self.im_buf[0..self.im_len], str); + // Update our preedit state in Ghostty core + self.core_surface.preeditCallback(str) catch |err| { + log.err("error in preedit callback err={}", .{err}); + }; } fn gtkInputPreeditEnd( _: *c.GtkIMContext, ud: ?*anyopaque, ) callconv(.C) void { - //log.debug("preedit end", .{}); + // log.warn("GTKIM: preedit end", .{}); const self = userdataSelf(ud.?); - if (!self.in_keypress) return; + + // End our composing state for GTK, allowing us to commit the text. self.im_composing = false; + + // End our preedit state in Ghostty core + self.core_surface.preeditCallback(null) catch |err| { + log.err("error in preedit callback err={}", .{err}); + }; } fn gtkInputCommit( @@ -1830,38 +1927,45 @@ fn gtkInputCommit( bytes: [*:0]u8, ud: ?*anyopaque, ) callconv(.C) void { + // log.warn("GTKIM: input commit", .{}); const self = userdataSelf(ud.?); const str = std.mem.sliceTo(bytes, 0); - // If we're in a key event, then we want to buffer the commit so - // that we can send the proper keycallback followed by the char - // callback. - if (self.in_keypress) { - if (str.len <= self.im_buf.len) { - @memcpy(self.im_buf[0..str.len], str); - self.im_len = @intCast(str.len); - - // If composing is done and character should be committed, - // It should be committed in preedit callback. - if (self.im_composing) { - self.im_commit_buffered = true; - } - - // log.debug("input commit len={}", .{self.im_len}); - } else { + // If we're in a keyEvent (i.e. a keyboard event) and we're not composing, + // then this is just a normal key press resulting in UTF-8 text. We + // want the keyEvent to handle this so that the UTF-8 text can be associated + // with a keyboard event. + if (!self.im_composing and self.in_keyevent) { + if (str.len > self.im_buf.len) { log.warn("not enough buffer space for input method commit", .{}); + return; } + // Copy our committed text to the buffer + @memcpy(self.im_buf[0..str.len], str); + self.im_len = @intCast(str.len); + + // log.debug("input commit len={}", .{self.im_len}); return; } - // This prevents staying in composing state after commit even though - // input method has changed. + // If we reach this point from above it means we're composing OR + // not in a keypress. In either case, we want to commit the text + // given to us because that's what GTK is asking us to do. If we're + // not in a keypress it means that this commit came via a non-keyboard + // event (i.e. on-screen keyboard, tablet of some kind, etc.). + + // Committing ends composing state self.im_composing = false; - // We're not in a keypress, so this was sent from an on-screen emoji - // keyboard or something like that. Send the characters directly to - // the surface. + // End our preedit state. Well-behaved input methods do this for us + // by triggering a preedit-end event but some do not (ibus 1.5.29). + self.core_surface.preeditCallback(null) catch |err| { + log.err("error in preedit callback err={}", .{err}); + }; + + // Send the text to the core surface, associated with no key (an + // invalid key, which should produce no PTY encoding). _ = self.core_surface.keyCallback(.{ .action = .press, .key = .invalid, @@ -1871,7 +1975,7 @@ fn gtkInputCommit( .composing = false, .utf8 = str, }) catch |err| { - log.err("error in key callback err={}", .{err}); + log.warn("error in key callback err={}", .{err}); return; }; } @@ -1889,6 +1993,12 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo self.unfocused_widget = null; } + if (self.pwd) |pwd| { + if (self.container.window()) |window| { + if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd); + } + } + // Notify our surface self.core_surface.focusCallback(true) catch |err| { log.err("error in focus callback err={}", .{err}); @@ -2018,3 +2128,95 @@ pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void { pub fn toggleSplitZoom(self: *Surface) void { self.setSplitZoom(!self.zoomed_in); } + +/// Handle items being dropped on our surface. +fn gtkDrop( + _: *c.GtkDropTarget, + value: *c.GValue, + x: f64, + y: f64, + ud: ?*anyopaque, +) callconv(.C) c.gboolean { + _ = x; + _ = y; + const self = userdataSelf(ud.?); + const alloc = self.app.core_app.alloc; + + if (g_value_holds(value, c.G_TYPE_BOXED)) { + var data = std.ArrayList(u8).init(alloc); + defer data.deinit(); + + var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{ + .child_writer = data.writer(), + }; + const writer = shell_escape_writer.writer(); + + const fl: *c.GdkFileList = @ptrCast(c.g_value_get_boxed(value)); + var l = c.gdk_file_list_get_files(fl); + + while (l != null) : (l = l.*.next) { + const file: *c.GFile = @ptrCast(l.*.data); + const path = c.g_file_get_path(file) orelse continue; + + writer.writeAll(std.mem.span(path)) catch |err| { + log.err("unable to write path to buffer: {}", .{err}); + continue; + }; + writer.writeAll("\n") catch |err| { + log.err("unable to write to buffer: {}", .{err}); + continue; + }; + } + + const string = data.toOwnedSliceSentinel(0) catch |err| { + log.err("unable to convert to a slice: {}", .{err}); + return 1; + }; + defer alloc.free(string); + + self.doPaste(string); + + return 1; + } + + if (g_value_holds(value, c.G_TYPE_STRING)) { + if (c.g_value_get_string(value)) |string| { + self.doPaste(std.mem.span(string)); + } + return 1; + } + + return 1; +} + +fn doPaste(self: *Surface, data: [:0]const u8) void { + if (data.len == 0) return; + + self.core_surface.completeClipboardRequest(.paste, data, false) catch |err| switch (err) { + error.UnsafePaste, + error.UnauthorizedPaste, + => { + ClipboardConfirmationWindow.create( + self.app, + data, + &self.core_surface, + .paste, + ) catch |window_err| { + log.err("failed to create clipboard confirmation window err={}", .{window_err}); + }; + }, + error.OutOfMemory, + error.NoSpaceLeft, + => log.err("failed to complete clipboard request err={}", .{err}), + }; +} + +/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's +/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it. +fn g_value_holds(value_: ?*c.GValue, g_type: c.GType) bool { + if (value_) |value| { + if (value.*.g_type == g_type) return true; + return c.g_type_check_value_holds(value, g_type) != 0; + } + return false; +} diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index ed0804fd3d..d320daa7c8 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -121,10 +121,63 @@ pub fn remove(self: *Tab) void { self.window.closeTab(self); } -pub fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { +/// Helper function to check if any surface in the split hierarchy needs close confirmation +fn needsConfirm(elem: Surface.Container.Elem) bool { + return switch (elem) { + .surface => |s| s.core_surface.needsConfirmQuit(), + .split => |s| needsConfirm(s.top_left) or needsConfirm(s.bottom_right), + }; +} + +/// Close the tab, asking for confirmation if any surface requests it. +pub fn closeWithConfirmation(tab: *Tab) void { + switch (tab.elem) { + .surface => |s| s.close(s.core_surface.needsConfirmQuit()), + .split => |s| { + if (needsConfirm(s.top_left) or needsConfirm(s.bottom_right)) { + const alert = c.gtk_message_dialog_new( + tab.window.window, + c.GTK_DIALOG_MODAL, + c.GTK_MESSAGE_QUESTION, + c.GTK_BUTTONS_YES_NO, + "Close this tab?", + ); + c.gtk_message_dialog_format_secondary_text( + @ptrCast(alert), + "All terminal sessions in this tab will be terminated.", + ); + + // We want the "yes" to appear destructive. + const yes_widget = c.gtk_dialog_get_widget_for_response( + @ptrCast(alert), + c.GTK_RESPONSE_YES, + ); + c.gtk_widget_add_css_class(yes_widget, "destructive-action"); + + // We want the "no" to be the default action + c.gtk_dialog_set_default_response( + @ptrCast(alert), + c.GTK_RESPONSE_NO, + ); + + _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kTabCloseConfirmation), tab, null, c.G_CONNECT_DEFAULT); + c.gtk_widget_show(alert); + return; + } + tab.remove(); + }, + } +} + +fn gtkTabCloseConfirmation( + alert: *c.GtkMessageDialog, + response: c.gint, + ud: ?*anyopaque, +) callconv(.C) void { const tab: *Tab = @ptrCast(@alignCast(ud)); - const window = tab.window; - window.closeTab(tab); + c.gtk_window_destroy(@ptrCast(alert)); + if (response != c.GTK_RESPONSE_YES) return; + tab.remove(); } fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { @@ -135,17 +188,3 @@ fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { const tab: *Tab = @ptrCast(@alignCast(ud)); tab.destroy(tab.window.app.core_app.alloc); } - -pub fn gtkTabClick( - gesture: *c.GtkGestureClick, - _: c.gint, - _: c.gdouble, - _: c.gdouble, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Tab = @ptrCast(@alignCast(ud)); - const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); - if (gtk_button == c.GDK_BUTTON_MIDDLE) { - self.remove(); - } -} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 516ea7fc58..58f5659f0d 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 winproto = @import("winproto.zig"); const log = std.log.scoped(.gtk); @@ -36,7 +37,7 @@ window: *c.GtkWindow, /// The header bar for the window. This is possibly null since it can be /// disabled using gtk-titlebar. This is either an AdwHeaderBar or /// GtkHeaderBar depending on if adw is enabled and linked. -header: ?HeaderBar, +headerbar: HeaderBar, /// The tab overview for the window. This is possibly null since there is no /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). @@ -55,6 +56,9 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, +/// State and logic for windowing protocol for a window. +winproto: winproto.Window, + 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 @@ -74,11 +78,12 @@ pub fn init(self: *Window, app: *App) !void { self.* = .{ .app = app, .window = undefined, - .header = null, + .headerbar = undefined, .tab_overview = null, .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, + .winproto = .none, }; // Create the window @@ -99,6 +104,7 @@ pub fn init(self: *Window, app: *App) !void { self.window = gtk_window; c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 1000, 600); + c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window"); c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window"); // GTK4 grabs F10 input by default to focus the menubar icon. We want @@ -114,11 +120,6 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty"); } - // Remove the window's background if any of the widgets need to be transparent - if (app.config.@"background-opacity" < 1) { - c.gtk_widget_remove_css_class(@ptrCast(window), "background"); - } - // Create our box which will hold our widgets in the main content area. const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); @@ -150,82 +151,66 @@ pub fn init(self: *Window, app: *App) !void { break :overview tab_overview; } else null; - // gtk-titlebar can be used to disable the header bar (but keep - // the window manager's decorations). We create this no matter if we - // are decorated or not because we can have a keybind to toggle the - // decorations. - if (app.config.@"gtk-titlebar") { - const header = HeaderBar.init(self); - - // If we are not decorated then we hide the titlebar. - header.setVisible(app.config.@"window-decoration"); + // gtk-titlebar can be used to disable the header bar (but keep the window + // manager's decorations). We create this no matter if we are decorated or + // not because we can have a keybind to toggle the decorations. + self.headerbar.init(); - { - const btn = c.gtk_menu_button_new(); - c.gtk_widget_set_tooltip_text(btn, "Main Menu"); - c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); - c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); - header.packEnd(btn); - } + { + const btn = c.gtk_menu_button_new(); + c.gtk_widget_set_tooltip_text(btn, "Main Menu"); + c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); + c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); + self.headerbar.packEnd(btn); + } - // If we're using an AdwWindow then we can support the tab overview. - if (self.tab_overview) |tab_overview| { - if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); - const btn = switch (app.config.@"gtk-tabs-location") { - .top, .bottom, .left, .right => btn: { - const btn = c.gtk_toggle_button_new(); - c.gtk_widget_set_tooltip_text(btn, "View Open Tabs"); - c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); - _ = c.g_object_bind_property( - btn, - "active", - tab_overview, - "open", - c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE, - ); - - break :btn btn; - }, - - .hidden => btn: { - const btn = c.adw_tab_button_new(); - 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; - }, - }; - - c.gtk_widget_set_focus_on_click(btn, c.FALSE); - header.packEnd(btn); - } + // If we're using an AdwWindow then we can support the tab overview. + if (self.tab_overview) |tab_overview| { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); + const btn = switch (app.config.@"gtk-tabs-location") { + .top, .bottom, .left, .right => btn: { + const btn = c.gtk_toggle_button_new(); + c.gtk_widget_set_tooltip_text(btn, "View Open Tabs"); + c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); + _ = c.g_object_bind_property( + btn, + "active", + tab_overview, + "open", + c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE, + ); + + break :btn btn; + }, - { - const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); - c.gtk_widget_set_tooltip_text(btn, "New Tab"); - _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); - header.packStart(btn); - } + .hidden => btn: { + const btn = c.adw_tab_button_new(); + 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; + }, + }; - self.header = header; + c.gtk_widget_set_focus_on_click(btn, c.FALSE); + self.headerbar.packEnd(btn); } - // If we are disabling decorations then disable them right away. - if (!app.config.@"window-decoration") { - c.gtk_window_set_decorated(gtk_window, 0); - - // Fix any artifacting that may occur in window corners. - if (app.config.@"gtk-titlebar") { - c.gtk_widget_add_css_class(window, "without-window-decoration-and-with-titlebar"); - } + { + const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); + c.gtk_widget_set_tooltip_text(btn, "New Tab"); + _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); + self.headerbar.packStart(btn); } + _ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_window, "notify::maximized", c.G_CALLBACK(>kWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(>kWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT); + // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we // need to stick the headerbar into the content box. if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { - if (self.header) |h| { - c.gtk_box_append(@ptrCast(box), h.asWidget()); - } + c.gtk_box_append(@ptrCast(box), self.headerbar.asWidget()); } // In debug we show a warning and apply the 'devel' class to the window. @@ -273,10 +258,13 @@ pub fn init(self: *Window, app: *App) !void { } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); - c.gtk_widget_set_parent(self.context_menu, window); + c.gtk_widget_set_parent(self.context_menu, box); c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0); c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START); + // If we want the window to be maximized, we do that here. + if (app.config.maximize) c.gtk_window_maximize(self.window); + // If we are in fullscreen mode, new windows start fullscreen. if (app.config.fullscreen) c.gtk_window_fullscreen(self.window); @@ -289,6 +277,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); @@ -299,10 +288,7 @@ pub fn init(self: *Window, app: *App) !void { if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); - if (self.header) |header| { - const header_widget = header.asWidget(); - c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); - } + c.adw_toolbar_view_add_top_bar(toolbar_view, self.headerbar.asWidget()); if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); @@ -375,10 +361,8 @@ pub fn init(self: *Window, app: *App) !void { box, ); } else { + c.gtk_window_set_titlebar(gtk_window, self.headerbar.asWidget()); c.gtk_window_set_child(gtk_window, box); - if (self.header) |h| { - c.gtk_window_set_titlebar(gtk_window, h.asWidget()); - } } } @@ -386,6 +370,74 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_show(window); } +pub fn updateConfig( + self: *Window, + config: *const configpkg.Config, +) !void { + self.winproto.updateConfigEvent(config) catch |err| { + // We want to continue attempting to make the other config + // changes necessary so we just log the error and continue. + log.warn("failed to update window protocol config error={}", .{err}); + }; + + // We always resync our appearance whenever the config changes. + try self.syncAppearance(config); +} + +/// 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 { + self.winproto.syncAppearance() catch |err| { + log.warn("failed to sync winproto appearance error={}", .{err}); + }; + + toggleCssClass( + @ptrCast(self.window), + "background", + config.@"background-opacity" >= 1, + ); + + // If we are disabling CSDs then disable them right away. + const csd_enabled = self.winproto.clientSideDecorationEnabled(); + c.gtk_window_set_decorated(self.window, @intFromBool(csd_enabled)); + + // If we are not decorated then we hide the titlebar. + self.headerbar.setVisible(config.@"gtk-titlebar" and csd_enabled); + + // Disable the title buttons (close, maximize, minimize, ...) + // *inside* the tab overview if CSDs are disabled. + // We do spare the search button, though. + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and + adwaita.enabled(&self.app.config)) + { + if (self.tab_overview) |tab_overview| { + c.adw_tab_overview_set_show_start_title_buttons( + @ptrCast(tab_overview), + @intFromBool(csd_enabled), + ); + c.adw_tab_overview_set_show_end_title_buttons( + @ptrCast(tab_overview), + @intFromBool(csd_enabled), + ); + } + } +} + +fn toggleCssClass( + widget: *c.GtkWidget, + class: [:0]const u8, + v: bool, +) void { + if (v) { + c.gtk_widget_add_css_class(widget, class); + } else { + c.gtk_widget_remove_css_class(widget, class); + } +} + /// 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,11 +475,23 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); + self.winproto.deinit(self.app.core_app.alloc); + if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); } } +/// Set the title of the window. +pub fn setTitle(self: *Window, title: [:0]const u8) void { + self.headerbar.setTitle(title); +} + +/// Set the subtitle of the window if it has one. +pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void { + self.headerbar.setSubtitle(subtitle); +} + /// Add a new tab to this window. pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { const alloc = self.app.core_app.alloc; @@ -473,9 +537,9 @@ pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void { self.notebook.moveTab(tab, position); } -/// Go to the next tab for a surface. +/// Go to the last tab for a surface. pub fn gotoLastTab(self: *Window) void { - const max = self.notebook.nPages() -| 1; + const max = self.notebook.nPages(); self.gotoTab(@intCast(max)); } @@ -498,6 +562,15 @@ pub fn toggleTabOverview(self: *Window) void { } } +/// Toggle the maximized state for this window. +pub fn toggleMaximize(self: *Window) void { + if (c.gtk_window_is_maximized(self.window) == 0) { + c.gtk_window_maximize(self.window); + } else { + c.gtk_window_unmaximize(self.window); + } +} + /// Toggle fullscreen for this window. pub fn toggleFullscreen(self: *Window) void { const is_fullscreen = c.gtk_window_is_fullscreen(self.window); @@ -510,24 +583,11 @@ pub fn toggleFullscreen(self: *Window) void { /// Toggle the window decorations for this window. pub fn toggleWindowDecorations(self: *Window) void { - const old_decorated = c.gtk_window_get_decorated(self.window) == 1; - const new_decorated = !old_decorated; - c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); - - // Fix any artifacting that may occur in window corners. - if (new_decorated) { - c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); - } else { - c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); - } - - // If we have a titlebar, then we also show/hide it depending on the - // decorated state. GTK tends to consider the titlebar part of the frame - // and hides it with decorations, but libadwaita doesn't. This makes it - // explicit. - if (self.header) |headerbar| { - headerbar.setVisible(new_decorated); - } + self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") { + .auto, .client, .server => .none, + .none => .client, + }; + self.updateConfig(&self.app.config) catch {}; } /// Grabs focus on the currently selected tab. @@ -542,7 +602,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 +610,85 @@ 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.?); + + // Initialize our window protocol logic + if (winproto.Window.init( + self.app.core_app.alloc, + &self.app.winproto, + v, + &self.app.config, + )) |winproto_win| { + self.winproto = winproto_win; + } else |err| { + log.warn("failed to initialize window protocol error={}", .{err}); + } + + // When we are realized we always setup our appearance + self.syncAppearance(&self.app.config) catch |err| { + log.err("failed to initialize appearance={}", .{err}); + }; + + return true; +} + +fn gtkWindowNotifyMaximized( + _: *c.GObject, + _: *c.GParamSpec, + ud: ?*anyopaque, +) callconv(.C) void { + const self = userdataSelf(ud orelse return); + + // Only toggle visibility of the header bar when we're using CSDs, + // and actually intend on displaying the header bar + if (!self.winproto.clientSideDecorationEnabled()) return; + + // If we aren't maximized, we should show the headerbar again + // if it was originally visible. + const maximized = c.gtk_window_is_maximized(self.window) != 0; + if (!maximized) { + self.headerbar.setVisible(self.app.config.@"gtk-titlebar"); + return; + } + + // If we are maximized, we should hide the headerbar if requested. + if (self.app.config.@"gtk-titlebar-hide-when-maximized") { + self.headerbar.setVisible(false); + } +} + +fn gtkWindowNotifyDecorated( + object: *c.GObject, + _: *c.GParamSpec, + _: ?*anyopaque, +) callconv(.C) void { + const is_decorated = c.gtk_window_get_decorated(@ptrCast(object)) == 1; + + // Fix any artifacting that may occur in window corners. The .ssd CSS + // class is defined in the GtkWindow documentation: + // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition + // for .ssd is provided by GTK and Adwaita. + toggleCssClass(@ptrCast(object), "ssd", !is_decorated); + toggleCssClass(@ptrCast(object), "no-border-radius", !is_decorated); +} + +fn gtkWindowNotifyFullscreened( + object: *c.GObject, + _: *c.GParamSpec, + ud: ?*anyopaque, +) callconv(.C) void { + const self = userdataSelf(ud orelse return); + const fullscreened = c.gtk_window_is_fullscreen(@ptrCast(object)) != 0; + if (!fullscreened) { + const csd_enabled = self.winproto.clientSideDecorationEnabled(); + self.headerbar.setVisible(self.app.config.@"gtk-titlebar" and csd_enabled); + return; + } + + self.headerbar.setVisible(false); +} + // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab // sends an undefined value. fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { @@ -894,10 +1033,6 @@ fn gtkActionCopy( log.warn("error performing binding action error={}", .{err}); return; }; - - if (self.app.config.@"adw-toast".@"clipboard-copy") { - self.sendToast("Copied to clipboard"); - } } fn gtkActionPaste( diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig index abd4821d36..4dc8ea57fd 100644 --- a/src/apprt/gtk/c.zig +++ b/src/apprt/gtk/c.zig @@ -11,9 +11,14 @@ pub const c = @cImport({ // Add in X11-specific GDK backend which we use for specific things // (e.g. X11 window class). @cInclude("gdk/x11/gdkx.h"); + @cInclude("X11/Xlib.h"); + @cInclude("X11/Xatom.h"); // 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/headerbar.zig b/src/apprt/gtk/headerbar.zig index 5bb92aca2e..0f7f15bf8a 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -4,70 +4,55 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const adwaita = @import("adwaita.zig"); -const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else void; +const HeaderBarAdw = @import("headerbar_adw.zig"); +const HeaderBarGtk = @import("headerbar_gtk.zig"); pub const HeaderBar = union(enum) { - adw: *AdwHeaderBar, - gtk: *c.GtkHeaderBar, - - pub fn init(window: *Window) HeaderBar { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and - adwaita.enabled(&window.app.config)) - { - return initAdw(); + adw: HeaderBarAdw, + gtk: HeaderBarGtk, + + pub fn init(self: *HeaderBar) void { + const window: *Window = @fieldParentPtr("headerbar", self); + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&window.app.config)) { + HeaderBarAdw.init(self); + } else { + HeaderBarGtk.init(self); } - - return initGtk(); - } - - fn initAdw() HeaderBar { - const headerbar = c.adw_header_bar_new(); - return .{ .adw = @ptrCast(headerbar) }; - } - - fn initGtk() HeaderBar { - const headerbar = c.gtk_header_bar_new(); - return .{ .gtk = @ptrCast(headerbar) }; } pub fn setVisible(self: HeaderBar, visible: bool) void { - c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); + switch (self) { + inline else => |v| v.setVisible(visible), + } } pub fn asWidget(self: HeaderBar) *c.GtkWidget { return switch (self) { - .adw => |headerbar| @ptrCast(@alignCast(headerbar)), - .gtk => |headerbar| @ptrCast(@alignCast(headerbar)), + inline else => |v| v.asWidget(), }; } pub fn packEnd(self: HeaderBar, widget: *c.GtkWidget) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - c.adw_header_bar_pack_end( - @ptrCast(@alignCast(headerbar)), - widget, - ); - }, - .gtk => |headerbar| c.gtk_header_bar_pack_end( - @ptrCast(@alignCast(headerbar)), - widget, - ), + inline else => |v| v.packEnd(widget), } } pub fn packStart(self: HeaderBar, widget: *c.GtkWidget) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - c.adw_header_bar_pack_start( - @ptrCast(@alignCast(headerbar)), - widget, - ); - }, - .gtk => |headerbar| c.gtk_header_bar_pack_start( - @ptrCast(@alignCast(headerbar)), - widget, - ), + inline else => |v| v.packStart(widget), + } + } + + pub fn setTitle(self: HeaderBar, title: [:0]const u8) void { + switch (self) { + inline else => |v| v.setTitle(title), + } + } + + pub fn setSubtitle(self: HeaderBar, subtitle: [:0]const u8) void { + switch (self) { + inline else => |v| v.setSubtitle(subtitle), } } }; diff --git a/src/apprt/gtk/headerbar_adw.zig b/src/apprt/gtk/headerbar_adw.zig new file mode 100644 index 0000000000..1ae23e6d91 --- /dev/null +++ b/src/apprt/gtk/headerbar_adw.zig @@ -0,0 +1,78 @@ +const HeaderBarAdw = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const adwaita = @import("adwaita.zig"); + +const HeaderBar = @import("headerbar.zig").HeaderBar; + +const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else anyopaque; +const AdwWindowTitle = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwWindowTitle else anyopaque; + +/// the window that this headerbar is attached to +window: *Window, +/// the Adwaita headerbar widget +headerbar: *AdwHeaderBar, +/// the Adwaita window title widget +title: *AdwWindowTitle, + +pub fn init(headerbar: *HeaderBar) void { + if (!adwaita.versionAtLeast(0, 0, 0)) return; + + const window: *Window = @fieldParentPtr("headerbar", headerbar); + headerbar.* = .{ + .adw = .{ + .window = window, + .headerbar = @ptrCast(@alignCast(c.adw_header_bar_new())), + .title = @ptrCast(@alignCast(c.adw_window_title_new( + c.gtk_window_get_title(window.window) orelse "Ghostty", + null, + ))), + }, + }; + c.adw_header_bar_set_title_widget( + headerbar.adw.headerbar, + @ptrCast(@alignCast(headerbar.adw.title)), + ); +} + +pub fn setVisible(self: HeaderBarAdw, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); +} + +pub fn asWidget(self: HeaderBarAdw) *c.GtkWidget { + return @ptrCast(@alignCast(self.headerbar)); +} + +pub fn packEnd(self: HeaderBarAdw, widget: *c.GtkWidget) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_header_bar_pack_end( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); + } +} + +pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_header_bar_pack_start( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); + } +} + +pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void { + c.gtk_window_set_title(self.window.window, title); + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_window_title_set_title(self.title, title); + } +} + +pub fn setSubtitle(self: HeaderBarAdw, subtitle: [:0]const u8) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_window_title_set_subtitle(self.title, subtitle); + } +} diff --git a/src/apprt/gtk/headerbar_gtk.zig b/src/apprt/gtk/headerbar_gtk.zig new file mode 100644 index 0000000000..63ba8b3895 --- /dev/null +++ b/src/apprt/gtk/headerbar_gtk.zig @@ -0,0 +1,52 @@ +const HeaderBarGtk = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const adwaita = @import("adwaita.zig"); + +const HeaderBar = @import("headerbar.zig").HeaderBar; + +/// the window that this headarbar is attached to +window: *Window, +/// the GTK headerbar widget +headerbar: *c.GtkHeaderBar, + +pub fn init(headerbar: *HeaderBar) void { + const window: *Window = @fieldParentPtr("headerbar", headerbar); + headerbar.* = .{ + .gtk = .{ + .window = window, + .headerbar = @ptrCast(c.gtk_header_bar_new()), + }, + }; +} + +pub fn setVisible(self: HeaderBarGtk, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); +} + +pub fn asWidget(self: HeaderBarGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.headerbar)); +} + +pub fn packEnd(self: HeaderBarGtk, widget: *c.GtkWidget) void { + c.gtk_header_bar_pack_end( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); +} + +pub fn packStart(self: HeaderBarGtk, widget: *c.GtkWidget) void { + c.gtk_header_bar_pack_start( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); +} + +pub fn setTitle(self: HeaderBarGtk, title: [:0]const u8) void { + c.gtk_window_set_title(self.window.window, title); +} + +pub fn setSubtitle(_: HeaderBarGtk, _: [:0]const u8) void {} diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index 0c5514ce8f..558175751c 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -143,6 +143,7 @@ const Window = struct { c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "inspector-window"); // Initialize our imgui widget diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 311bff0da4..40c9ca9a4b 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("build_options"); const input = @import("../../input.zig"); const c = @import("c.zig").c; -const x11 = @import("x11.zig"); +const winproto = @import("winproto.zig"); /// Returns a GTK accelerator string from a trigger. pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { @@ -105,34 +105,14 @@ pub fn keyvalUnicodeUnshifted( /// This requires a lot of context because the GdkEvent /// doesn't contain enough on its own. pub fn eventMods( - widget: *c.GtkWidget, event: *c.GdkEvent, physical_key: input.Key, gtk_mods: c.GdkModifierType, - x11_xkb: ?*x11.Xkb, + app_winproto: *winproto.App, ) input.Mods { const device = c.gdk_event_get_device(event); - var mods = mods: { - // Add any modifier state events from Xkb if we have them (X11 - // only). Null back from the Xkb call means there was no modifier - // event to read. This likely means that the key event did not - // result in a modifier change and we can safely rely on the GDK - // state. - if (comptime build_options.x11) { - const display = c.gtk_widget_get_display(widget); - if (x11_xkb) |xkb| { - if (xkb.modifier_state_from_notify(display)) |x11_mods| break :mods x11_mods; - break :mods translateMods(gtk_mods); - } - } - - // 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 - // pressed (i.e. left control). - break :mods translateMods(c.gdk_device_get_modifier_state(device)); - }; - + var mods = app_winproto.eventMods(device, gtk_mods); mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1; switch (physical_key) { diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 85083a97eb..790b3aa359 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -17,6 +17,14 @@ pub const NotebookAdw = struct { /// the tab view tab_view: *AdwTabView, + /// Set to true so that the adw close-page handler knows we're forcing + /// and to allow a close to happen with no confirm. This is a bit of a hack + /// because we currently use GTK alerts to confirm tab close and they + /// don't carry with them the ADW state that we are confirming or not. + /// Long term we should move to ADW alerts so we can know if we are + /// confirming or not. + forcing_close: bool = false, + pub fn init(notebook: *Notebook) void { const window: *Window = @fieldParentPtr("notebook", notebook); const app = window.app; @@ -38,6 +46,7 @@ pub const NotebookAdw = struct { }; _ = 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, "close-page", c.G_CALLBACK(&adwClosePage), 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); } @@ -112,11 +121,24 @@ pub const NotebookAdw = struct { pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + // closeTab always expects to close unconditionally so we mark this + // as true so that the close_page call below doesn't request + // confirmation. + self.forcing_close = true; + const n = self.nPages(); + defer { + // self becomes invalid if we close the last page because we close + // the whole window + if (n > 1) self.forcing_close = false; + } + 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) { + const window = tab.window.window; + // 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 @@ -128,7 +150,9 @@ pub const NotebookAdw = struct { c.g_object_unref(tab.box); } - c.gtk_window_destroy(tab.window.window); + // `self` will become invalid after this call because it will have + // been freed up as part of the process of closing the window. + c.gtk_window_destroy(window); } } }; @@ -143,6 +167,28 @@ fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaqu window.focusCurrentTab(); } +fn adwClosePage( + _: *AdwTabView, + page: *c.AdwTabPage, + ud: ?*anyopaque, +) callconv(.C) c.gboolean { + 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 0)); + + const window: *Window = @ptrCast(@alignCast(ud.?)); + const notebook = window.notebook.adw; + c.adw_tab_view_close_page_finish( + notebook.tab_view, + page, + @intFromBool(notebook.forcing_close), + ); + if (!notebook.forcing_close) tab.closeWithConfirmation(); + return 1; +} + fn adwTabViewCreateWindow( _: *AdwTabView, ud: ?*anyopaque, @@ -159,5 +205,5 @@ 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); + window.setTitle(std.mem.span(title)); } diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig index 6e8b016ba4..5f145dc840 100644 --- a/src/apprt/gtk/notebook_gtk.zig +++ b/src/apprt/gtk/notebook_gtk.zig @@ -157,8 +157,8 @@ pub const NotebookGtk = struct { 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); + _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(>kTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(>kTabClick), tab, null, c.G_CONNECT_DEFAULT); // Tab settings c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); @@ -259,7 +259,7 @@ fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaqu 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); + window.setTitle(std.mem.span(label_text)); } fn gtkNotebookCreateWindow( @@ -283,3 +283,22 @@ fn gtkNotebookCreateWindow( return newWindow.notebook.gtk.notebook; } + +fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { + const tab: *Tab = @ptrCast(@alignCast(ud)); + tab.closeWithConfirmation(); +} + +fn gtkTabClick( + gesture: *c.GtkGestureClick, + _: c.gint, + _: c.gdouble, + _: c.gdouble, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Tab = @ptrCast(@alignCast(ud)); + const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); + if (gtk_button == c.GDK_BUTTON_MIDDLE) { + self.closeWithConfirmation(); + } +} diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index bf0ee62f69..d1e848ac61 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -33,7 +33,11 @@ label.size-overlay.hidden { opacity: 0; } -window.without-window-decoration-and-with-titlebar { +window.ssd.no-border-radius { + /* Without clearing the border radius, at least on Mutter with + * gtk-titlebar=true and gtk-adwaita=false, there is some window artifacting + * that this will mitigate. + */ border-radius: 0 0; } diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig index af7ad12ea0..d8686fa28a 100644 --- a/src/apprt/gtk/version.zig +++ b/src/apprt/gtk/version.zig @@ -7,6 +7,11 @@ const c = @import("c.zig").c; /// in the headers. If it is run in a runtime context, it will /// check the actual version of the library we are linked against. /// +/// This function should be used in cases where the version check +/// would affect code generation, such as using symbols that are +/// only available beyond a certain version. For checks which only +/// depend on GTK's runtime behavior, use `runtimeAtLeast`. +/// /// This is inlined so that the comptime checks will disable the /// runtime checks if the comptime checks fail. pub inline fn atLeast( @@ -26,6 +31,20 @@ pub inline fn atLeast( // If we're in comptime then we can't check the runtime version. if (@inComptime()) return true; + return runtimeAtLeast(major, minor, micro); +} + +/// Verifies that the GTK version at runtime is at least the given +/// version. +/// +/// This function should be used in cases where the only the runtime +/// behavior is affected by the version check. For checks which would +/// affect code generation, use `atLeast`. +pub inline fn runtimeAtLeast( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { // We use the functions instead of the constants such as // c.GTK_MINOR_VERSION because the function gets the actual // runtime version. @@ -44,15 +63,18 @@ test "atLeast" { const std = @import("std"); const testing = std.testing; - try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + const funs = &.{ atLeast, runtimeAtLeast }; + inline for (funs) |fun| { + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + } } diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig new file mode 100644 index 0000000000..e6020f49e8 --- /dev/null +++ b/src/apprt/gtk/winproto.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const Config = @import("../../config.zig").Config; +const input = @import("../../input.zig"); +const key = @import("key.zig"); + +pub const noop = @import("winproto/noop.zig"); +pub const x11 = @import("winproto/x11.zig"); +pub const wayland = @import("winproto/wayland.zig"); + +pub const Protocol = enum { + none, + wayland, + x11, +}; + +/// App-state for the underlying windowing protocol. There should be one +/// instance of this struct per application. +pub const App = union(Protocol) { + none: noop.App, + wayland: if (build_options.wayland) wayland.App else noop.App, + x11: if (build_options.x11) x11.App else noop.App, + + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !App { + inline for (@typeInfo(App).Union.fields) |field| { + if (try field.type.init( + alloc, + gdk_display, + app_id, + config, + )) |v| { + return @unionInit(App, field.name, v); + } + } + + return .{ .none = .{} }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + switch (self.*) { + inline else => |*v| v.deinit(alloc), + } + } + + pub fn eventMods( + self: *App, + device: ?*c.GdkDevice, + gtk_mods: c.GdkModifierType, + ) input.Mods { + return switch (self.*) { + inline else => |*v| v.eventMods(device, gtk_mods), + } orelse key.translateMods(gtk_mods); + } +}; + +/// Per-Window state for the underlying windowing protocol. +/// +/// In Wayland, the terminology used is "Surface" and for it, this is +/// really "Surface"-specific state. But Ghostty uses the term "Surface" +/// heavily to mean something completely different, so we use "Window" here +/// to better match what it generally maps to in the Ghostty codebase. +pub const Window = union(Protocol) { + none: noop.Window, + wayland: if (build_options.wayland) wayland.Window else noop.Window, + x11: if (build_options.x11) x11.Window else noop.Window, + + pub fn init( + alloc: Allocator, + app: *App, + window: *c.GtkWindow, + config: *const Config, + ) !Window { + return switch (app.*) { + inline else => |*v, tag| { + inline for (@typeInfo(Window).Union.fields) |field| { + if (comptime std.mem.eql( + u8, + field.name, + @tagName(tag), + )) return @unionInit( + Window, + field.name, + try field.type.init( + alloc, + v, + window, + config, + ), + ); + } + }, + }; + } + + pub fn deinit(self: *Window, alloc: Allocator) void { + switch (self.*) { + inline else => |*v| v.deinit(alloc), + } + } + + pub fn resizeEvent(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.resizeEvent(), + } + } + + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + switch (self.*) { + inline else => |*v| try v.updateConfigEvent(config), + } + } + + pub fn syncAppearance(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.syncAppearance(), + } + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self) { + inline else => |v| v.clientSideDecorationEnabled(), + }; + } +}; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig new file mode 100644 index 0000000000..38703aecbe --- /dev/null +++ b/src/apprt/gtk/winproto/noop.zig @@ -0,0 +1,64 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); + +const log = std.log.scoped(.winproto_noop); + +pub const App = struct { + pub fn init( + _: Allocator, + _: *c.GdkDisplay, + _: [:0]const u8, + _: *const Config, + ) !?App { + return null; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn eventMods( + _: *App, + _: ?*c.GdkDevice, + _: c.GdkModifierType, + ) ?input.Mods { + return null; + } +}; + +pub const Window = struct { + pub fn init( + _: Allocator, + _: *App, + _: *c.GtkWindow, + _: *const Config, + ) !Window { + return .{}; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn updateConfigEvent( + _: *Window, + _: *const Config, + ) !void {} + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(_: *Window) !void {} + + /// This returns true if CSD is enabled for this window. This + /// should be the actual present state of the window, not the + /// desired state. + pub fn clientSideDecorationEnabled(self: Window) bool { + _ = self; + return true; + } +}; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig new file mode 100644 index 0000000000..8df3e57b32 --- /dev/null +++ b/src/apprt/gtk/winproto/wayland.zig @@ -0,0 +1,302 @@ +//! Wayland protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const wayland = @import("wayland"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); + +const wl = wayland.client.wl; +const org = wayland.client.org; + +const log = std.log.scoped(.winproto_wayland); + +/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). +pub const App = struct { + display: *wl.Display, + context: *Context, + + const Context = struct { + kde_blur_manager: ?*org.KdeKwinBlurManager = null, + + // FIXME: replace with `zxdg_decoration_v1` once GTK merges + // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 + kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, + + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, + }; + + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = config; + _ = app_id; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_display)), + c.gdk_wayland_display_get_type(), + ) == 0) return null; + + const display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display( + gdk_display, + ) orelse return error.NoWaylandDisplay); + + // Create our context for our callbacks so we have a stable pointer. + // Note: at the time of writing this comment, we don't really need + // a stable pointer, but it's too scary that we'd need one in the future + // and not have it and corrupt memory or something so let's just do it. + const context = try alloc.create(Context); + errdefer alloc.destroy(context); + context.* = .{}; + + // Get our display registry so we can get all the available interfaces + // and bind to what we need. + const registry = try display.getRegistry(); + registry.setListener(*Context, registryListener, context); + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + if (context.kde_decoration_manager != null) { + // FIXME: Roundtrip again because we have to wait for the decoration + // manager to respond with the preferred default mode. Ew. + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + } + + return .{ + .display = display, + .context = context, + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + alloc.destroy(self.context); + } + + pub fn eventMods( + _: *App, + _: ?*c.GdkDevice, + _: c.GdkModifierType, + ) ?input.Mods { + return null; + } + + fn registryListener( + registry: *wl.Registry, + event: wl.Registry.Event, + context: *Context, + ) void { + switch (event) { + // https://wayland.app/protocols/wayland#wl_registry:event:global + .global => |global| { + log.debug("wl_registry.global: interface={s}", .{global.interface}); + + if (registryBind( + org.KdeKwinBlurManager, + registry, + global, + )) |blur_manager| { + context.kde_blur_manager = blur_manager; + } else if (registryBind( + org.KdeKwinServerDecorationManager, + registry, + global, + )) |deco_manager| { + context.kde_decoration_manager = deco_manager; + deco_manager.setListener(*Context, decoManagerListener, context); + } + }, + + // We don't handle removal events + .global_remove => {}, + } + } + + /// Bind a Wayland interface to a global object. Returns non-null + /// if the binding was successful, otherwise null. + /// + /// The type T is the Wayland interface type that we're requesting. + /// This function will verify that the global object is the correct + /// interface and version before binding. + fn registryBind( + comptime T: type, + registry: *wl.Registry, + global: anytype, + ) ?*T { + if (std.mem.orderZ( + u8, + global.interface, + T.interface.name, + ) != .eq) return null; + + return registry.bind(global.name, T, T.generated_version) catch |err| { + log.warn("error binding interface {s} error={}", .{ + global.interface, + err, + }); + return null; + }; + } + + fn decoManagerListener( + _: *org.KdeKwinServerDecorationManager, + event: org.KdeKwinServerDecorationManager.Event, + context: *Context, + ) void { + switch (event) { + .default_mode => |mode| { + context.default_deco_mode = @enumFromInt(mode.mode); + }, + } + } +}; + +/// Per-window (wl_surface) state for the Wayland protocol. +pub const Window = struct { + config: DerivedConfig, + + /// The Wayland surface for this window. + surface: *wl.Surface, + + /// The context from the app where we can load our Wayland interfaces. + app_context: *App.Context, + + /// A token that, when present, indicates that the window is blurred. + blur_token: ?*org.KdeKwinBlur, + + /// Object that controls the decoration mode (client/server/auto) + /// of the window. + decoration: ?*org.KdeKwinServerDecoration, + + const DerivedConfig = struct { + blur: bool, + window_decoration: Config.WindowDecoration, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur".enabled(), + .window_decoration = config.@"window-decoration", + }; + } + }; + + pub fn init( + alloc: Allocator, + app: *App, + gtk_window: *c.GtkWindow, + config: *const Config, + ) !Window { + _ = alloc; + + const gdk_surface = c.gtk_native_get_surface( + @ptrCast(gtk_window), + ) orelse return error.NotWaylandSurface; + + // This should never fail, because if we're being called at this point + // then we've already asserted that our app state is Wayland. + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_surface)), + c.gdk_wayland_surface_get_type(), + ) == 0) return error.NotWaylandSurface; + + const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface( + gdk_surface, + ) orelse return error.NoWaylandSurface); + + // Get our decoration object so we can control the + // CSD vs SSD status of this surface. + const deco: ?*org.KdeKwinServerDecoration = deco: { + const mgr = app.context.kde_decoration_manager orelse + break :deco null; + + const deco: *org.KdeKwinServerDecoration = mgr.create( + wl_surface, + ) catch |err| { + log.warn("could not create decoration object={}", .{err}); + break :deco null; + }; + + break :deco deco; + }; + + return .{ + .config = DerivedConfig.init(config), + .surface = wl_surface, + .app_context = app.context, + .blur_token = null, + .decoration = deco, + }; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = alloc; + if (self.blur_token) |blur| blur.release(); + if (self.decoration) |deco| deco.release(); + } + + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + self.config = DerivedConfig.init(config); + } + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(self: *Window) !void { + try self.syncBlur(); + try self.syncDecoration(); + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + // Compositor doesn't support the SSD protocol + if (self.decoration == null) return true; + + return switch (self.getDecorationMode()) { + .Client => true, + .Server, .None => false, + else => unreachable, + }; + } + + /// Update the blur state of the window. + fn syncBlur(self: *Window) !void { + const manager = self.app_context.kde_blur_manager orelse return; + const blur = self.config.blur; + + if (self.blur_token) |tok| { + // Only release token when transitioning from blurred -> not blurred + if (!blur) { + manager.unset(self.surface); + tok.release(); + self.blur_token = null; + } + } else { + // Only acquire token when transitioning from not blurred -> blurred + if (blur) { + const tok = try manager.create(self.surface); + tok.commit(); + self.blur_token = tok; + } + } + } + + fn syncDecoration(self: *Window) !void { + const deco = self.decoration orelse return; + + // The protocol requests uint instead of enum so we have + // to convert it. + deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode()))); + } + + fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { + return switch (self.config.window_decoration) { + .auto => self.app_context.default_deco_mode orelse .Client, + .client => .Client, + .server => .Server, + .none => .None, + }; + } +}; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig new file mode 100644 index 0000000000..7a6b8b4c78 --- /dev/null +++ b/src/apprt/gtk/winproto/x11.zig @@ -0,0 +1,302 @@ +//! X11 window protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const input = @import("../../../input.zig"); +const Config = @import("../../../config.zig").Config; +const adwaita = @import("../adwaita.zig"); + +const log = std.log.scoped(.gtk_x11); + +pub const App = struct { + display: *c.Display, + base_event_code: c_int, + kde_blur_atom: c.Atom, + + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = alloc; + + // If the display isn't X11, then we don't need to do anything. + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_display)), + c.gdk_x11_display_get_type(), + ) == 0) return null; + + // Get our X11 display + const display: *c.Display = c.gdk_x11_display_get_xdisplay( + gdk_display, + ) orelse return error.NoX11Display; + + const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| + pn + else if (builtin.mode == .Debug) + "ghostty-debug" + else + "ghostty"; + + // Set the X11 window class property (WM_CLASS) if are are on an X11 + // display. + // + // Note that we also set the program name here using g_set_prgname. + // This is how the instance name field for WM_CLASS is derived when + // calling gdk_x11_display_set_program_class; there does not seem to be + // a way to set it directly. It does not look like this is being set by + // our other app initialization routines currently, but since we're + // currently deriving its value from x11-instance-name effectively, I + // feel like gating it behind an X11 check is better intent. + // + // This makes the property show up like so when using xprop: + // + // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" + // + // Append "-debug" on both when using the debug build. + c.g_set_prgname(x11_program_name); + c.gdk_x11_display_set_program_class(gdk_display, app_id); + + // XKB + log.debug("Xkb.init: initializing Xkb", .{}); + log.debug("Xkb.init: running XkbQueryExtension", .{}); + var opcode: c_int = 0; + var base_event_code: c_int = 0; + var base_error_code: c_int = 0; + var major = c.XkbMajorVersion; + var minor = c.XkbMinorVersion; + if (c.XkbQueryExtension( + display, + &opcode, + &base_event_code, + &base_error_code, + &major, + &minor, + ) == 0) { + log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{}); + return error.XkbInitializationError; + } + + log.debug("Xkb.init: running XkbSelectEventDetails", .{}); + if (c.XkbSelectEventDetails( + display, + c.XkbUseCoreKbd, + c.XkbStateNotify, + c.XkbModifierStateMask, + c.XkbModifierStateMask, + ) == 0) { + log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{}); + return error.XkbInitializationError; + } + + return .{ + .display = display, + .base_event_code = base_event_code, + .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display( + gdk_display, + "_KDE_NET_WM_BLUR_BEHIND_REGION", + ), + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + /// Checks for an immediate pending XKB state update event, and returns the + /// keyboard state based on if it finds any. This is necessary as the + /// standard GTK X11 API (and X11 in general) does not include the current + /// key pressed in any modifier state snapshot for that event (e.g. if the + /// pressed key is a modifier, that is not necessarily reflected in the + /// modifiers). + /// + /// Returns null if there is no event. In this case, the caller should fall + /// back to the standard GDK modifier state (this likely means the key + /// event did not result in a modifier change). + pub fn eventMods( + self: App, + device: ?*c.GdkDevice, + gtk_mods: c.GdkModifierType, + ) ?input.Mods { + _ = device; + _ = gtk_mods; + + // Shoutout to Mozilla for figuring out a clean way to do this, this is + // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. + if (c.XEventsQueued(self.display, c.QueuedAfterReading) == 0) return null; + + var nextEvent: c.XEvent = undefined; + _ = c.XPeekEvent(self.display, &nextEvent); + if (nextEvent.type != self.base_event_code) return null; + + const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); + if (xkb_event.any.xkb_type != c.XkbStateNotify) return null; + + const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event); + // Check the state according to XKB masks. + const lookup_mods = xkb_state_notify_event.lookup_mods; + var mods: input.Mods = .{}; + + log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods}); + if (lookup_mods & c.ShiftMask != 0) mods.shift = true; + if (lookup_mods & c.ControlMask != 0) mods.ctrl = true; + if (lookup_mods & c.Mod1Mask != 0) mods.alt = true; + if (lookup_mods & c.Mod4Mask != 0) mods.super = true; + if (lookup_mods & c.LockMask != 0) mods.caps_lock = true; + + return mods; + } +}; + +pub const Window = struct { + app: *App, + config: DerivedConfig, + window: c.Window, + gtk_window: *c.GtkWindow, + blur_region: Region, + + const DerivedConfig = struct { + blur: bool, + has_decoration: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur".enabled(), + .has_decoration = switch (config.@"window-decoration") { + .none => false, + .auto, .client, .server => true, + }, + }; + } + }; + + pub fn init( + _: Allocator, + app: *App, + gtk_window: *c.GtkWindow, + config: *const Config, + ) !Window { + const surface = c.gtk_native_get_surface( + @ptrCast(gtk_window), + ) orelse return error.NotX11Surface; + + // Check if we're actually on X11 + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(surface)), + c.gdk_x11_surface_get_type(), + ) == 0) return error.NotX11Surface; + + const blur_region: Region = blur: { + if ((comptime !adwaita.versionAtLeast(0, 0, 0)) or + !adwaita.enabled(config)) break :blur .{}; + + // NOTE(pluiedev): CSDs are a f--king mistake. + // Please, GNOME, stop this nonsense of making a window ~30% bigger + // internally than how they really are just for your shadows and + // rounded corners and all that fluff. Please. I beg of you. + var x: f64 = 0; + var y: f64 = 0; + c.gtk_native_get_surface_transform( + @ptrCast(gtk_window), + &x, + &y, + ); + + break :blur .{ + .x = @intFromFloat(x), + .y = @intFromFloat(y), + }; + }; + + return .{ + .app = app, + .config = DerivedConfig.init(config), + .window = c.gdk_x11_surface_get_xid(surface), + .gtk_window = gtk_window, + .blur_region = blur_region, + }; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + self.config = DerivedConfig.init(config); + } + + pub fn resizeEvent(self: *Window) !void { + // The blur region must update with window resizes + self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.gtk_window)); + self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.gtk_window)); + try self.syncBlur(); + } + + pub fn syncAppearance(self: *Window) !void { + try self.syncBlur(); + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return self.config.has_decoration; + } + + fn syncBlur(self: *Window) !void { + // FIXME: This doesn't currently factor in rounded corners on Adwaita, + // which means that the blur region will grow slightly outside of the + // window borders. Unfortunately, actually calculating the rounded + // region can be quite complex without having access to existing APIs + // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) + // and I think it's not really noticeable enough to justify the effort. + // (Wayland also has this visual artifact anyway...) + + const blur = self.config.blur; + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.window, + self.blur_region, + }); + + if (blur) { + _ = c.XChangeProperty( + self.app.display, + self.window, + self.app.kde_blur_atom, + c.XA_CARDINAL, + // Despite what you might think, the "32" here does NOT mean + // that the data should be in u32s. Instead, they should be + // c_longs, which on any 64-bit architecture would be obviously + // 64 bits. WTF?! + 32, + c.PropModeReplace, + // SAFETY: Region is an extern struct that has the same + // representation of 4 c_longs put next to each other. + // Therefore, reinterpretation should be safe. + // We don't have to care about endianness either since + // Xlib converts it to network byte order for us. + @ptrCast(std.mem.asBytes(&self.blur_region)), + 4, + ); + } else { + _ = c.XDeleteProperty( + self.app.display, + self.window, + self.app.kde_blur_atom, + ); + } + } +}; + +const Region = extern struct { + x: c_long = 0, + y: c_long = 0, + width: c_long = 0, + height: c_long = 0, +}; diff --git a/src/apprt/gtk/x11.zig b/src/apprt/gtk/x11.zig deleted file mode 100644 index 21ff87b34f..0000000000 --- a/src/apprt/gtk/x11.zig +++ /dev/null @@ -1,119 +0,0 @@ -/// Utility functions for X11 handling. -const std = @import("std"); -const build_options = @import("build_options"); -const c = @import("c.zig").c; -const input = @import("../../input.zig"); - -const log = std.log.scoped(.gtk_x11); - -/// Returns true if the passed in display is an X11 display. -pub fn is_display(display: ?*c.GdkDisplay) bool { - if (comptime !build_options.x11) return false; - return c.g_type_check_instance_is_a( - @ptrCast(@alignCast(display orelse return false)), - c.gdk_x11_display_get_type(), - ) != 0; -} - -/// Returns true if the app is running on X11 -pub fn is_current_display_server() bool { - if (comptime !build_options.x11) return false; - const display = c.gdk_display_get_default(); - return is_display(display); -} - -pub const Xkb = struct { - base_event_code: c_int, - - /// Initialize an Xkb struct for the given GDK display. If the display isn't - /// backed by X then this will return null. - pub fn init(display_: ?*c.GdkDisplay) !?Xkb { - if (comptime !build_options.x11) return null; - - // Display should never be null but we just treat that as a non-X11 - // display so that the caller can just ignore it and not unwrap it. - const display = display_ orelse return null; - - // If the display isn't X11, then we don't need to do anything. - if (!is_display(display)) return null; - - log.debug("Xkb.init: initializing Xkb", .{}); - const xdisplay = c.gdk_x11_display_get_xdisplay(display); - var result: Xkb = .{ - .base_event_code = 0, - }; - - log.debug("Xkb.init: running XkbQueryExtension", .{}); - var opcode: c_int = 0; - var base_error_code: c_int = 0; - var major = c.XkbMajorVersion; - var minor = c.XkbMinorVersion; - if (c.XkbQueryExtension( - xdisplay, - &opcode, - &result.base_event_code, - &base_error_code, - &major, - &minor, - ) == 0) { - log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{}); - return error.XkbInitializationError; - } - - log.debug("Xkb.init: running XkbSelectEventDetails", .{}); - if (c.XkbSelectEventDetails( - xdisplay, - c.XkbUseCoreKbd, - c.XkbStateNotify, - c.XkbModifierStateMask, - c.XkbModifierStateMask, - ) == 0) { - log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{}); - return error.XkbInitializationError; - } - - return result; - } - - /// Checks for an immediate pending XKB state update event, and returns the - /// keyboard state based on if it finds any. This is necessary as the - /// standard GTK X11 API (and X11 in general) does not include the current - /// key pressed in any modifier state snapshot for that event (e.g. if the - /// pressed key is a modifier, that is not necessarily reflected in the - /// modifiers). - /// - /// Returns null if there is no event. In this case, the caller should fall - /// back to the standard GDK modifier state (this likely means the key - /// event did not result in a modifier change). - pub fn modifier_state_from_notify(self: Xkb, display_: ?*c.GdkDisplay) ?input.Mods { - if (comptime !build_options.x11) return null; - - const display = display_ orelse return null; - - // Shoutout to Mozilla for figuring out a clean way to do this, this is - // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. - const xdisplay = c.gdk_x11_display_get_xdisplay(display); - if (c.XEventsQueued(xdisplay, c.QueuedAfterReading) == 0) return null; - - var nextEvent: c.XEvent = undefined; - _ = c.XPeekEvent(xdisplay, &nextEvent); - if (nextEvent.type != self.base_event_code) return null; - - const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); - if (xkb_event.any.xkb_type != c.XkbStateNotify) return null; - - const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event); - // Check the state according to XKB masks. - const lookup_mods = xkb_state_notify_event.lookup_mods; - var mods: input.Mods = .{}; - - log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods}); - if (lookup_mods & c.ShiftMask != 0) mods.shift = true; - if (lookup_mods & c.ControlMask != 0) mods.ctrl = true; - if (lookup_mods & c.Mod1Mask != 0) mods.alt = true; - if (lookup_mods & c.Mod4Mask != 0) mods.super = true; - if (lookup_mods & c.LockMask != 0) mods.caps_lock = true; - - return mods; - } -}; diff --git a/src/build/Config.zig b/src/build/Config.zig new file mode 100644 index 0000000000..c6f0e6d093 --- /dev/null +++ b/src/build/Config.zig @@ -0,0 +1,527 @@ +/// Build configuration. This is the configuration that is populated +/// during `zig build` to control the rest of the build process. +const Config = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); + +const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); +const renderer = @import("../renderer.zig"); +const Command = @import("../Command.zig"); +const WasmTarget = @import("../os/wasm/target.zig").Target; + +const gtk = @import("gtk.zig"); +const GitVersion = @import("GitVersion.zig"); + +/// The version of the next release. +/// +/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly. +/// Until then this MUST match build.zig.zon and should always be the +/// _next_ version to release. +const app_version: std.SemanticVersion = .{ .major = 1, .minor = 0, .patch = 2 }; + +/// Standard build configuration options. +optimize: std.builtin.OptimizeMode, +target: std.Build.ResolvedTarget, +wasm_target: WasmTarget, + +/// Comptime interfaces +app_runtime: apprt.Runtime = .none, +renderer: renderer.Impl = .opengl, +font_backend: font.Backend = .freetype, + +/// Feature flags +adwaita: bool = false, +x11: bool = false, +wayland: bool = false, +sentry: bool = true, +wasm_shared: bool = true, + +/// Ghostty exe properties +exe_entrypoint: ExeEntrypoint = .ghostty, +version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + +/// Binary properties +pie: bool = false, +strip: bool = false, +patch_rpath: ?[]const u8 = null, + +/// Artifacts +flatpak: bool = false, +emit_test_exe: bool = false, +emit_bench: bool = false, +emit_helpgen: bool = false, +emit_docs: bool = false, +emit_webdata: bool = false, +emit_xcframework: bool = false, +emit_terminfo: bool = false, +emit_termcap: bool = false, + +/// Environmental properties +env: std.process.EnvMap, + +pub fn init(b: *std.Build) !Config { + // Setup our standard Zig target and optimize options, i.e. + // `-Doptimize` and `-Dtarget`. + const optimize = b.standardOptimizeOption(.{}); + const target = target: { + var result = b.standardTargetOptions(.{}); + + // If we're building for macOS and we're on macOS, we need to + // use a generic target to workaround compilation issues. + if (result.result.os.tag == .macos and builtin.target.isDarwin()) { + result = genericMacOSTarget(b, null); + } + + // If we have no minimum OS version, we set the default based on + // our tag. Not all tags have a minimum so this may be null. + if (result.query.os_version_min == null) { + result.query.os_version_min = osVersionMin(result.result.os.tag); + } + + break :target result; + }; + + // This is set to true when we're building a system package. For now + // this is trivially detected using the "system_package_mode" bool + // but we may want to make this more sophisticated in the future. + const system_package: bool = b.graph.system_package_mode; + + // This specifies our target wasm runtime. For now only one semi-usable + // one exists so this is hardcoded. + const wasm_target: WasmTarget = .browser; + + // Determine whether GTK supports X11 and Wayland. This is always safe + // to run even on non-Linux platforms because any failures result in + // defaults. + const gtk_targets = gtk.targets(b); + + // We use env vars throughout the build so we grab them immediately here. + var env = try std.process.getEnvMap(b.allocator); + errdefer env.deinit(); + + var config: Config = .{ + .optimize = optimize, + .target = target, + .wasm_target = wasm_target, + .env = env, + }; + + //--------------------------------------------------------------- + // Comptime Interfaces + + config.font_backend = b.option( + font.Backend, + "font-backend", + "The font backend to use for discovery and rasterization.", + ) orelse font.Backend.default(target.result, wasm_target); + + config.app_runtime = b.option( + apprt.Runtime, + "app-runtime", + "The app runtime to use. Not all values supported on all platforms.", + ) orelse apprt.Runtime.default(target.result); + + config.renderer = b.option( + renderer.Impl, + "renderer", + "The app runtime to use. Not all values supported on all platforms.", + ) orelse renderer.Impl.default(target.result, wasm_target); + + //--------------------------------------------------------------- + // Feature Flags + + config.adwaita = b.option( + bool, + "gtk-adwaita", + "Enables the use of Adwaita when using the GTK rendering backend.", + ) orelse true; + + config.flatpak = b.option( + bool, + "flatpak", + "Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.", + ) orelse false; + + 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, + } + }; + + config.wayland = b.option( + bool, + "gtk-wayland", + "Enables linking against Wayland libraries when using the GTK rendering backend.", + ) orelse gtk_targets.wayland; + + config.x11 = b.option( + bool, + "gtk-x11", + "Enables linking against X11 libraries when using the GTK rendering backend.", + ) orelse gtk_targets.x11; + + //--------------------------------------------------------------- + // Ghostty Exe Properties + + const version_string = b.option( + []const u8, + "version-string", + "A specific version string to use for the build. " ++ + "If not specified, git will be used. This must be a semantic version.", + ); + + config.version = if (version_string) |v| + // If an explicit version is given, we always use it. + try std.SemanticVersion.parse(v) + else version: { + // If no explicit version is given, we try to detect it from git. + const vsn = GitVersion.detect(b) catch |err| switch (err) { + // If Git isn't available we just make an unknown dev version. + error.GitNotFound, + error.GitNotRepository, + => break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + .pre = "dev", + .build = "0000000", + }, + + else => return err, + }; + if (vsn.tag) |tag| { + // Tip releases behave just like any other pre-release so we skip. + if (!std.mem.eql(u8, tag, "tip")) { + const expected = b.fmt("v{d}.{d}.{d}", .{ + app_version.major, + app_version.minor, + app_version.patch, + }); + + if (!std.mem.eql(u8, tag, expected)) { + @panic("tagged releases must be in vX.Y.Z format matching build.zig"); + } + + break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; + } + } + + break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + .pre = vsn.branch, + .build = vsn.short_hash, + }; + }; + + //--------------------------------------------------------------- + // Binary Properties + + // On NixOS, the built binary from `zig build` needs to patch the rpath + // into the built binary for it to be portable across the NixOS system + // it was built for. We default this to true if we can detect we're in + // a Nix shell and have LD_LIBRARY_PATH set. + config.patch_rpath = b.option( + []const u8, + "patch-rpath", + "Inject the LD_LIBRARY_PATH as the rpath in the built binary. " ++ + "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment on NixOS.", + ) orelse patch_rpath: { + // We only do the patching if we're targeting our own CPU and its Linux. + if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath null; + + // If we're in a nix shell we default to doing this. + // Note: we purposely never deinit envmap because we leak the strings + if (env.get("IN_NIX_SHELL") == null) break :patch_rpath null; + break :patch_rpath env.get("LD_LIBRARY_PATH"); + }; + + config.pie = b.option( + bool, + "pie", + "Build a Position Independent Executable. Default true for system packages.", + ) orelse system_package; + + config.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, + }; + + //--------------------------------------------------------------- + // Artifacts to Emit + + config.emit_test_exe = b.option( + bool, + "emit-test-exe", + "Build and install test executables with 'build'", + ) orelse false; + + config.emit_bench = b.option( + bool, + "emit-bench", + "Build and install the benchmark executables.", + ) orelse false; + + config.emit_helpgen = b.option( + bool, + "emit-helpgen", + "Build and install the helpgen executable.", + ) orelse false; + + config.emit_docs = b.option( + bool, + "emit-docs", + "Build and install auto-generated documentation (requires pandoc)", + ) orelse emit_docs: { + // If we are emitting any other artifacts then we default to false. + if (config.emit_bench or + config.emit_test_exe or + config.emit_helpgen) break :emit_docs false; + + // We always emit docs in system package mode. + if (system_package) break :emit_docs true; + + // We only default to true if we can find pandoc. + const path = Command.expandPath(b.allocator, "pandoc") catch + break :emit_docs false; + defer if (path) |p| b.allocator.free(p); + break :emit_docs path != null; + }; + + config.emit_terminfo = b.option( + bool, + "emit-terminfo", + "Install Ghostty terminfo source file", + ) orelse switch (target.result.os.tag) { + .windows => true, + else => switch (optimize) { + .Debug => true, + .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, + }, + }; + + config.emit_termcap = b.option( + bool, + "emit-termcap", + "Install Ghostty termcap file", + ) orelse switch (optimize) { + .Debug => true, + .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, + }; + + config.emit_webdata = b.option( + bool, + "emit-webdata", + "Build the website data for the website.", + ) orelse false; + + config.emit_xcframework = b.option( + bool, + "emit-xcframework", + "Build and install the xcframework for the macOS library.", + ) orelse builtin.target.isDarwin() and + target.result.os.tag == .macos and + config.app_runtime == .none and + (!config.emit_bench and + !config.emit_test_exe and + !config.emit_helpgen); + + //--------------------------------------------------------------- + // System Packages + + // These are all our dependencies that can be used with system + // packages if they exist. We set them up here so that we can set + // their defaults early. The first call configures the integration and + // subsequent calls just return the configured value. This lets them + // show up properly in `--help`. + + { + // These dependencies we want to default false if we're on macOS. + // On macOS we don't want to use system libraries because we + // generally want a fat binary. This can be overridden with the + // `-fsys` flag. + for (&[_][]const u8{ + "freetype", + "harfbuzz", + "fontconfig", + "libpng", + "zlib", + "oniguruma", + }) |dep| { + _ = b.systemIntegrationOption( + dep, + .{ + // If we're not on darwin we want to use whatever the + // default is via the system package mode + .default = if (target.result.isDarwin()) false else null, + }, + ); + } + + // These default to false because they're rarely available as + // system packages so we usually want to statically link them. + for (&[_][]const u8{ + "glslang", + "spirv-cross", + "simdutf", + }) |dep| { + _ = b.systemIntegrationOption(dep, .{ .default = false }); + } + } + + return config; +} + +/// Configure the build options with our values. +pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { + // We need to break these down individual because addOption doesn't + // support all types. + 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(renderer.Impl, "renderer", self.renderer); + step.addOption(ExeEntrypoint, "exe_entrypoint", self.exe_entrypoint); + step.addOption(WasmTarget, "wasm_target", self.wasm_target); + step.addOption(bool, "wasm_shared", self.wasm_shared); + + // Our version. We also add the string version so we don't need + // to do any allocations at runtime. This has to be long enough to + // accommodate realistic large branch names for dev versions. + var buf: [1024]u8 = undefined; + step.addOption(std.SemanticVersion, "app_version", self.version); + step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ( + &buf, + "{}", + .{self.version}, + )); + step.addOption( + ReleaseChannel, + "release_channel", + channel: { + const pre = self.version.pre orelse break :channel .stable; + if (pre.len == 0) break :channel .stable; + break :channel .tip; + }, + ); +} + +/// Rehydrate our Config from the comptime options. Note that not all +/// options are available at comptime, so look closely at this implementation +/// to see what is and isn't available. +pub fn fromOptions() Config { + const options = @import("build_options"); + return .{ + // Unused at runtime. + .optimize = undefined, + .target = undefined, + .env = undefined, + + .version = options.app_version, + .flatpak = options.flatpak, + .adwaita = options.adwaita, + .app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?, + .font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?, + .renderer = std.meta.stringToEnum(renderer.Impl, @tagName(options.renderer)).?, + .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, + .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, + .wasm_shared = options.wasm_shared, + }; +} + +/// Returns the minimum OS version for the given OS tag. This shouldn't +/// be used generally, it should only be used for Darwin-based OS currently. +pub fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { + return switch (tag) { + // We support back to the earliest officially supported version + // of macOS by Apple. EOL versions are not supported. + .macos => .{ .semver = .{ + .major = 13, + .minor = 0, + .patch = 0, + } }, + + // iOS 17 picked arbitrarily + .ios => .{ .semver = .{ + .major = 17, + .minor = 0, + .patch = 0, + } }, + + // This should never happen currently. If we add a new target then + // we should add a new case here. + else => null, + }; +} + +// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`. +// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`. +// +// This is used to workaround compilation issues on macOS. +// (see for example https://github.com/mitchellh/ghostty/issues/1640). +pub fn genericMacOSTarget( + b: *std.Build, + arch: ?std.Target.Cpu.Arch, +) std.Build.ResolvedTarget { + return b.resolveTargetQuery(.{ + .cpu_arch = arch orelse builtin.target.cpu.arch, + .os_tag = .macos, + .os_version_min = osVersionMin(.macos), + }); +} + +/// The possible entrypoints for the exe artifact. This has no effect on +/// other artifact types (i.e. lib, wasm_module). +/// +/// The whole existence of this enum is to workaround the fact that Zig +/// doesn't allow the main function to be in a file in a subdirctory +/// from the "root" of the module, and I don't want to pollute our root +/// directory with a bunch of individual zig files for each entrypoint. +/// +/// Therefore, main.zig uses this to switch between the different entrypoints. +pub const ExeEntrypoint = enum { + ghostty, + helpgen, + mdgen_ghostty_1, + mdgen_ghostty_5, + webgen_config, + webgen_actions, + webgen_commands, + bench_parser, + bench_stream, + bench_codepoint_width, + bench_grapheme_break, + bench_page_init, +}; + +/// The release channel for the build. +pub const ReleaseChannel = enum { + /// Unstable builds on every commit. + tip, + + /// Stable tagged releases. + stable, +}; diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig new file mode 100644 index 0000000000..27f40abffe --- /dev/null +++ b/src/build/GhosttyBench.zig @@ -0,0 +1,69 @@ +//! GhosttyBench generates all the Ghostty benchmark helper binaries. +const GhosttyBench = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +steps: []*std.Build.Step.Compile, + +pub fn init( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyBench { + var steps = std.ArrayList(*std.Build.Step.Compile).init(b.allocator); + errdefer steps.deinit(); + + // Open the directory ./src/bench + const c_dir_path = b.pathFromRoot("src/bench"); + var c_dir = try std.fs.cwd().openDir(c_dir_path, .{ .iterate = true }); + defer c_dir.close(); + + // Go through and add each as a step + var c_dir_it = c_dir.iterate(); + while (try c_dir_it.next()) |entry| { + // Get the index of the last '.' so we can strip the extension. + const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; + if (index == 0) continue; + + // If it doesn't end in 'zig' then ignore + if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue; + + // Name of the conformance app and full path to the entrypoint. + const name = entry.name[0..index]; + + // Executable builder. + const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); + const c_exe = b.addExecutable(.{ + .name = bin_name, + .root_source_file = b.path("src/main.zig"), + .target = deps.config.target, + + // We always want our benchmarks to be in release mode. + .optimize = .ReleaseFast, + }); + c_exe.linkLibC(); + + // Update our entrypoint + var enum_name: [64]u8 = undefined; + @memcpy(enum_name[0..name.len], name); + std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_'); + + var buf: [64]u8 = undefined; + const new_deps = try deps.changeEntrypoint(b, std.meta.stringToEnum( + Config.ExeEntrypoint, + try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}), + ).?); + + _ = try new_deps.add(c_exe); + + try steps.append(c_exe); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyBench) void { + const b = self.steps[0].step.owner; + for (self.steps) |step| b.installArtifact(step); +} diff --git a/src/build/GhosttyDocs.zig b/src/build/GhosttyDocs.zig new file mode 100644 index 0000000000..28cbea2c94 --- /dev/null +++ b/src/build/GhosttyDocs.zig @@ -0,0 +1,92 @@ +//! GhosttyDocs generates all the on-disk documentation that Ghostty is +//! installed with (man pages, html, markdown, etc.) +const GhosttyDocs = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +steps: []*std.Build.Step, + +pub fn init( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyDocs { + var steps = std.ArrayList(*std.Build.Step).init(b.allocator); + errdefer steps.deinit(); + + const manpages = [_]struct { + name: []const u8, + section: []const u8, + }{ + .{ .name = "ghostty", .section = "1" }, + .{ .name = "ghostty", .section = "5" }, + }; + + inline for (manpages) |manpage| { + const generate_markdown = b.addExecutable(.{ + .name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(generate_markdown); + + const gen_config = config: { + var copy = deps.config.*; + copy.exe_entrypoint = @field( + Config.ExeEntrypoint, + "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, + ); + break :config copy; + }; + + const generate_markdown_options = b.addOptions(); + try gen_config.addOptions(generate_markdown_options); + generate_markdown.root_module.addOptions("build_options", generate_markdown_options); + + const generate_markdown_step = b.addRunArtifact(generate_markdown); + const markdown_output = generate_markdown_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + markdown_output, + "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".md", + ).step); + + const generate_html = b.addSystemCommand(&.{"pandoc"}); + generate_html.addArgs(&.{ + "--standalone", + "--from", + "markdown", + "--to", + "html", + }); + generate_html.addFileArg(markdown_output); + + try steps.append(&b.addInstallFile( + generate_html.captureStdOut(), + "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".html", + ).step); + + const generate_manpage = b.addSystemCommand(&.{"pandoc"}); + generate_manpage.addArgs(&.{ + "--standalone", + "--from", + "markdown", + "--to", + "man", + }); + generate_manpage.addFileArg(markdown_output); + + try steps.append(&b.addInstallFile( + generate_manpage.captureStdOut(), + "share/man/man" ++ manpage.section ++ "/" ++ manpage.name ++ "." ++ manpage.section, + ).step); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyDocs) void { + const b = self.steps[0].owner; + for (self.steps) |step| b.getInstallStep().dependOn(step); +} diff --git a/src/build/GhosttyExe.zig b/src/build/GhosttyExe.zig new file mode 100644 index 0000000000..ef5303baad --- /dev/null +++ b/src/build/GhosttyExe.zig @@ -0,0 +1,120 @@ +const Ghostty = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +/// The primary Ghostty executable. +exe: *std.Build.Step.Compile, + +/// The install step for the executable. +install_step: *std.Build.Step.InstallArtifact, + +pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty { + const exe: *std.Build.Step.Compile = b.addExecutable(.{ + .name = "ghostty", + .root_source_file = b.path("src/main.zig"), + .target = cfg.target, + .optimize = cfg.optimize, + .strip = cfg.strip, + }); + const install_step = b.addInstallArtifact(exe, .{}); + + // Set PIE if requested + if (cfg.pie) exe.pie = true; + + // Add the shared dependencies + _ = try deps.add(exe); + + // Check for possible issues + try checkNixShell(exe, cfg); + + // Patch our rpath if that option is specified. + if (cfg.patch_rpath) |rpath| { + if (rpath.len > 0) { + const run = std.Build.Step.Run.create(b, "patchelf rpath"); + run.addArgs(&.{ "patchelf", "--set-rpath", rpath }); + run.addArtifactArg(exe); + install_step.step.dependOn(&run.step); + } + } + + // OS-specific + switch (cfg.target.result.os.tag) { + .windows => { + exe.subsystem = .Windows; + exe.addWin32ResourceFile(.{ + .file = b.path("dist/windows/ghostty.rc"), + }); + }, + + else => {}, + } + + return .{ + .exe = exe, + .install_step = install_step, + }; +} + +/// Add the ghostty exe to the install target. +pub fn install(self: *const Ghostty) void { + const b = self.install_step.step.owner; + b.getInstallStep().dependOn(&self.install_step.step); +} + +/// If we're in NixOS but not in the shell environment then we issue +/// a warning because the rpath may not be setup properly. This doesn't modify +/// our build in any way but addresses a common build-from-source issue +/// for a subset of users. +fn checkNixShell(exe: *std.Build.Step.Compile, cfg: *const Config) !void { + // Non-Linux doesn't have rpath issues. + if (cfg.target.result.os.tag != .linux) return; + + // When cross-compiling, we don't need to worry about matching our + // Nix shell rpath since the resulting binary will be run on a + // separate system. + if (!cfg.target.query.isNativeCpu()) return; + if (!cfg.target.query.isNativeOs()) return; + + // Verify we're in NixOS + std.fs.accessAbsolute("/etc/NIXOS", .{}) catch return; + + // If we're in a nix shell, not a problem + if (cfg.env.get("IN_NIX_SHELL") != null) return; + + try exe.step.addError( + "\x1b[" ++ color_map.get("yellow").? ++ + "\x1b[" ++ color_map.get("d").? ++ + \\Detected building on and for NixOS outside of the Nix shell environment. + \\ + \\The resulting ghostty binary will likely fail on launch because it is + \\unable to dynamically load the windowing libs (X11, Wayland, etc.). + \\We highly recommend running only within the Nix build environment + \\and the resulting binary will be portable across your system. + \\ + \\To run in the Nix build environment, use the following command. + \\Append any additional options like (`-Doptimize` flags). The resulting + \\binary will be in zig-out as usual. + \\ + \\ nix develop -c zig build + \\ + ++ + "\x1b[0m", + .{}, + ); +} + +/// ANSI escape codes for colored log output +const color_map = std.StaticStringMap([]const u8).initComptime(.{ + &.{ "black", "30m" }, + &.{ "blue", "34m" }, + &.{ "b", "1m" }, + &.{ "d", "2m" }, + &.{ "cyan", "36m" }, + &.{ "green", "32m" }, + &.{ "magenta", "35m" }, + &.{ "red", "31m" }, + &.{ "white", "37m" }, + &.{ "yellow", "33m" }, +}); diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig new file mode 100644 index 0000000000..53aee0e248 --- /dev/null +++ b/src/build/GhosttyLib.zig @@ -0,0 +1,110 @@ +const GhosttyLib = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); +const LibtoolStep = @import("LibtoolStep.zig"); +const LipoStep = @import("LipoStep.zig"); + +/// The step that generates the file. +step: *std.Build.Step, + +/// The final static library file +output: std.Build.LazyPath, + +pub fn initStatic( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyLib { + const lib = b.addStaticLibrary(.{ + .name = "ghostty", + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + }); + lib.bundle_compiler_rt = true; + lib.linkLibC(); + + // Add our dependencies. Get the list of all static deps so we can + // build a combined archive if necessary. + var lib_list = try deps.add(lib); + try lib_list.append(lib.getEmittedBin()); + + if (!deps.config.target.result.isDarwin()) return .{ + .step = &lib.step, + .output = lib.getEmittedBin(), + }; + + // Create a static lib that contains all our dependencies. + const libtool = LibtoolStep.create(b, .{ + .name = "ghostty", + .out_name = "libghostty-fat.a", + .sources = lib_list.items, + }); + libtool.step.dependOn(&lib.step); + + return .{ + .step = libtool.step, + .output = libtool.output, + }; +} + +pub fn initShared( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyLib { + const lib = b.addSharedLibrary(.{ + .name = "ghostty", + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + .strip = deps.config.strip, + }); + _ = try deps.add(lib); + + return .{ + .step = &lib.step, + .output = lib.getEmittedBin(), + }; +} + +pub fn initMacOSUniversal( + b: *std.Build, + original_deps: *const SharedDeps, +) !GhosttyLib { + const aarch64 = try initStatic(b, &try original_deps.retarget( + b, + Config.genericMacOSTarget(b, .aarch64), + )); + const x86_64 = try initStatic(b, &try original_deps.retarget( + b, + Config.genericMacOSTarget(b, .x86_64), + )); + + const universal = LipoStep.create(b, .{ + .name = "ghostty", + .out_name = "libghostty.a", + .input_a = aarch64.output, + .input_b = x86_64.output, + }); + + return .{ + .step = universal.step, + .output = universal.output, + }; +} + +pub fn install(self: *const GhosttyLib, name: []const u8) void { + const b = self.step.owner; + const lib_install = b.addInstallLibFile(self.output, name); + b.getInstallStep().dependOn(&lib_install.step); +} + +pub fn installHeader(self: *const GhosttyLib) void { + const b = self.step.owner; + const header_install = b.addInstallHeaderFile( + b.path("include/ghostty.h"), + "ghostty.h", + ); + b.getInstallStep().dependOn(&header_install.step); +} diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig new file mode 100644 index 0000000000..a7ff40cbdc --- /dev/null +++ b/src/build/GhosttyResources.zig @@ -0,0 +1,272 @@ +const GhosttyResources = @This(); + +const std = @import("std"); +const buildpkg = @import("main.zig"); +const Config = @import("Config.zig"); +const config_vim = @import("../config/vim.zig"); +const config_sublime_syntax = @import("../config/sublime_syntax.zig"); +const terminfo = @import("../terminfo/main.zig"); +const RunStep = std.Build.Step.Run; + +steps: []*std.Build.Step, + +pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { + var steps = std.ArrayList(*std.Build.Step).init(b.allocator); + errdefer steps.deinit(); + + // Terminfo + terminfo: { + // Encode our terminfo + var str = std.ArrayList(u8).init(b.allocator); + defer str.deinit(); + try terminfo.ghostty.encode(str.writer()); + + // Write it + var wf = b.addWriteFiles(); + const source = wf.add("ghostty.terminfo", str.items); + + if (cfg.emit_terminfo) { + const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo"); + try steps.append(&source_install.step); + } + + // Windows doesn't have the binaries below. + if (cfg.target.result.os.tag == .windows) break :terminfo; + + // Convert to termcap source format if thats helpful to people and + // install it. The resulting value here is the termcap source in case + // that is used for other commands. + if (cfg.emit_termcap) { + const run_step = RunStep.create(b, "infotocap"); + run_step.addArg("infotocap"); + run_step.addFileArg(source); + const out_source = run_step.captureStdOut(); + _ = run_step.captureStdErr(); // so we don't see stderr + + const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); + try steps.append(&cap_install.step); + } + + // Compile the terminfo source into a terminfo database + { + const run_step = RunStep.create(b, "tic"); + run_step.addArgs(&.{ "tic", "-x", "-o" }); + const path = run_step.addOutputFileArg("terminfo"); + run_step.addFileArg(source); + _ = run_step.captureStdErr(); // so we don't see stderr + + // Ensure that `share/terminfo` is a directory, otherwise the `cp + // -R` will create a file named `share/terminfo` + const mkdir_step = RunStep.create(b, "make share/terminfo directory"); + switch (cfg.target.result.os.tag) { + // windows mkdir shouldn't need "-p" + .windows => mkdir_step.addArgs(&.{"mkdir"}), + else => mkdir_step.addArgs(&.{ "mkdir", "-p" }), + } + mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path})); + try steps.append(&mkdir_step.step); + + // Use cp -R instead of Step.InstallDir because we need to preserve + // symlinks in the terminfo database. Zig's InstallDir step doesn't + // handle symlinks correctly yet. + const copy_step = RunStep.create(b, "copy terminfo db"); + copy_step.addArgs(&.{ "cp", "-R" }); + copy_step.addFileArg(path); + copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); + copy_step.step.dependOn(&mkdir_step.step); + try steps.append(©_step.step); + } + } + + // Shell-integration + { + const install_step = b.addInstallDirectory(.{ + .source_dir = b.path("src/shell-integration"), + .install_dir = .{ .custom = "share" }, + .install_subdir = b.pathJoin(&.{ "ghostty", "shell-integration" }), + .exclude_extensions = &.{".md"}, + }); + try steps.append(&install_step.step); + } + + // Themes + { + const upstream = b.dependency("iterm2_themes", .{}); + const install_step = b.addInstallDirectory(.{ + .source_dir = upstream.path("ghostty"), + .install_dir = .{ .custom = "share" }, + .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), + .exclude_extensions = &.{".md"}, + }); + try steps.append(&install_step.step); + } + + // Fish shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.fish", buildpkg.fish_completions); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/fish/vendor_completions.d", + }); + try steps.append(&install_step.step); + } + + // zsh shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("_ghostty", buildpkg.zsh_completions); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/zsh/site-functions", + }); + try steps.append(&install_step.step); + } + + // bash shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.bash", buildpkg.bash_completions); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/bash-completion/completions", + }); + try steps.append(&install_step.step); + } + + // Vim plugin + { + const wf = b.addWriteFiles(); + _ = wf.add("syntax/ghostty.vim", config_vim.syntax); + _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); + _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + _ = wf.add("compiler/ghostty.vim", config_vim.compiler); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/vim/vimfiles", + }); + try steps.append(&install_step.step); + } + + // Neovim plugin + // This is just a copy-paste of the Vim plugin, but using a Neovim subdir. + // By default, Neovim doesn't look inside share/vim/vimfiles. Some distros + // configure it to do that however. Fedora, does not as a counterexample. + { + const wf = b.addWriteFiles(); + _ = wf.add("syntax/ghostty.vim", config_vim.syntax); + _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); + _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + _ = wf.add("compiler/ghostty.vim", config_vim.compiler); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/nvim/site", + }); + try steps.append(&install_step.step); + } + + // Sublime syntax highlighting for bat cli tool + // NOTE: The current implementation requires symlinking the generated + // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' + // directory. The syntax then needs to be mapped to the correct language in + // the config file within the '~.config/bat' directory + // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.sublime-syntax", config_sublime_syntax.syntax); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/bat/syntaxes", + }); + try steps.append(&install_step.step); + } + + // App (Linux) + if (cfg.target.result.os.tag == .linux) { + // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + + // Desktop file so that we have an icon and other metadata + try steps.append(&b.addInstallFile( + b.path("dist/linux/app.desktop"), + "share/applications/com.mitchellh.ghostty.desktop", + ).step); + + // Right click menu action for Plasma desktop + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_dolphin.desktop"), + "share/kio/servicemenus/com.mitchellh.ghostty.desktop", + ).step); + + // Right click menu action for Nautilus + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_nautilus.py"), + "share/nautilus-python/extensions/com.mitchellh.ghostty.py", + ).step); + + // Various icons that our application can use, including the icon + // that will be used for the desktop. + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16.png"), + "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32.png"), + "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128.png"), + "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256.png"), + "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_512.png"), + "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", + ).step); + // Flatpaks only support icons up to 512x512. + if (!cfg.flatpak) { + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_1024.png"), + "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", + ).step); + } + + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16@2x.png"), + "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32@2x.png"), + "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128@2x.png"), + "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256@2x.png"), + "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + ).step); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyResources) void { + const b = self.steps[0].owner; + for (self.steps) |step| b.getInstallStep().dependOn(step); +} diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig new file mode 100644 index 0000000000..860feb705a --- /dev/null +++ b/src/build/GhosttyWebdata.zig @@ -0,0 +1,111 @@ +//! GhosttyWebdata generates all the Ghostty website data that is +//! merged with the website for things like config references. +const GhosttyWebdata = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +steps: []*std.Build.Step, + +pub fn init( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyWebdata { + var steps = std.ArrayList(*std.Build.Step).init(b.allocator); + errdefer steps.deinit(); + + { + const webgen_config = b.addExecutable(.{ + .name = "webgen_config", + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(webgen_config); + + { + const buildconfig = config: { + var copy = deps.config.*; + copy.exe_entrypoint = .webgen_config; + break :config copy; + }; + + const options = b.addOptions(); + try buildconfig.addOptions(options); + webgen_config.root_module.addOptions("build_options", options); + } + + const webgen_config_step = b.addRunArtifact(webgen_config); + const webgen_config_out = webgen_config_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + webgen_config_out, + "share/ghostty/webdata/config.mdx", + ).step); + } + + { + const webgen_actions = b.addExecutable(.{ + .name = "webgen_actions", + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(webgen_actions); + + { + const buildconfig = config: { + var copy = deps.config.*; + copy.exe_entrypoint = .webgen_actions; + break :config copy; + }; + + const options = b.addOptions(); + try buildconfig.addOptions(options); + webgen_actions.root_module.addOptions("build_options", options); + } + + const webgen_actions_step = b.addRunArtifact(webgen_actions); + const webgen_actions_out = webgen_actions_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + webgen_actions_out, + "share/ghostty/webdata/actions.mdx", + ).step); + } + + { + const webgen_commands = b.addExecutable(.{ + .name = "webgen_commands", + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(webgen_commands); + + { + const buildconfig = config: { + var copy = deps.config.*; + copy.exe_entrypoint = .webgen_commands; + break :config copy; + }; + + const options = b.addOptions(); + try buildconfig.addOptions(options); + webgen_commands.root_module.addOptions("build_options", options); + } + + const webgen_commands_step = b.addRunArtifact(webgen_commands); + const webgen_commands_out = webgen_commands_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + webgen_commands_out, + "share/ghostty/webdata/commands.mdx", + ).step); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyWebdata) void { + const b = self.steps[0].owner; + for (self.steps) |step| b.getInstallStep().dependOn(step); +} diff --git a/src/build/GhosttyXCFramework.zig b/src/build/GhosttyXCFramework.zig new file mode 100644 index 0000000000..38bc2c43ff --- /dev/null +++ b/src/build/GhosttyXCFramework.zig @@ -0,0 +1,68 @@ +const GhosttyXCFramework = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); +const GhosttyLib = @import("GhosttyLib.zig"); +const XCFrameworkStep = @import("XCFrameworkStep.zig"); + +xcframework: *XCFrameworkStep, +macos: GhosttyLib, + +pub fn init(b: *std.Build, deps: *const SharedDeps) !GhosttyXCFramework { + // Create our universal macOS static library. + const macos = try GhosttyLib.initMacOSUniversal(b, deps); + + // iOS + const ios = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = .ios, + .os_version_min = Config.osVersionMin(.ios), + .abi = null, + }), + )); + + // iOS Simulator + const ios_sim = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = .ios, + .os_version_min = Config.osVersionMin(.ios), + .abi = .simulator, + }), + )); + + // The xcframework wraps our ghostty library so that we can link + // it to the final app built with Swift. + const xcframework = XCFrameworkStep.create(b, .{ + .name = "GhosttyKit", + .out_path = "macos/GhosttyKit.xcframework", + .libraries = &.{ + .{ + .library = macos.output, + .headers = b.path("include"), + }, + .{ + .library = ios.output, + .headers = b.path("include"), + }, + .{ + .library = ios_sim.output, + .headers = b.path("include"), + }, + }, + }); + + return .{ + .xcframework = xcframework, + .macos = macos, + }; +} + +pub fn install(self: *const GhosttyXCFramework) void { + const b = self.xcframework.step.owner; + b.getInstallStep().dependOn(self.xcframework.step); +} diff --git a/src/build/Version.zig b/src/build/GitVersion.zig similarity index 100% rename from src/build/Version.zig rename to src/build/GitVersion.zig diff --git a/src/build/HelpStrings.zig b/src/build/HelpStrings.zig new file mode 100644 index 0000000000..0244670ccc --- /dev/null +++ b/src/build/HelpStrings.zig @@ -0,0 +1,46 @@ +const HelpStrings = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); + +/// The "helpgen" exe. +exe: *std.Build.Step.Compile, + +/// The output path for the help strings. +output: std.Build.LazyPath, + +pub fn init(b: *std.Build, cfg: *const Config) !HelpStrings { + const exe = b.addExecutable(.{ + .name = "helpgen", + .root_source_file = b.path("src/helpgen.zig"), + .target = b.host, + }); + + const help_config = config: { + var copy = cfg.*; + copy.exe_entrypoint = .helpgen; + break :config copy; + }; + const options = b.addOptions(); + try help_config.addOptions(options); + exe.root_module.addOptions("build_options", options); + + const help_run = b.addRunArtifact(exe); + return .{ + .exe = exe, + .output = help_run.captureStdOut(), + }; +} + +/// Add the "help_strings" import. +pub fn addImport(self: *const HelpStrings, step: *std.Build.Step.Compile) void { + self.output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("help_strings", .{ + .root_source_file = self.output, + }); +} + +/// Install the help exe +pub fn install(self: *const HelpStrings) void { + self.exe.step.owner.installArtifact(self.exe); +} diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index 587d276c13..12adf3edb4 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -21,13 +21,13 @@ pub const Options = struct { step: *Step, output: LazyPath, -pub fn create(b: *std.Build, opts: Options) *MetallibStep { +pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { const self = b.allocator.create(MetallibStep) catch @panic("OOM"); const sdk = switch (opts.target.result.os.tag) { .macos => "macosx", .ios => "iphoneos", - else => @panic("unsupported metallib OS"), + else => return null, }; const min_version = if (opts.target.query.os_version_min) |v| diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig new file mode 100644 index 0000000000..64068658de --- /dev/null +++ b/src/build/SharedDeps.zig @@ -0,0 +1,512 @@ +const SharedDeps = @This(); + +const std = @import("std"); +const Scanner = @import("zig_wayland").Scanner; +const Config = @import("Config.zig"); +const HelpStrings = @import("HelpStrings.zig"); +const MetallibStep = @import("MetallibStep.zig"); +const UnicodeTables = @import("UnicodeTables.zig"); + +config: *const Config, + +options: *std.Build.Step.Options, +help_strings: HelpStrings, +metallib: ?*MetallibStep, +unicode_tables: UnicodeTables, + +/// Used to keep track of a list of file sources. +pub const LazyPathList = std.ArrayList(std.Build.LazyPath); + +pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { + var result: SharedDeps = .{ + .config = cfg, + .help_strings = try HelpStrings.init(b, cfg), + .unicode_tables = try UnicodeTables.init(b), + + // Setup by retarget + .options = undefined, + .metallib = undefined, + }; + try result.initTarget(b, cfg.target); + return result; +} + +/// Retarget our dependencies for another build target. Modifies in-place. +pub fn retarget( + self: *const SharedDeps, + b: *std.Build, + target: std.Build.ResolvedTarget, +) !SharedDeps { + var result = self.*; + try result.initTarget(b, target); + return result; +} + +/// Change the exe entrypoint. +pub fn changeEntrypoint( + self: *const SharedDeps, + b: *std.Build, + entrypoint: Config.ExeEntrypoint, +) !SharedDeps { + // Change our config + const config = try b.allocator.create(Config); + config.* = self.config.*; + config.exe_entrypoint = entrypoint; + + var result = self.*; + result.config = config; + return result; +} + +fn initTarget( + self: *SharedDeps, + b: *std.Build, + target: std.Build.ResolvedTarget, +) !void { + // Update our metallib + self.metallib = MetallibStep.create(b, .{ + .name = "Ghostty", + .target = target, + .sources = &.{b.path("src/renderer/shaders/cell.metal")}, + }); + + // Change our config + const config = try b.allocator.create(Config); + config.* = self.config.*; + config.target = target; + self.config = config; + + // Setup our shared build options + self.options = b.addOptions(); + try self.config.addOptions(self.options); +} + +pub fn add( + self: *const SharedDeps, + step: *std.Build.Step.Compile, +) !LazyPathList { + const b = step.step.owner; + + // We could use our config.target/optimize fields here but its more + // correct to always match our step. + const target = step.root_module.resolved_target.?; + const optimize = step.root_module.optimize.?; + + // We maintain a list of our static libraries and return it so that + // we can build a single fat static library for the final app. + var static_libs = LazyPathList.init(b.allocator); + errdefer static_libs.deinit(); + + // Every exe gets build options populated + step.root_module.addOptions("build_options", self.options); + + // Freetype + _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help + if (self.config.font_backend.hasFreetype()) { + const freetype_dep = b.dependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + }); + step.root_module.addImport("freetype", freetype_dep.module("freetype")); + + if (b.systemIntegrationOption("freetype", .{})) { + step.linkSystemLibrary2("bzip2", dynamic_link_opts); + step.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + step.linkLibrary(freetype_dep.artifact("freetype")); + try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin()); + } + } + + // Harfbuzz + _ = b.systemIntegrationOption("harfbuzz", .{}); // Shows it in help + if (self.config.font_backend.hasHarfbuzz()) { + const harfbuzz_dep = b.dependency("harfbuzz", .{ + .target = target, + .optimize = optimize, + .@"enable-freetype" = true, + .@"enable-coretext" = self.config.font_backend.hasCoretext(), + }); + + step.root_module.addImport( + "harfbuzz", + harfbuzz_dep.module("harfbuzz"), + ); + if (b.systemIntegrationOption("harfbuzz", .{})) { + step.linkSystemLibrary2("harfbuzz", dynamic_link_opts); + } else { + step.linkLibrary(harfbuzz_dep.artifact("harfbuzz")); + try static_libs.append(harfbuzz_dep.artifact("harfbuzz").getEmittedBin()); + } + } + + // Fontconfig + _ = b.systemIntegrationOption("fontconfig", .{}); // Shows it in help + if (self.config.font_backend.hasFontconfig()) { + const fontconfig_dep = b.dependency("fontconfig", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport( + "fontconfig", + fontconfig_dep.module("fontconfig"), + ); + + if (b.systemIntegrationOption("fontconfig", .{})) { + step.linkSystemLibrary2("fontconfig", dynamic_link_opts); + } else { + step.linkLibrary(fontconfig_dep.artifact("fontconfig")); + try static_libs.append(fontconfig_dep.artifact("fontconfig").getEmittedBin()); + } + } + + // Libpng - Ghostty doesn't actually use this directly, its only used + // through dependencies, so we only need to add it to our static + // libs list if we're not using system integration. The dependencies + // will handle linking it. + if (!b.systemIntegrationOption("libpng", .{})) { + const libpng_dep = b.dependency("libpng", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(libpng_dep.artifact("png")); + try static_libs.append(libpng_dep.artifact("png").getEmittedBin()); + } + + // Zlib - same as libpng, only used through dependencies. + if (!b.systemIntegrationOption("zlib", .{})) { + const zlib_dep = b.dependency("zlib", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(zlib_dep.artifact("z")); + try static_libs.append(zlib_dep.artifact("z").getEmittedBin()); + } + + // Oniguruma + const oniguruma_dep = b.dependency("oniguruma", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("oniguruma", oniguruma_dep.module("oniguruma")); + if (b.systemIntegrationOption("oniguruma", .{})) { + step.linkSystemLibrary2("oniguruma", dynamic_link_opts); + } else { + step.linkLibrary(oniguruma_dep.artifact("oniguruma")); + try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin()); + } + + // Glslang + const glslang_dep = b.dependency("glslang", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("glslang", glslang_dep.module("glslang")); + if (b.systemIntegrationOption("glslang", .{})) { + step.linkSystemLibrary2("glslang", dynamic_link_opts); + step.linkSystemLibrary2("glslang-default-resource-limits", dynamic_link_opts); + } else { + step.linkLibrary(glslang_dep.artifact("glslang")); + try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin()); + } + + // Spirv-cross + const spirv_cross_dep = b.dependency("spirv_cross", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("spirv_cross", spirv_cross_dep.module("spirv_cross")); + if (b.systemIntegrationOption("spirv-cross", .{})) { + step.linkSystemLibrary2("spirv-cross", dynamic_link_opts); + } else { + step.linkLibrary(spirv_cross_dep.artifact("spirv_cross")); + try static_libs.append(spirv_cross_dep.artifact("spirv_cross").getEmittedBin()); + } + + // Simdutf + if (b.systemIntegrationOption("simdutf", .{})) { + step.linkSystemLibrary2("simdutf", dynamic_link_opts); + } else { + const simdutf_dep = b.dependency("simdutf", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(simdutf_dep.artifact("simdutf")); + try static_libs.append(simdutf_dep.artifact("simdutf").getEmittedBin()); + } + + // Sentry + if (self.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()); + + // We also need to include breakpad in the static libs. + const breakpad_dep = sentry_dep.builder.dependency("breakpad", .{ + .target = target, + .optimize = optimize, + }); + try static_libs.append(breakpad_dep.artifact("breakpad").getEmittedBin()); + } + + // Wasm we do manually since it is such a different build. + if (step.rootModuleTarget().cpu.arch == .wasm32) { + const js_dep = b.dependency("zig_js", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("zig-js", js_dep.module("zig-js")); + + return static_libs; + } + + // On Linux, we need to add a couple common library paths that aren't + // on the standard search list. i.e. GTK is often in /usr/lib/x86_64-linux-gnu + // on x86_64. + if (step.rootModuleTarget().os.tag == .linux) { + const triple = try step.rootModuleTarget().linuxTriple(b.allocator); + step.addLibraryPath(.{ .cwd_relative = b.fmt("/usr/lib/{s}", .{triple}) }); + } + + // C files + step.linkLibC(); + step.addIncludePath(b.path("src/stb")); + step.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} }); + if (step.rootModuleTarget().os.tag == .linux) { + step.addIncludePath(b.path("src/apprt/gtk")); + } + + // C++ files + step.linkLibCpp(); + step.addIncludePath(b.path("src")); + { + // From hwy/detect_targets.h + const HWY_AVX3_SPR: c_int = 1 << 4; + const HWY_AVX3_ZEN4: c_int = 1 << 6; + const HWY_AVX3_DL: c_int = 1 << 7; + const HWY_AVX3: c_int = 1 << 8; + + // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 + // To workaround this we just disable AVX512 support completely. + // The performance difference between AVX2 and AVX512 is not + // significant for our use case and AVX512 is very rare on consumer + // hardware anyways. + const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + + step.addCSourceFiles(.{ + .files = &.{ + "src/simd/base64.cpp", + "src/simd/codepoint_width.cpp", + "src/simd/index_of.cpp", + "src/simd/vt.cpp", + }, + .flags = if (step.rootModuleTarget().cpu.arch == .x86_64) &.{ + b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}), + } else &.{}, + }); + } + + // We always require the system SDK so that our system headers are available. + // This makes things like `os/log.h` available for cross-compiling. + if (step.rootModuleTarget().isDarwin()) { + try @import("apple_sdk").addPaths(b, &step.root_module); + + const metallib = self.metallib.?; + metallib.output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("ghostty_metallib", .{ + .root_source_file = metallib.output, + }); + } + + // Other dependencies, mostly pure Zig + step.root_module.addImport("opengl", b.dependency( + "opengl", + .{}, + ).module("opengl")); + step.root_module.addImport("vaxis", b.dependency("vaxis", .{ + .target = target, + .optimize = optimize, + }).module("vaxis")); + step.root_module.addImport("wuffs", b.dependency("wuffs", .{ + .target = target, + .optimize = optimize, + }).module("wuffs")); + step.root_module.addImport("xev", b.dependency("libxev", .{ + .target = target, + .optimize = optimize, + }).module("xev")); + step.root_module.addImport("z2d", b.addModule("z2d", .{ + .root_source_file = b.dependency("z2d", .{}).path("src/z2d.zig"), + .target = target, + .optimize = optimize, + })); + step.root_module.addImport("ziglyph", b.dependency("ziglyph", .{ + .target = target, + .optimize = optimize, + }).module("ziglyph")); + step.root_module.addImport("zf", b.dependency("zf", .{ + .target = target, + .optimize = optimize, + .with_tui = false, + }).module("zf")); + + // Mac Stuff + if (step.rootModuleTarget().isDarwin()) { + const objc_dep = b.dependency("zig_objc", .{ + .target = target, + .optimize = optimize, + }); + const macos_dep = b.dependency("macos", .{ + .target = target, + .optimize = optimize, + }); + + step.root_module.addImport("objc", objc_dep.module("objc")); + step.root_module.addImport("macos", macos_dep.module("macos")); + step.linkLibrary(macos_dep.artifact("macos")); + try static_libs.append(macos_dep.artifact("macos").getEmittedBin()); + + if (self.config.renderer == .opengl) { + step.linkFramework("OpenGL"); + } + } + + // cimgui + const cimgui_dep = b.dependency("cimgui", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("cimgui", cimgui_dep.module("cimgui")); + step.linkLibrary(cimgui_dep.artifact("cimgui")); + try static_libs.append(cimgui_dep.artifact("cimgui").getEmittedBin()); + + // Highway + const highway_dep = b.dependency("highway", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(highway_dep.artifact("highway")); + try static_libs.append(highway_dep.artifact("highway").getEmittedBin()); + + // utfcpp - This is used as a dependency on our hand-written C++ code + const utfcpp_dep = b.dependency("utfcpp", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(utfcpp_dep.artifact("utfcpp")); + try static_libs.append(utfcpp_dep.artifact("utfcpp").getEmittedBin()); + + // If we're building an exe then we have additional dependencies. + if (step.kind != .lib) { + // We always statically compile glad + step.addIncludePath(b.path("vendor/glad/include/")); + step.addCSourceFile(.{ + .file = b.path("vendor/glad/src/gl.c"), + .flags = &.{}, + }); + + // When we're targeting flatpak we ALWAYS link GTK so we + // get access to glib for dbus. + if (self.config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts); + + switch (self.config.app_runtime) { + .none => {}, + + .glfw => glfw: { + const mach_glfw_dep = b.lazyDependency("mach_glfw", .{ + .target = target, + .optimize = optimize, + }) orelse break :glfw; + step.root_module.addImport("glfw", mach_glfw_dep.module("mach-glfw")); + }, + + .gtk => { + step.linkSystemLibrary2("gtk4", dynamic_link_opts); + if (self.config.adwaita) step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); + if (self.config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); + + if (self.config.wayland) { + const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{ + // We shouldn't be using getPath but we need to for now + // https://codeberg.org/ifreund/zig-wayland/issues/66 + .wayland_xml = b.dependency("wayland", .{}) + .path("protocol/wayland.xml"), + .wayland_protocols = b.dependency("wayland_protocols", .{}) + .path(""), + }); + + const wayland = b.createModule(.{ .root_source_file = scanner.result }); + + const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{ + .target = target, + .optimize = optimize, + }); + + // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 + scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml")); + scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/server-decoration.xml")); + + scanner.generate("wl_compositor", 1); + scanner.generate("org_kde_kwin_blur_manager", 1); + scanner.generate("org_kde_kwin_server_decoration_manager", 1); + + step.root_module.addImport("wayland", wayland); + step.linkSystemLibrary2("wayland-client", dynamic_link_opts); + } + + { + const gresource = @import("../apprt/gtk/gresource.zig"); + + const wf = b.addWriteFiles(); + const gresource_xml = wf.add("gresource.xml", gresource.gresource_xml); + + const generate_resources_c = b.addSystemCommand(&.{ + "glib-compile-resources", + "--c-name", + "ghostty", + "--generate-source", + "--target", + }); + const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c"); + generate_resources_c.addFileArg(gresource_xml); + generate_resources_c.extra_file_dependencies = &gresource.dependencies; + step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} }); + + const generate_resources_h = b.addSystemCommand(&.{ + "glib-compile-resources", + "--c-name", + "ghostty", + "--generate-header", + "--target", + }); + const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h"); + generate_resources_h.addFileArg(gresource_xml); + generate_resources_h.extra_file_dependencies = &gresource.dependencies; + step.addIncludePath(ghostty_resources_h.dirname()); + } + }, + } + } + + self.help_strings.addImport(step); + self.unicode_tables.addImport(step); + + return static_libs; +} + +// For dynamic linking, we prefer dynamic linking and to search by +// mode first. Mode first will search all paths for a dynamic library +// before falling back to static. +const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, +}; diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig new file mode 100644 index 0000000000..7a4b0a5a2e --- /dev/null +++ b/src/build/UnicodeTables.zig @@ -0,0 +1,42 @@ +const UnicodeTables = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); + +/// The exe. +exe: *std.Build.Step.Compile, + +/// The output path for the unicode tables +output: std.Build.LazyPath, + +pub fn init(b: *std.Build) !UnicodeTables { + const exe = b.addExecutable(.{ + .name = "unigen", + .root_source_file = b.path("src/unicode/props.zig"), + .target = b.host, + }); + + const ziglyph_dep = b.dependency("ziglyph", .{ + .target = b.host, + }); + exe.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph")); + + const run = b.addRunArtifact(exe); + return .{ + .exe = exe, + .output = run.captureStdOut(), + }; +} + +/// Add the "unicode_tables" import. +pub fn addImport(self: *const UnicodeTables, step: *std.Build.Step.Compile) void { + self.output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("unicode_tables", .{ + .root_source_file = self.output, + }); +} + +/// Install the exe +pub fn install(self: *const UnicodeTables, b: *std.Build) void { + b.installArtifact(self.exe); +} diff --git a/src/build/bash_completions.zig b/src/build/bash_completions.zig index 6649bcb014..86c2dc3cf2 100644 --- a/src/build/bash_completions.zig +++ b/src/build/bash_completions.zig @@ -14,7 +14,7 @@ const Action = @import("../cli/action.zig").Action; /// it's part of an on going completion like --=. Working around this requires looking /// backward in the command line args to pretend the = is an empty string /// see: https://www.gnu.org/software/gnuastro/manual/html_node/Bash-TAB-completion-tutorial.html -pub const bash_completions = comptimeGenerateBashCompletions(); +pub const completions = comptimeGenerateBashCompletions(); fn comptimeGenerateBashCompletions() []const u8 { comptime { @@ -319,7 +319,7 @@ fn writeBashCompletions(writer: anytype) !void { \\ # clear out prev so we don't run any of the key specific completions \\ prev="" \\ fi - \\ + \\ \\ case "${COMP_WORDS[1]}" in \\ --*) _handle_config ;; \\ +*) _handle_actions ;; diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b75c4dd162..dca119c6f4 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -5,7 +5,7 @@ const Action = @import("../cli/action.zig").Action; /// A fish completions configuration that contains all the available commands /// and options. -pub const fish_completions = comptimeGenerateFishCompletions(); +pub const completions = comptimeGenerateFishCompletions(); fn comptimeGenerateFishCompletions() []const u8 { comptime { diff --git a/src/build/gtk.zig b/src/build/gtk.zig new file mode 100644 index 0000000000..f33219988a --- /dev/null +++ b/src/build/gtk.zig @@ -0,0 +1,24 @@ +const std = @import("std"); + +pub const Targets = packed struct { + x11: bool = false, + wayland: bool = false, +}; + +/// Returns the targets that GTK4 was compiled with. +pub fn targets(b: *std.Build) Targets { + // Run pkg-config. We allow it to fail so that zig build --help + // works without all dependencies. The build will fail later when + // GTK isn't found anyways. + var code: u8 = undefined; + const output = b.runAllowFail( + &.{ "pkg-config", "--variable=targets", "gtk4" }, + &code, + .Ignore, + ) catch return .{}; + + return .{ + .x11 = std.mem.indexOf(u8, output, "x11") != null, + .wayland = std.mem.indexOf(u8, output, "wayland") != null, + }; +} diff --git a/src/build/main.zig b/src/build/main.zig new file mode 100644 index 0000000000..2917919174 --- /dev/null +++ b/src/build/main.zig @@ -0,0 +1,33 @@ +//! Build logic for Ghostty. A single "build.zig" file became far too complex +//! and spaghetti, so this package extracts the build logic into smaller, +//! more manageable pieces. + +pub const gtk = @import("gtk.zig"); +pub const Config = @import("Config.zig"); +pub const GitVersion = @import("GitVersion.zig"); + +// Artifacts +pub const GhosttyBench = @import("GhosttyBench.zig"); +pub const GhosttyDocs = @import("GhosttyDocs.zig"); +pub const GhosttyExe = @import("GhosttyExe.zig"); +pub const GhosttyLib = @import("GhosttyLib.zig"); +pub const GhosttyResources = @import("GhosttyResources.zig"); +pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig"); +pub const GhosttyWebdata = @import("GhosttyWebdata.zig"); +pub const HelpStrings = @import("HelpStrings.zig"); +pub const SharedDeps = @import("SharedDeps.zig"); +pub const UnicodeTables = @import("UnicodeTables.zig"); + +// Steps +pub const LibtoolStep = @import("LibtoolStep.zig"); +pub const LipoStep = @import("LipoStep.zig"); +pub const MetallibStep = @import("MetallibStep.zig"); +pub const XCFrameworkStep = @import("XCFrameworkStep.zig"); + +// Shell completions +pub const fish_completions = @import("fish_completions.zig").completions; +pub const zsh_completions = @import("zsh_completions.zig").completions; +pub const bash_completions = @import("bash_completions.zig").completions; + +// Helpers +pub const requireZig = @import("zig.zig").requireZig; diff --git a/src/build/webgen/main_commands.zig b/src/build/webgen/main_commands.zig new file mode 100644 index 0000000000..6e6b00c5e3 --- /dev/null +++ b/src/build/webgen/main_commands.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const Action = @import("../../cli/action.zig").Action; +const help_strings = @import("help_strings"); + +pub fn main() !void { + const output = std.io.getStdOut().writer(); + try genActions(output); +} + +// Note: as a shortcut for defining inline editOnGithubLinks per cli action the user +// is directed to the folder view on Github. This includes a README pointing them to +// the files to edit. +pub fn genActions(writer: anytype) !void { + // Write the header + try writer.writeAll( + \\--- + \\title: Reference + \\description: Reference of all Ghostty action subcommands. + \\editOnGithubLink: https://github.com/ghostty-org/ghostty/tree/main/src/cli + \\--- + \\Ghostty includes a number of utility actions that can be accessed as subcommands. + \\Actions provide utilities to work with config, list keybinds, list fonts, demo themes, + \\and debug. + \\ + ); + + inline for (@typeInfo(Action).Enum.fields) |field| { + const action = std.meta.stringToEnum(Action, field.name).?; + + switch (action) { + .help, .version => try writer.writeAll("## " ++ field.name ++ "\n"), + else => try writer.writeAll("## " ++ field.name ++ "\n"), + } + + if (@hasDecl(help_strings.Action, field.name)) { + var iter = std.mem.splitScalar(u8, @field(help_strings.Action, field.name), '\n'); + var first = true; + while (iter.next()) |s| { + try writer.writeAll(s); + try writer.writeAll("\n"); + first = false; + } + try writer.writeAll("\n```\n"); + switch (action) { + .help, .version => try writer.writeAll("ghostty --" ++ field.name ++ "\n"), + else => try writer.writeAll("ghostty +" ++ field.name ++ "\n"), + } + try writer.writeAll("```\n\n"); + } + } +} diff --git a/src/build/zig.zig b/src/build/zig.zig new file mode 100644 index 0000000000..7e327127d4 --- /dev/null +++ b/src/build/zig.zig @@ -0,0 +1,17 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Require a specific version of Zig to build this project. +pub fn requireZig(comptime required_zig: []const u8) void { + // Fail compilation if the current Zig version doesn't meet requirements. + const current_vsn = builtin.zig_version; + const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable; + if (current_vsn.major != required_vsn.major or + current_vsn.minor != required_vsn.minor) + { + @compileError(std.fmt.comptimePrint( + "Your Zig version v{} does not meet the required build version of v{}", + .{ current_vsn, required_vsn }, + )); + } +} diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index 5c42ea5ab7..4114abc63e 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -5,7 +5,7 @@ const Action = @import("../cli/action.zig").Action; /// A zsh completions configuration that contains all the available commands /// and options. -pub const zsh_completions = comptimeGenerateZshCompletions(); +pub const completions = comptimeGenerateZshCompletions(); const equals_required = "=-:::"; diff --git a/src/build_config.zig b/src/build_config.zig index c706151441..b80247aab2 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -10,86 +10,9 @@ const apprt = @import("apprt.zig"); const font = @import("font/main.zig"); const rendererpkg = @import("renderer.zig"); const WasmTarget = @import("os/wasm/target.zig").Target; +const BuildConfig = @import("build/Config.zig"); -/// The build configurations options. This may not be all available options -/// to `zig build` but it contains all the options that the Ghostty source -/// needs to know about at comptime. -/// -/// We put this all in a single struct so that we can check compatibility -/// between options, make it easy to copy and mutate options for different -/// build types, etc. -pub const BuildConfig = struct { - version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, - flatpak: bool = false, - adwaita: bool = false, - x11: bool = false, - sentry: bool = true, - app_runtime: apprt.Runtime = .none, - renderer: rendererpkg.Impl = .opengl, - font_backend: font.Backend = .freetype, - - /// The entrypoint for exe targets. - exe_entrypoint: ExeEntrypoint = .ghostty, - - /// The target runtime for the wasm build and whether to use wasm shared - /// memory or not. These are both legacy wasm-specific options that we - /// will probably have to revisit when we get back to work on wasm. - wasm_target: WasmTarget = .browser, - wasm_shared: bool = true, - - /// Configure the build options with our values. - pub fn addOptions(self: BuildConfig, step: *std.Build.Step.Options) !void { - // We need to break these down individual because addOption doesn't - // support all types. - step.addOption(bool, "flatpak", self.flatpak); - step.addOption(bool, "adwaita", self.adwaita); - step.addOption(bool, "x11", self.x11); - step.addOption(bool, "sentry", self.sentry); - step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); - step.addOption(font.Backend, "font_backend", self.font_backend); - step.addOption(rendererpkg.Impl, "renderer", self.renderer); - step.addOption(ExeEntrypoint, "exe_entrypoint", self.exe_entrypoint); - step.addOption(WasmTarget, "wasm_target", self.wasm_target); - step.addOption(bool, "wasm_shared", self.wasm_shared); - - // Our version. We also add the string version so we don't need - // to do any allocations at runtime. This has to be long enough to - // accommodate realistic large branch names for dev versions. - var buf: [1024]u8 = undefined; - step.addOption(std.SemanticVersion, "app_version", self.version); - step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ( - &buf, - "{}", - .{self.version}, - )); - step.addOption( - ReleaseChannel, - "release_channel", - channel: { - const pre = self.version.pre orelse break :channel .stable; - if (pre.len == 0) break :channel .stable; - break :channel .tip; - }, - ); - } - - /// Rehydrate our BuildConfig from the comptime options. Note that not all - /// options are available at comptime, so look closely at this implementation - /// to see what is and isn't available. - pub fn fromOptions() BuildConfig { - return .{ - .version = options.app_version, - .flatpak = options.flatpak, - .adwaita = options.adwaita, - .app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?, - .font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?, - .renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?, - .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, - .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, - .wasm_shared = options.wasm_shared, - }; - } -}; +pub const ReleaseChannel = BuildConfig.ReleaseChannel; /// The semantic version of this build. pub const version = options.app_version; @@ -112,7 +35,7 @@ pub const artifact = Artifact.detect(); /// Our build configuration. We re-export a lot of these back at the /// top-level so its a bit cleaner to use throughout the code. See the doc /// comments in BuildConfig for details on each. -pub const config = BuildConfig.fromOptions(); +const config = BuildConfig.fromOptions(); pub const exe_entrypoint = config.exe_entrypoint; pub const flatpak = options.flatpak; pub const app_runtime: apprt.Runtime = config.app_runtime; @@ -173,35 +96,3 @@ pub const Artifact = enum { }; } }; - -/// The possible entrypoints for the exe artifact. This has no effect on -/// other artifact types (i.e. lib, wasm_module). -/// -/// The whole existence of this enum is to workaround the fact that Zig -/// doesn't allow the main function to be in a file in a subdirctory -/// from the "root" of the module, and I don't want to pollute our root -/// directory with a bunch of individual zig files for each entrypoint. -/// -/// Therefore, main.zig uses this to switch between the different entrypoints. -pub const ExeEntrypoint = enum { - ghostty, - helpgen, - mdgen_ghostty_1, - mdgen_ghostty_5, - webgen_config, - webgen_actions, - bench_parser, - bench_stream, - bench_codepoint_width, - bench_grapheme_break, - bench_page_init, -}; - -/// The release channel for the build. -pub const ReleaseChannel = enum { - /// Unstable builds on every commit. - tip, - - /// Stable tagged releases. - stable, -}; diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 0000000000..7a1d99409c --- /dev/null +++ b/src/cli/README.md @@ -0,0 +1,13 @@ +# Subcommand Actions + +This is the cli specific code. It contains cli actions and tui definitions and +argument parsing. + +This README is meant as developer documentation and not as user documentation. +For user documentation, see the main README or [ghostty.org](https://ghostty.org/docs). + +## Updating documentation + +Each cli action is defined in it's own file. Documentation for each action is defined +in the doc comment associated with the `run` function. For example the `run` function +in `list_keybinds.zig` contains the help text for `ghostty +list-keybinds`. diff --git a/src/cli/action.zig b/src/cli/action.zig index a84a400241..693d509fca 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -45,12 +45,12 @@ pub const Action = enum { // Validate passed config file @"validate-config", - // List, (eventually) view, and (eventually) send crash reports. - @"crash-report", - // Show which font face Ghostty loads a codepoint from. @"show-face", + // List, (eventually) view, and (eventually) send crash reports. + @"crash-report", + pub const Error = error{ /// Multiple actions were detected. You can specify at most one /// action on the CLI otherwise the behavior desired is ambiguous. diff --git a/src/cli/args.zig b/src/cli/args.zig index be71b9096d..166b2daf51 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -38,6 +38,12 @@ pub const Error = error{ /// "DiagnosticList" and any diagnostic messages will be added to that list. /// When diagnostics are present, only allocation errors will be returned. /// +/// If the destination type has a decl "renamed", it must be of type +/// std.StaticStringMap([]const u8) and contains a mapping from the old +/// field name to the new field name. This is used to allow renaming fields +/// while still supporting the old name. If a renamed field is set, parsing +/// will automatically set the new field name. +/// /// Note: If the arena is already non-null, then it will be used. In this /// case, in the case of an error some memory might be leaked into the arena. pub fn parse( @@ -49,6 +55,24 @@ pub fn parse( const info = @typeInfo(T); assert(info == .Struct); + comptime { + // Verify all renamed fields are valid (source does not exist, + // destination does exist). + if (@hasDecl(T, "renamed")) { + for (T.renamed.keys(), T.renamed.values()) |key, value| { + if (@hasField(T, key)) { + @compileLog(key); + @compileError("renamed field source exists"); + } + + if (!@hasField(T, value)) { + @compileLog(value); + @compileError("renamed field destination does not exist"); + } + } + } + } + // Make an arena for all our allocations if we support it. Otherwise, // use an allocator that always fails. If the arena is already set on // the config, then we reuse that. See memory note in parse docs. @@ -367,6 +391,16 @@ pub fn parseIntoField( } } + // Unknown field, is the field renamed? + if (@hasDecl(T, "renamed")) { + for (T.renamed.keys(), T.renamed.values()) |old, new| { + if (mem.eql(u8, old, key)) { + try parseIntoField(T, alloc, dst, new, value); + return; + } + } + } + return error.InvalidField; } @@ -533,7 +567,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" }; @@ -1104,6 +1138,24 @@ test "parseIntoField: tagged union missing tag" { ); } +test "parseIntoField: renamed field" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var data: struct { + a: []const u8, + + const renamed = std.StaticStringMap([]const u8).initComptime(&.{ + .{ "old", "a" }, + }); + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "old", "42"); + try testing.expectEqualStrings("42", data.a); +} + /// An iterator that considers its location to be CLI args. It /// iterates through an underlying iterator and increments a counter /// to track the current CLI arg index. diff --git a/src/cli/help.zig b/src/cli/help.zig index daadc37ccd..22fe27d8d1 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -15,9 +15,11 @@ pub const Options = struct { } }; -/// The `help` command shows general help about Ghostty. You can also specify -/// `--help` or `-h` along with any action such as `+list-themes` to see help -/// for a specific action. +/// The `help` command shows general help about Ghostty. Recognized as either +/// `-h, `--help`, or like other actions `+help`. +/// +/// You can also specify `--help` or `-h` along with any action such as +/// `+list-themes` to see help for a specific action. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 65b9dcdadc..6f67a92d2b 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -24,7 +24,9 @@ pub const Options = struct { /// 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. +/// Flags: +/// +/// * `--docs`: will print out the documentation for each action. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index 9d1f34cd1e..e8a010ecd0 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -44,14 +44,21 @@ pub const Options = struct { /// the sorting will be disabled and the results instead will be shown in the /// same priority order Ghostty would use to pick a font. /// -/// The `--family` argument can be used to filter results to a specific family. -/// The family handling is identical to the `font-family` set of Ghostty -/// configuration values, so this can be used to debug why your desired font may -/// not be loading. +/// Flags: /// -/// The `--bold` and `--italic` arguments can be used to filter results to -/// specific styles. It is not guaranteed that only those styles are returned, -/// it will just prioritize fonts that match those styles. +/// * `--bold`: Filter results to specific bold styles. It is not guaranteed +/// that only those styles are returned. They are only prioritized. +/// +/// * `--italic`: Filter results to specific italic styles. It is not guaranteed +/// that only those styles are returned. They are only prioritized. +/// +/// * `--style`: Filter results based on the style string advertised by a font. +/// It is not guaranteed that only those styles are returned. They are only +/// prioritized. +/// +/// * `--family`: Filter results to a specific font family. The family handling +/// is identical to the `font-family` set of Ghostty configuration values, so +/// this can be used to debug why your desired font may not be loading. pub fn run(alloc: Allocator) !u8 { var iter = try args.argsIterator(alloc); defer iter.deinit(); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index ddaf751770..13c69d9709 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -42,11 +42,15 @@ pub const Options = struct { /// changes to the keybinds it will print out the default ones configured for /// Ghostty /// -/// The `--default` argument will print out all the default keybinds configured -/// for Ghostty +/// Flags: /// -/// The `--plain` flag will disable formatting and make the output more -/// friendly for Unix tooling. This is default when not printing to a tty. +/// * `--default`: will print out all the default keybinds +/// +/// * `--docs`: currently does nothing, intended to print out documentation +/// about the action associated with the keybinds +/// +/// * `--plain`: will disable formatting and make the output more +/// friendly for Unix tooling. This is default when not printing to a tty. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index c4dd415e7f..f7ee10ce65 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, @@ -85,6 +91,7 @@ const ThemeListElement = struct { /// Flags: /// /// * `--path`: Show the full path to the theme. +/// /// * `--plain`: Force a plain listing of themes. pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; @@ -323,9 +330,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/validate_config.zig b/src/cli/validate_config.zig index 1615ef66b3..5bc6ff4062 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -23,10 +23,13 @@ pub const Options = struct { /// The `validate-config` command is used to validate a Ghostty config file. /// -/// When executed without any arguments, this will load the config from the default location. +/// When executed without any arguments, this will load the config from the default +/// location. /// -/// The `--config-file` argument can be passed to validate a specific target config -/// file in a non-default location. +/// Flags: +/// +/// * `--config-file`: can be passed to validate a specific target config file in +/// a non-default location pub fn run(alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/version.zig b/src/cli/version.zig index 99f03384b4..4a6af242c3 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -10,7 +10,8 @@ const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig"). pub const Options = struct {}; -/// The `version` command is used to display information about Ghostty. +/// The `version` command is used to display information about Ghostty. Recognized as +/// either `+version` or `--version`. pub fn run(alloc: Allocator) !u8 { _ = alloc; @@ -68,6 +69,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 8283c2a227..8396561692 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -32,7 +32,7 @@ const url = @import("url.zig"); const Key = @import("key.zig").Key; const KeyValue = @import("key.zig").Value; const ErrorList = @import("ErrorList.zig"); -const MetricModifier = fontpkg.face.Metrics.Modifier; +const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); const log = std.log.scoped(.config); @@ -42,6 +42,15 @@ const c = @cImport({ @cInclude("unistd.h"); }); +/// Renamed fields, used by cli.parse +pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ + // Ghostty 1.1 introduced background-blur support for Linux which + // doesn't support a specific radius value. The renaming is to let + // one field be used for both platforms (macOS retained the ability + // to set a radius). + .{ "background-blur-radius", "background-blur" }, +}); + /// The font families to use. /// /// You can generate the list of valid values using the CLI: @@ -234,10 +243,46 @@ 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, + +/// What color space to use when performing alpha blending. +/// +/// This affects how text looks for different background/foreground color pairs. +/// +/// Valid values: +/// +/// * `native` - Perform alpha blending in the native color space for the OS. +/// On macOS this corresponds to Display P3, and on Linux it's sRGB. +/// +/// * `linear` - Perform alpha blending in linear space. This will eliminate +/// the darkening artifacts around the edges of text that are very visible +/// when certain color combinations are used (e.g. red / green), but makes +/// dark text look much thinner than normal and light text much thicker. +/// This is also sometimes known as "gamma correction". +/// (Currently only supported on macOS. Has no effect on Linux.) +/// +/// * `linear-corrected` - Corrects the thinning/thickening effect of linear +/// by applying a correction curve to the text alpha depending on its +/// brightness. This compensates for the thinning and makes the weight of +/// most text appear very similar to when it's blended non-linearly. +/// +/// Note: This setting affects more than just text, images will also be blended +/// in the selected color space, and custom shaders will receive colors in that +/// color space as well. +@"text-blending": TextBlending = .native, + /// 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 @@ -246,7 +291,7 @@ const c = @cImport({ /// For example, a value of `1` increases the value by 1; it does not set it to /// literally 1. A value of `20%` increases the value by 20%. And so on. /// -/// There is little to no validation on these values so the wrong values (i.e. +/// There is little to no validation on these values so the wrong values (e.g. /// `-100%`) can cause the terminal to be unusable. Use with caution and reason. /// /// Some values are clamped to minimum or maximum values. This can make it @@ -431,7 +476,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The minimum contrast ratio between the foreground and background colors. /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no -/// contrast (i.e. black on black). This value is the contrast ratio as defined +/// contrast (e.g. black on black). This value is the contrast ratio as defined /// by the [WCAG 2.0 specification](https://www.w3.org/TR/WCAG20/). /// /// If you want to avoid invisible text (same color as background), a value of @@ -582,13 +627,38 @@ palette: Palette = .{}, /// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, -/// A positive value enables blurring of the background when background-opacity -/// is less than 1. 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. +/// +/// 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. /// -/// This is only supported on macOS. -@"background-blur-radius": u8 = 0, +/// Supported on macOS and on some Linux desktop environments, including: +/// +/// * KDE Plasma (Wayland and X11) +/// +/// 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": 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 @@ -610,6 +680,10 @@ palette: Palette = .{}, /// 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: @@ -657,7 +731,7 @@ command: ?[]const u8 = null, /// injecting any configured shell integration into the command's /// environment. With `-e` its highly unlikely that you're executing a /// shell and forced shell integration is likely to cause problems -/// (i.e. by wrapping your command in a shell, setting env vars, etc.). +/// (e.g. by wrapping your command in a shell, setting env vars, etc.). /// This is a safety measure to prevent unexpected behavior. If you want /// shell integration with a `-e`-executed command, you must either /// name your binary appropriately or source the shell integration script @@ -705,7 +779,7 @@ command: ?[]const u8 = null, /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions -/// can be opening using the system opener (i.e. `open` or `xdg-open`) or +/// can be opening using the system opener (e.g. `open` or `xdg-open`) or /// executing any arbitrary binding action. /// /// Links that are configured earlier take precedence over links that are @@ -725,6 +799,11 @@ link: RepeatableLink = .{}, /// `link`). If you want to customize URL matching, use `link` and disable this. @"link-url": bool = true, +/// Whether to start the window in a maximized state. This setting applies +/// to new windows and does not apply to tabs, splits, etc. However, this setting +/// will apply to all new windows, not just the first one. +maximize: bool = false, + /// Start new windows in fullscreen. This setting applies to new windows and /// does not apply to tabs, splits, etc. However, this setting will apply to all /// new windows, not just the first one. @@ -806,7 +885,7 @@ class: ?[:0]const u8 = null, /// Valid keys are currently only listed in the /// [Ghostty source code](https://github.com/ghostty-org/ghostty/blob/d6e76858164d52cff460fedc61ddf2e560912d71/src/input/key.zig#L255). /// This is a documentation limitation and we will improve this in the future. -/// A common gotcha is that numeric keys are written as words: i.e. `one`, +/// A common gotcha is that numeric keys are written as words: e.g. `one`, /// `two`, `three`, etc. and not `1`, `2`, `3`. This will also be improved in /// the future. /// @@ -849,7 +928,7 @@ class: ?[:0]const u8 = null, /// * Ghostty will wait an indefinite amount of time for the next key in /// the sequence. There is no way to specify a timeout. The only way to /// force the output of a prefix key is to assign another keybind to -/// specifically output that key (i.e. `ctrl+a>ctrl+a=text:foo`) or +/// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or /// press an unbound key which will send both keys to the program. /// /// * If a prefix in a sequence is previously bound, the sequence will @@ -875,15 +954,17 @@ class: ?[:0]const u8 = null, /// /// * `unbind` - Remove the binding. This makes it so the previous action /// is removed, and the key will be sent through to the child command -/// if it is printable. +/// if it is printable. Unbind will remove any matching trigger, +/// including `physical:`-prefixed triggers without specifying the +/// prefix. /// -/// * `csi:text` - Send a CSI sequence. i.e. `csi:A` sends "cursor up". +/// * `csi:text` - Send a CSI sequence. e.g. `csi:A` sends "cursor up". /// -/// * `esc:text` - Send an escape sequence. i.e. `esc:d` deletes to the +/// * `esc:text` - Send an escape sequence. e.g. `esc:d` deletes to the /// end of the word to the right. /// /// * `text:text` - Send a string. Uses Zig string literal syntax. -/// i.e. `text:\x15` sends Ctrl-U. +/// e.g. `text:\x15` sends Ctrl-U. /// /// * All other actions can be found in the documentation or by using the /// `ghostty +list-actions` command. @@ -909,12 +990,12 @@ class: ?[:0]const u8 = null, /// keybinds only apply to the focused terminal surface. If this is true, /// then the keybind will be sent to all terminal surfaces. This only /// applies to actions that are surface-specific. For actions that -/// are already global (i.e. `quit`), this prefix has no effect. +/// are already global (e.g. `quit`), this prefix has no effect. /// /// * `global:` - Make the keybind global. By default, keybinds only work /// within Ghostty and under the right conditions (application focused, /// sometimes terminal focused, etc.). If you want a keybind to work -/// globally across your system (i.e. even when Ghostty is not focused), +/// globally across your system (e.g. even when Ghostty is not focused), /// specify this prefix. This prefix implies `all:`. Note: this does not /// work in all environments; see the additional notes below for more /// information. @@ -1015,7 +1096,7 @@ keybind: Keybinds = .{}, /// any of the heuristics that disable extending noted below. /// /// The "extend" value will be disabled in certain scenarios. On primary -/// screen applications (i.e. not something like Neovim), the color will not +/// screen applications (e.g. not something like Neovim), the color will not /// be extended vertically if any of the following are true: /// /// * The nearest row has any cells that have the default background color. @@ -1055,27 +1136,70 @@ keybind: Keybinds = .{}, /// configuration `font-size` will be used. @"window-inherit-font-size": bool = true, +/// Configure a preference for window decorations. This setting specifies +/// a _preference_; the actual OS, desktop environment, window manager, etc. +/// may override this preference. Ghostty will do its best to respect this +/// preference but it may not always be possible. +/// /// Valid values: /// -/// * `true` -/// * `false` - windows won't have native decorations, i.e. titlebar and -/// borders. On macOS this also disables tabs and tab overview. +/// * `none` - All window decorations will be disabled. Titlebar, +/// borders, etc. will not be shown. On macOS, this will also disable +/// tabs (enforced by the system). +/// +/// * `auto` - Automatically decide to use either client-side or server-side +/// decorations based on the detected preferences of the current OS and +/// desktop environment. This option usually makes Ghostty look the most +/// "native" for your desktop. +/// +/// * `client` - Prefer client-side decorations. +/// +/// * `server` - Prefer server-side decorations. This is only relevant +/// on Linux with GTK. This currently only works on Linux with Wayland +/// and the `org_kde_kwin_server_decoration` protocol available (e.g. +/// KDE Plasma, but almost any non-GNOME desktop supports this protocol). +/// +/// If `server` is set but the environment doesn't support server-side +/// decorations, client-side decorations will be used instead. +/// +/// The default value is `auto`. +/// +/// For the sake of backwards compatibility and convenience, this setting also +/// accepts boolean true and false values. If set to `true`, this is equivalent +/// to `auto`. If set to `false`, this is equivalent to `none`. +/// This is convenient for users who live primarily on systems that don't +/// differentiate between client and server-side decorations (e.g. macOS and +/// Windows). /// /// The "toggle_window_decorations" keybind action can be used to create -/// a keybinding to toggle this setting at runtime. +/// a keybinding to toggle this setting at runtime. This will always toggle +/// back to "auto" if the current value is "none" (this is an issue +/// that will be fixed in the future). /// /// Changing this configuration in your configuration and reloading will /// only affect new windows. Existing windows will not be affected. /// /// macOS: To hide the titlebar without removing the native window borders /// or rounded corners, use `macos-titlebar-style = hidden` instead. -@"window-decoration": bool = true, +@"window-decoration": WindowDecoration = .auto, /// 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 text that will be displayed in the subtitle of the window. Valid values: +/// +/// * `false` - Disable the subtitle. +/// * `working-directory` - Set the subtitle to the working directory of the +/// surface. +/// +/// This feature is only supported on GTK with Adwaita enabled. +@"window-subtitle": WindowSubtitle = .false, + /// The theme to use for the windows. Valid values: /// /// * `auto` - Determine the theme based on the configured terminal @@ -1280,7 +1404,7 @@ keybind: Keybinds = .{}, @"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms }, /// If true, when there are multiple split panes, the mouse selects the pane -/// that is focused. This only applies to the currently focused window; i.e. +/// that is focused. This only applies to the currently focused window; e.g. /// mousing over a split in an unfocused window will not focus that split /// and bring the window to front. /// @@ -1324,7 +1448,7 @@ keybind: Keybinds = .{}, /// and a minor amount of user interaction). @"title-report": bool = false, -/// The total amount of bytes that can be used for image data (i.e. the Kitty +/// The total amount of bytes that can be used for image data (e.g. the Kitty /// image protocol) per terminal screen. The maximum value is 4,294,967,295 /// (4GiB). The default is 320MB. If this is set to zero, then all image /// protocols will be disabled. @@ -1334,24 +1458,19 @@ keybind: Keybinds = .{}, @"image-storage-limit": u32 = 320 * 1000 * 1000, /// Whether to automatically copy selected text to the clipboard. `true` -/// will prefer to copy to the selection clipboard if supported by the -/// OS, otherwise it will copy to the system clipboard. +/// will prefer to copy to the selection clipboard, otherwise it will copy to +/// the system clipboard. /// /// The value `clipboard` will always copy text to the selection clipboard -/// (for supported systems) as well as the system clipboard. This is sometimes -/// a preferred behavior on Linux. +/// as well as the system clipboard. /// -/// Middle-click paste will always use the selection clipboard on Linux -/// and the system clipboard on macOS. Middle-click paste is always enabled -/// even if this is `false`. +/// Middle-click paste will always use the selection clipboard. Middle-click +/// paste is always enabled even if this is `false`. /// -/// The default value is true on Linux and false on macOS. macOS copy on -/// select behavior is not typical for applications so it is disabled by -/// default. On Linux, this is a standard behavior so it is enabled by -/// default. +/// The default value is true on Linux and macOS. @"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { .linux => .true, - .macos => .false, + .macos => .true, else => .false, }, @@ -1517,6 +1636,23 @@ keybind: Keybinds = .{}, /// Set it to false for the quick terminal to remain open even when it loses focus. @"quick-terminal-autohide": bool = true, +/// This configuration option determines the behavior of the quick terminal +/// when switching between macOS spaces. macOS spaces are virtual desktops +/// that can be manually created or are automatically created when an +/// application is in full-screen mode. +/// +/// Valid values are: +/// +/// * `move` - When switching to another space, the quick terminal will +/// also moved to the current space. +/// +/// * `remain` - The quick terminal will stay only in the space where it +/// was originally opened and will not follow when switching to another +/// space. +/// +/// The default value is `move`. +@"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -1543,7 +1679,9 @@ keybind: Keybinds = .{}, /// The default value is `detect`. @"shell-integration": ShellIntegration = .detect, -/// Shell integration features to enable if shell integration itself is enabled. +/// Shell integration features to enable. These require our shell integration +/// to be loaded, either automatically via shell-integration or manually. +/// /// The format of this is a list of features to enable separated by commas. If /// you prefix a feature with `no-` then it is disabled. If you omit a feature, /// its default value is used, so you must explicitly disable features you don't @@ -1572,7 +1710,7 @@ keybind: Keybinds = .{}, /// /// * `none` - OSC 4/10/11 queries receive no reply /// -/// * `8-bit` - Color components are return unscaled, i.e. `rr/gg/bb` +/// * `8-bit` - Color components are return unscaled, e.g. `rr/gg/bb` /// /// * `16-bit` - Color components are returned scaled, e.g. `rrrr/gggg/bbbb` /// @@ -1633,6 +1771,31 @@ keybind: Keybinds = .{}, /// open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, +/// Control the in-app notifications that Ghostty shows. +/// +/// On Linux (GTK) with Adwaita, in-app notifications show up as toasts. Toasts +/// appear overlaid on top of the terminal window. They are used to show +/// information that is not critical but may be important. +/// +/// Possible notifications are: +/// +/// - `clipboard-copy` (default: true) - Show a notification when text is copied +/// to the clipboard. +/// +/// To specify a notification to enable, specify the name of the notification. +/// To specify a notification to disable, prefix the name with `no-`. For +/// example, to disable `clipboard-copy`, set this configuration to +/// `no-clipboard-copy`. To enable it, set this configuration to `clipboard-copy`. +/// +/// Multiple notifications can be enabled or disabled by separating them +/// with a comma. +/// +/// A value of "false" will disable all notifications. A value of "true" will +/// enable all notifications. +/// +/// This configuration only applies to GTK with Adwaita enabled. +@"app-notifications": AppNotifications = .{}, + /// If anything other than false, fullscreen mode on macOS will not use the /// native fullscreen, but make the window fullscreen without animations and /// using a new space. It's faster than the native fullscreen mode since it @@ -1671,7 +1834,7 @@ keybind: Keybinds = .{}, /// typical for a macOS application and may not work well with all themes. /// /// The "transparent" style will also update in real-time to dynamic -/// changes to the window background color, i.e. via OSC 11. To make this +/// changes to the window background color, e.g. via OSC 11. To make this /// more aesthetically pleasing, this only happens if the terminal is /// a window, tab, or split that borders the top of the window. This /// avoids a disjointed appearance where the titlebar color changes @@ -1687,9 +1850,12 @@ keybind: Keybinds = .{}, /// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect -/// existing windows in buggy ways. The top titlebar area of the window will -/// continue to drag the window around and you will not be able to use -/// the mouse for terminal events in this space. +/// existing windows in buggy ways. +/// +/// When "hidden", the top titlebar area can no longer be used for dragging +/// the window. To drag the window, you can use option+click on the resizable +/// areas of the frame to drag the window. This is a standard macOS behavior +/// and not something Ghostty enables. /// /// The default value is "transparent". This is an opinionated choice /// but its one I think is the most aesthetically pleasing and works in @@ -1738,7 +1904,7 @@ keybind: Keybinds = .{}, /// - U.S. International /// /// Note that if an *Option*-sequence doesn't produce a printable character, it -/// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`). +/// will be treated as *Alt* regardless of this setting. (e.g. `alt+ctrl+a`). /// /// Explicit values that can be set: /// @@ -1910,6 +2076,18 @@ keybind: Keybinds = .{}, /// must always be able to move themselves into an isolated cgroup. @"linux-cgroup-hard-fail": bool = false, +/// Enable or disable GTK's OpenGL debugging logs. The default is `true` for +/// debug builds, `false` for all others. +@"gtk-opengl-debug": bool = builtin.mode == .Debug, + +/// After GTK 4.14.0, we need to force the GSK renderer to OpenGL as the default +/// GSK renderer is broken on some systems. If you would like to override +/// that bekavior, set `gtk-gsk-renderer=default` and either use your system's +/// default GSK renderer, or set the GSK_RENDERER environment variable to your +/// renderer of choice before launching Ghostty. This setting has no effect when +/// using versions of GTK earlier than 4.14.0. +@"gtk-gsk-renderer": GtkGskRenderer = .opengl, + /// If `true`, the Ghostty GTK application will run in single-instance mode: /// each new `ghostty` process launched will result in a new window if there is /// already a running process. @@ -1950,6 +2128,10 @@ keybind: Keybinds = .{}, /// title bar, or you can switch tabs with keybinds. @"gtk-tabs-location": GtkTabsLocation = .top, +/// If this is `true`, the titlebar will be hidden when the window is maximized, +/// and shown when the titlebar is unmaximized. GTK only. +@"gtk-titlebar-hide-when-maximized": bool = false, + /// Determines the appearance of the top and bottom bars when using the /// Adwaita tab bar. This requires `gtk-adwaita` to be enabled (it is /// by default). @@ -1964,29 +2146,6 @@ 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, @@ -2158,6 +2317,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()) @@ -2175,20 +2353,6 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .v }, .mods = mods }, .{ .paste_from_clipboard = {} }, ); - // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an - // alt keybinding for Copy and shift+ins is an alt keybinding for Paste - if (!builtin.target.isDarwin()) { - try result.keybind.set.put( - alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, - .{ .copy_to_clipboard = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, - .{ .paste_from_clipboard = {} }, - ); - } } // Increase font size mapping for keyboards with dedicated plus keys (like german) @@ -2220,13 +2384,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, - .{ .write_scrollback_file = .paste }, + .{ .write_screen_file = .paste }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, - .{ .write_scrollback_file = .open }, + .{ .write_screen_file = .open }, ); // Expand Selection @@ -2318,6 +2482,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .t }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_tab = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .close_tab = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .shift = true } }, @@ -2583,6 +2752,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, .{ .close_surface = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .alt = true } }, + .{ .close_tab = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true } }, @@ -2700,6 +2874,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .toggle_fullscreen = {} }, ); + // Selection clipboard paste, matches Terminal.app + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .v }, .mods = .{ .super = true, .shift = true } }, + .{ .paste_from_selection = {} }, + ); + // "Natural text editing" keybinds. This forces these keys to go back // to legacy encoding (not fixterms). It seems macOS users more than // others are used to these keys so we set them as defaults. If @@ -2718,7 +2899,7 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .backspace }, .mods = .{ .super = true } }, - .{ .esc = "\x15" }, + .{ .text = "\\x15" }, ); try result.keybind.set.put( alloc, @@ -3033,25 +3214,31 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { // We must only load a unique file once if (try loaded.fetchPut(path, {}) != null) { - try self._diagnostics.append(arena_alloc, .{ + const diag: cli.Diagnostic = .{ .message = try std.fmt.allocPrintZ( arena_alloc, "config-file {s}: cycle detected", .{path}, ), - }); + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); continue; } var file = std.fs.openFileAbsolute(path, .{}) catch |err| { if (err != error.FileNotFound or !optional) { - try self._diagnostics.append(arena_alloc, .{ + const diag: cli.Diagnostic = .{ .message = try std.fmt.allocPrintZ( arena_alloc, "error opening config-file {s}: {}", .{ path, err }, ), - }); + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); } continue; }; @@ -3061,13 +3248,16 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { switch (stat.kind) { .file => {}, else => |kind| { - try self._diagnostics.append(arena_alloc, .{ + const diag: cli.Diagnostic = .{ .message = try std.fmt.allocPrintZ( arena_alloc, "config-file {s}: not reading because file type is {s}", .{ path, @tagName(kind) }, ), - }); + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); continue; }, } @@ -3217,7 +3407,7 @@ fn loadTheme(self: *Config, theme: Theme) !void { // Setup our replay to be conditional. conditional: for (new_config._replay_steps.items) |*item| { switch (item.*) { - .expand => {}, + .expand, .diagnostic => {}, // If we see "-e" then we do NOT make the following arguments // conditional since they are supposed to be part of the @@ -3769,6 +3959,16 @@ const Replay = struct { arg: []const u8, }, + /// A diagnostic to be added to the new configuration when + /// replayed. This should only be used for diagnostics that won't + /// be reproduced during playback. For example, `config-file` + /// errors are not reloaded so they should be added here. + /// + /// Diagnostics cannot be conditional. They are always present + /// even if the conditionals don't match. This helps users find + /// errors in their configuration. + diagnostic: cli.Diagnostic, + /// The start of a "-e" argument. This marks the end of /// traditional configuration and the beginning of the /// "-e" initial command magic. This is separate from "arg" @@ -3785,6 +3985,7 @@ const Replay = struct { ) Allocator.Error!Step { return switch (self) { .@"-e" => self, + .diagnostic => |v| .{ .diagnostic = try v.clone(alloc) }, .arg => |v| .{ .arg = try alloc.dupe(u8, v) }, .expand => |v| .{ .expand = try alloc.dupe(u8, v) }, .conditional_arg => |v| conditional: { @@ -3820,6 +4021,20 @@ const Replay = struct { log.warn("error expanding paths err={}", .{err}); }, + .diagnostic => |diag| diag: { + // Best effort to clone and append the diagnostic. + // If it fails we log a warning and continue. + const arena_alloc = self.config._arena.?.allocator(); + const cloned = diag.clone(arena_alloc) catch |err| { + log.warn("error cloning diagnostic err={}", .{err}); + break :diag; + }; + self.config._diagnostics.append(arena_alloc, cloned) catch |err| { + log.warn("error appending diagnostic err={}", .{err}); + break :diag; + }; + }, + .conditional_arg => |v| conditional: { // All conditions must match. for (v.conditions) |cond| { @@ -3887,6 +4102,11 @@ pub const WindowPaddingColor = enum { @"extend-always", }; +pub const WindowSubtitle = enum { + false, + @"working-directory", +}; + /// Color represents a color using RGB. /// /// This is a packed struct so that the C API to read color values just @@ -5527,8 +5747,8 @@ pub const AdwToolbarStyle = enum { @"raised-border", }; -/// See adw-toast -pub const AdwToast = packed struct { +/// See app-notifications +pub const AppNotifications = packed struct { @"clipboard-copy": bool = true, }; @@ -5594,12 +5814,32 @@ pub const QuickTerminalScreen = enum { @"macos-menu-bar", }; +// See quick-terminal-space-behavior +pub const QuickTerminalSpaceBehavior = enum { + remain, + move, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, unicode, }; +/// See text-blending +pub const TextBlending = enum { + native, + linear, + @"linear-corrected", + + pub fn isLinear(self: TextBlending) bool { + return switch (self) { + .native => false, + .linear, .@"linear-corrected" => true, + }; + } +}; + /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults @@ -5625,6 +5865,134 @@ pub const AutoUpdate = enum { download, }; +/// See background-blur +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 enabled(self: BackgroundBlur) bool { + return switch (self) { + .false => false, + .true => true, + .radius => |v| v > 0, + }; + } + + 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 window-decoration +pub const WindowDecoration = enum { + auto, + client, + server, + none, + + pub fn parseCLI(input_: ?[]const u8) !WindowDecoration { + const input = input_ orelse return .auto; + + return if (cli.args.parseBool(input)) |b| + if (b) .auto else .none + else |_| if (std.meta.stringToEnum(WindowDecoration, input)) |v| + v + else + error.InvalidValue; + } + + test "parse WindowDecoration" { + const testing = std.testing; + + { + const v = try WindowDecoration.parseCLI(null); + try testing.expectEqual(WindowDecoration.auto, v); + } + { + const v = try WindowDecoration.parseCLI("true"); + try testing.expectEqual(WindowDecoration.auto, v); + } + { + const v = try WindowDecoration.parseCLI("false"); + try testing.expectEqual(WindowDecoration.none, v); + } + { + const v = try WindowDecoration.parseCLI("server"); + try testing.expectEqual(WindowDecoration.server, v); + } + { + const v = try WindowDecoration.parseCLI("client"); + try testing.expectEqual(WindowDecoration.client, v); + } + { + const v = try WindowDecoration.parseCLI("auto"); + try testing.expectEqual(WindowDecoration.auto, v); + } + { + const v = try WindowDecoration.parseCLI("none"); + try testing.expectEqual(WindowDecoration.none, v); + } + { + try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("")); + try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("aaaa")); + } + } +}; + /// See theme pub const Theme = struct { light: []const u8, @@ -5954,6 +6322,12 @@ pub const WindowPadding = struct { } }; +/// See the `gtk-gsk-renderer` config. +pub const GtkGskRenderer = enum { + default, + opengl, +}; + test "parse duration" { inline for (Duration.units) |unit| { var buf: [16]u8 = undefined; diff --git a/src/config/c_get.zig b/src/config/c_get.zig index d3f38415ea..251a95e772 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -84,6 +84,17 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { ptr.* = @intCast(@as(Backing, @bitCast(value))); }, + .Union => |_| { + if (@hasDecl(T, "cval")) { + const PtrT = @typeInfo(@TypeOf(T.cval)).Fn.return_type.?; + const ptr: *PtrT = @ptrCast(@alignCast(ptr_raw)); + ptr.* = value.cval(); + return true; + } + + return false; + }, + else => return false, }, } @@ -172,3 +183,30 @@ test "c_get: optional" { try testing.expectEqual(0, cval.b); } } + +test "c_get: background-blur" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + + { + c.@"background-blur" = .false; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(0, cval); + } + { + c.@"background-blur" = .true; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(20, cval); + } + { + c.@"background-blur" = .{ .radius = 42 }; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(42, cval); + } +} diff --git a/src/config/url.zig b/src/config/url.zig index 1d07647369..78f9816fdb 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -24,12 +24,18 @@ const oni = @import("oniguruma"); /// handling them well requires a non-regex approach. pub const regex = "(?:" ++ url_schemes ++ - \\)(?:[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? + \\" + \\" THIS FILE IS AUTO-GENERATED + \\ + \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* set ft=ghostty + \\ +; pub const ftplugin = \\" Vim filetype plugin file \\" Language: Ghostty config file @@ -24,6 +33,27 @@ pub const ftplugin = \\ \\let b:undo_ftplugin = 'setl cms< isk< ofu<' \\ + \\if !exists('current_compiler') + \\ compiler ghostty + \\ let b:undo_ftplugin .= " makeprg< errorformat<" + \\endif + \\ +; +pub const compiler = + \\" Vim compiler file + \\" Language: Ghostty config file + \\" Maintainer: Ghostty + \\" + \\" THIS FILE IS AUTO-GENERATED + \\ + \\if exists("current_compiler") + \\ finish + \\endif + \\let current_compiler = "ghostty" + \\ + \\CompilerSet makeprg=ghostty\ +validate-config\ --config-file=%:S + \\CompilerSet errorformat=%f:%l:%m,%m + \\ ; /// Generates the syntax file at comptime. diff --git a/src/font/Collection.zig b/src/font/Collection.zig index f79c809360..cb16528aaf 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -25,7 +25,7 @@ const DeferredFace = font.DeferredFace; const DesiredSize = font.face.DesiredSize; const Face = font.Face; const Library = font.Library; -const Metrics = font.face.Metrics; +const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; @@ -35,6 +35,17 @@ const log = std.log.scoped(.font_collection); /// Instead, use the functions available on Collection. faces: StyleArray, +/// The metric modifiers to use for this collection. The memory +/// for this is owned by the user and is not freed by the collection. +/// +/// Call `Collection.updateMetrics` to recompute the +/// collection's metrics after making changes to these. +metric_modifiers: Metrics.ModifierSet = .{}, + +/// Metrics for this collection. Call `Collection.updateMetrics` to (re)compute +/// these after adding a primary font or making changes to `metric_modifiers`. +metrics: ?Metrics = null, + /// The load options for deferred faces in the face list. If this /// is not set, then deferred faces will not be loaded. Attempting to /// add a deferred face will result in an error. @@ -421,6 +432,28 @@ pub fn setSize(self: *Collection, size: DesiredSize) !void { .alias => continue, }; } + + try self.updateMetrics(); +} + +const UpdateMetricsError = font.Face.GetMetricsError || error{ + CannotLoadPrimaryFont, +}; + +/// Update the cell metrics for this collection, based on +/// the primary font and the modifiers in `metric_modifiers`. +/// +/// This requires a primary font (index `0`) to be present. +pub fn updateMetrics(self: *Collection) UpdateMetricsError!void { + const primary_face = self.getFace(.{ .idx = 0 }) catch return error.CannotLoadPrimaryFont; + + const face_metrics = try primary_face.getMetrics(); + + var metrics = Metrics.calc(face_metrics); + + metrics.apply(self.metric_modifiers); + + self.metrics = metrics; } /// Packed array of all Style enum cases mapped to a growable list of faces. @@ -448,10 +481,6 @@ pub const LoadOptions = struct { /// The desired font size for all loaded faces. size: DesiredSize = .{ .points = 12 }, - /// The metric modifiers to use for all loaded faces. The memory - /// for this is owned by the user and is not freed by the collection. - metric_modifiers: Metrics.ModifierSet = .{}, - /// Freetype Load Flags to use when loading glyphs. This is a list of /// bitfield constants that controls operations to perform during glyph /// loading. Only a subset is exposed for configuration, for the whole set @@ -467,7 +496,6 @@ pub const LoadOptions = struct { pub fn faceOptions(self: *const LoadOptions) font.face.Options { return .{ .size = self.size, - .metric_modifiers = &self.metric_modifiers, .freetype_load_flags = self.freetype_load_flags, }; } @@ -864,3 +892,66 @@ test "hasCodepoint emoji default graphical" { try testing.expect(c.hasCodepoint(idx, '🥸', .{ .any = {} })); // TODO(fontmem): test explicit/implicit } + +test "metrics" { + const testing = std.testing; + const alloc = testing.allocator; + const testFont = font.embedded.inconsolata; + + var lib = try Library.init(); + defer lib.deinit(); + + var c = init(); + defer c.deinit(alloc); + c.load_options = .{ .library = lib }; + + _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + lib, + testFont, + .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, + ) }); + + try c.updateMetrics(); + + try std.testing.expectEqual(font.Metrics{ + .cell_width = 8, + // The cell height is 17 px because the calculation is + // + // ascender - descender + gap + // + // which, for inconsolata is + // + // 859 - -190 + 0 + // + // font units, at 1000 units per em that works out to 1.049 em, + // and 1em should be the point size * dpi scale, so 12 * (96/72) + // which is 16, and 16 * 1.049 = 16.784, which finally is rounded + // to 17. + .cell_height = 17, + .cell_baseline = 3, + .underline_position = 17, + .underline_thickness = 1, + .strikethrough_position = 10, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_height = 17, + }, c.metrics); + + // Resize should change metrics + try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 }); + try std.testing.expectEqual(font.Metrics{ + .cell_width = 16, + .cell_height = 34, + .cell_baseline = 6, + .underline_position = 34, + .underline_thickness = 2, + .strikethrough_position = 19, + .strikethrough_thickness = 2, + .overline_position = 0, + .overline_thickness = 2, + .box_thickness = 2, + .cursor_height = 34, + }, c.metrics); +} diff --git a/src/font/face/Metrics.zig b/src/font/Metrics.zig similarity index 94% rename from src/font/face/Metrics.zig rename to src/font/Metrics.zig index 7bc4566294..c78ac0972b 100644 --- a/src/font/face/Metrics.zig +++ b/src/font/Metrics.zig @@ -52,7 +52,12 @@ const Minimums = struct { const cursor_height = 1; }; -const CalcOpts = struct { +/// Metrics extracted from a font face, based on +/// the metadata tables and glyph measurements. +pub const FaceMetrics = struct { + /// The minimum cell width that can contain any glyph in the ASCII range. + /// + /// Determined by measuring all printable glyphs in the ASCII range. cell_width: f64, /// The typographic ascent metric from the font. @@ -110,45 +115,45 @@ const CalcOpts = struct { /// do not round them before using them for this function. /// /// For any nullable options that are not provided, estimates will be used. -pub fn calc(opts: CalcOpts) Metrics { +pub fn calc(face: FaceMetrics) Metrics { // We use the ceiling of the provided cell width and height to ensure // that the cell is large enough for the provided size, since we cast // it to an integer later. - const cell_width = @ceil(opts.cell_width); - const cell_height = @ceil(opts.ascent - opts.descent + opts.line_gap); + const cell_width = @ceil(face.cell_width); + const cell_height = @ceil(face.ascent - face.descent + face.line_gap); // We split our line gap in two parts, and put half of it on the top // of the cell and the other half on the bottom, so that our text never // bumps up against either edge of the cell vertically. - const half_line_gap = opts.line_gap / 2; + const half_line_gap = face.line_gap / 2; // Unlike all our other metrics, `cell_baseline` is relative to the // BOTTOM of the cell. - const cell_baseline = @round(half_line_gap - opts.descent); + const cell_baseline = @round(half_line_gap - face.descent); // We calculate a top_to_baseline to make following calculations simpler. const top_to_baseline = cell_height - cell_baseline; // If we don't have a provided cap height, // we estimate it as 75% of the ascent. - const cap_height = opts.cap_height orelse opts.ascent * 0.75; + const cap_height = face.cap_height orelse face.ascent * 0.75; // If we don't have a provided ex height, // we estimate it as 75% of the cap height. - const ex_height = opts.ex_height orelse cap_height * 0.75; + const ex_height = face.ex_height orelse cap_height * 0.75; // If we don't have a provided underline thickness, // we estimate it as 15% of the ex height. - const underline_thickness = @max(1, @ceil(opts.underline_thickness orelse 0.15 * ex_height)); + const underline_thickness = @max(1, @ceil(face.underline_thickness orelse 0.15 * ex_height)); // If we don't have a provided strikethrough thickness // then we just use the underline thickness for it. - const strikethrough_thickness = @max(1, @ceil(opts.strikethrough_thickness orelse underline_thickness)); + const strikethrough_thickness = @max(1, @ceil(face.strikethrough_thickness orelse underline_thickness)); // If we don't have a provided underline position then // we place it 1 underline-thickness below the baseline. const underline_position = @round(top_to_baseline - - (opts.underline_position orelse + (face.underline_position orelse -underline_thickness)); // If we don't have a provided strikethrough position @@ -156,7 +161,7 @@ pub fn calc(opts: CalcOpts) Metrics { // ex height, so that it's perfectly centered on lower // case text. const strikethrough_position = @round(top_to_baseline - - (opts.strikethrough_position orelse + (face.strikethrough_position orelse ex_height * 0.5 + strikethrough_thickness * 0.5)); var result: Metrics = .{ @@ -355,7 +360,7 @@ pub const Modifier = union(enum) { } test "formatConfig percent" { - const configpkg = @import("../../config.zig"); + const configpkg = @import("../config.zig"); const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); defer buf.deinit(); @@ -366,7 +371,7 @@ pub const Modifier = union(enum) { } test "formatConfig absolute" { - const configpkg = @import("../../config.zig"); + const configpkg = @import("../config.zig"); const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); defer buf.deinit(); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index f907b59ad6..65c7ecd870 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -29,7 +29,7 @@ const Collection = font.Collection; const Face = font.Face; const Glyph = font.Glyph; const Library = font.Library; -const Metrics = font.face.Metrics; +const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; const RenderOptions = font.face.RenderOptions; @@ -111,15 +111,10 @@ pub fn deinit(self: *SharedGrid, alloc: Allocator) void { } fn reloadMetrics(self: *SharedGrid) !void { - // Get our cell metrics based on a regular font ascii 'M'. Why 'M'? - // Doesn't matter, any normal ASCII will do we're just trying to make - // sure we use the regular font. - // We don't go through our caching layer because we want to minimize - // possible failures. const collection = &self.resolver.collection; - const index = collection.getIndex('M', .regular, .{ .any = {} }).?; - const face = try collection.getFace(index); - self.metrics = face.metrics; + try collection.updateMetrics(); + + self.metrics = collection.metrics.?; // Setup our sprite font. self.resolver.sprite = .{ .metrics = self.metrics }; diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 95ef02495e..249a11f759 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -20,7 +20,7 @@ const Collection = font.Collection; const Discover = font.Discover; const Style = font.Style; const Library = font.Library; -const Metrics = font.face.Metrics; +const Metrics = font.Metrics; const CodepointMap = font.CodepointMap; const DesiredSize = font.face.DesiredSize; const Face = font.Face; @@ -167,13 +167,13 @@ fn collection( const load_options: Collection.LoadOptions = .{ .library = self.font_lib, .size = size, - .metric_modifiers = key.metric_modifiers, .freetype_load_flags = key.freetype_load_flags, }; var c = Collection.init(); errdefer c.deinit(self.alloc); c.load_options = load_options; + c.metric_modifiers = key.metric_modifiers; // Search for fonts if (Discover != void) discover: { diff --git a/src/font/face.zig b/src/font/face.zig index 9f80c56378..0102010de4 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const options = @import("main.zig").options; -pub const Metrics = @import("face/Metrics.zig"); +const Metrics = @import("main.zig").Metrics; const config = @import("../config.zig"); const freetype = @import("face/freetype.zig"); const coretext = @import("face/coretext.zig"); @@ -38,7 +38,6 @@ pub const freetype_load_flags_default = if (FreetypeLoadFlags != void) .{} else /// Options for initializing a font face. pub const Options = struct { size: DesiredSize, - metric_modifiers: ?*const Metrics.ModifierSet = null, freetype_load_flags: FreetypeLoadFlags = freetype_load_flags_default, }; @@ -89,7 +88,7 @@ pub const RenderOptions = struct { /// the metrics of the primary font face. The grid metrics are used /// by the font face to better layout the glyph in situations where /// the font is not exactly the same size as the grid. - grid_metrics: ?Metrics = null, + grid_metrics: Metrics, /// The number of grid cells this glyph will take up. This can be used /// optionally by the rasterizer to better layout the glyph. @@ -100,6 +99,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 dd4f6432ec..3749b48241 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -18,9 +18,6 @@ pub const Face = struct { /// if we're using Harfbuzz. hb_font: if (harfbuzz_shaper) harfbuzz.Font else void, - /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, - /// Set quirks.disableDefaultFontFeatures quirks_disable_default_font_features: bool = false, @@ -87,11 +84,6 @@ pub const Face = struct { /// the CTFont. This does NOT copy or retain the CTFont. pub fn initFont(ct_font: *macos.text.Font, opts: font.face.Options) !Face { const traits = ct_font.getSymbolicTraits(); - const metrics = metrics: { - var metrics = try calcMetrics(ct_font); - if (opts.metric_modifiers) |v| metrics.apply(v.*); - break :metrics metrics; - }; var hb_font = if (comptime harfbuzz_shaper) font: { var hb_font = try harfbuzz.coretext.createFont(ct_font); @@ -109,7 +101,6 @@ pub const Face = struct { var result: Face = .{ .font = ct_font, .hb_font = hb_font, - .metrics = metrics, .color = color, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); @@ -352,13 +343,12 @@ pub const Face = struct { } = if (!self.isColorGlyph(glyph_index)) .{ .color = false, .depth = 1, - .space = try macos.graphics.ColorSpace.createDeviceGray(), - .context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) & - @intFromEnum(macos.graphics.ImageAlphaInfo.none), + .space = try macos.graphics.ColorSpace.createNamed(.linearGray), + .context_opts = @intFromEnum(macos.graphics.ImageAlphaInfo.only), } else .{ .color = true, .depth = 4, - .space = try macos.graphics.ColorSpace.createDeviceRGB(), + .space = try macos.graphics.ColorSpace.createNamed(.displayP3), .context_opts = @intFromEnum(macos.graphics.BitmapInfo.byte_order_32_little) | @intFromEnum(macos.graphics.ImageAlphaInfo.premultiplied_first), }; @@ -398,7 +388,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 +411,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 @@ -462,7 +453,7 @@ pub const Face = struct { }; atlas.set(region, buf); - const metrics = opts.grid_metrics orelse self.metrics; + const metrics = opts.grid_metrics; // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. @@ -505,14 +496,17 @@ pub const Face = struct { }; } - const CalcMetricsError = error{ + pub const GetMetricsError = error{ CopyTableError, InvalidHeadTable, InvalidPostTable, InvalidHheaTable, }; - fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { + /// Get the `FaceMetrics` for this face. + pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + const ct_font = self.font; + // Read the 'head' table out of the font data. const head: opentype.Head = head: { // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but @@ -730,7 +724,7 @@ pub const Face = struct { break :cell_width max; }; - return font.face.Metrics.calc(.{ + return .{ .cell_width = cell_width, .ascent = ascent, .descent = descent, @@ -741,7 +735,7 @@ pub const Face = struct { .strikethrough_thickness = strikethrough_thickness, .cap_height = cap_height, .ex_height = ex_height, - }); + }; } /// Copy the font table data for the given tag. @@ -865,7 +859,12 @@ test { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -925,7 +924,12 @@ test "in-memory" { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -947,7 +951,12 @@ test "variable" { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -973,7 +982,12 @@ test "variable set variation" { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -1016,60 +1030,3 @@ test "glyphIndex colored vs text" { try testing.expect(face.isColorGlyph(glyph)); } } - -test "coretext: metrics" { - const testFont = font.embedded.inconsolata; - const alloc = std.testing.allocator; - - var atlas = try font.Atlas.init(alloc, 512, .grayscale); - defer atlas.deinit(alloc); - - var ct_font = try Face.init( - undefined, - testFont, - .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, - ); - defer ct_font.deinit(); - - try std.testing.expectEqual(font.face.Metrics{ - .cell_width = 8, - // The cell height is 17 px because the calculation is - // - // ascender - descender + gap - // - // which, for inconsolata is - // - // 859 - -190 + 0 - // - // font units, at 1000 units per em that works out to 1.049 em, - // and 1em should be the point size * dpi scale, so 12 * (96/72) - // which is 16, and 16 * 1.049 = 16.784, which finally is rounded - // to 17. - .cell_height = 17, - .cell_baseline = 3, - .underline_position = 17, - .underline_thickness = 1, - .strikethrough_position = 10, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_height = 17, - }, ct_font.metrics); - - // Resize should change metrics - try ct_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - try std.testing.expectEqual(font.face.Metrics{ - .cell_width = 16, - .cell_height = 34, - .cell_baseline = 6, - .underline_position = 34, - .underline_thickness = 2, - .strikethrough_position = 19, - .strikethrough_thickness = 2, - .overline_position = 0, - .overline_thickness = 2, - .box_thickness = 2, - .cursor_height = 34, - }, ct_font.metrics); -} diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 630eaee25c..b56e946952 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -38,9 +38,6 @@ pub const Face = struct { /// Harfbuzz font corresponding to this face. hb_font: harfbuzz.Font, - /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, - /// Freetype load flags for this font face. load_flags: font.face.FreetypeLoadFlags, @@ -86,7 +83,6 @@ pub const Face = struct { .lib = lib.lib, .face = face, .hb_font = hb_font, - .metrics = try calcMetrics(face, opts.metric_modifiers), .load_flags = opts.freetype_load_flags, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); @@ -186,7 +182,6 @@ pub const Face = struct { /// for clearing any glyph caches, font atlas data, etc. pub fn setSize(self: *Face, opts: font.face.Options) !void { try setSize_(self.face, opts.size); - self.metrics = try calcMetrics(self.face, opts.metric_modifiers); } fn setSize_(face: freetype.Face, size: font.face.DesiredSize) !void { @@ -224,6 +219,8 @@ pub const Face = struct { vs: []const font.face.Variation, opts: font.face.Options, ) !void { + _ = opts; + // If this font doesn't support variations, we can't do anything. if (!self.face.hasMultipleMasters() or vs.len == 0) return; @@ -257,9 +254,6 @@ pub const Face = struct { // Set them! try self.face.setVarDesignCoordinates(coords); - - // We need to recalculate font metrics which may have changed. - self.metrics = try calcMetrics(self.face, opts.metric_modifiers); } /// Returns the glyph index for the given Unicode code point. If this @@ -306,7 +300,7 @@ pub const Face = struct { glyph_index: u32, opts: font.face.RenderOptions, ) !Glyph { - const metrics = opts.grid_metrics orelse self.metrics; + const metrics = opts.grid_metrics; // If we have synthetic italic, then we apply a transformation matrix. // We have to undo this because synthetic italic works by increasing @@ -589,23 +583,14 @@ pub const Face = struct { return @as(opentype.sfnt.F26Dot6, @bitCast(@as(u32, @intCast(v)))).to(f64); } - const CalcMetricsError = error{ + pub const GetMetricsError = error{ CopyTableError, }; - /// Calculate the metrics associated with a face. This is not public because - /// the metrics are calculated for every face and cached since they're - /// frequently required for renderers and take up next to little memory space - /// in the grand scheme of things. - /// - /// An aside: the proper way to limit memory usage due to faces is to limit - /// the faces with DeferredFaces and reload on demand. A Face can't be converted - /// into a DeferredFace but a Face that comes from a DeferredFace can be - /// deinitialized anytime and reloaded with the deferred face. - fn calcMetrics( - face: freetype.Face, - modifiers: ?*const font.face.Metrics.ModifierSet, - ) CalcMetricsError!font.face.Metrics { + /// Get the `FaceMetrics` for this face. + pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + const face = self.face; + const size_metrics = face.handle.*.size.*.metrics; // This code relies on this assumption, and it should always be @@ -793,7 +778,7 @@ pub const Face = struct { }; }; - var result = font.face.Metrics.calc(.{ + return .{ .cell_width = cell_width, .ascent = ascent, @@ -808,13 +793,7 @@ pub const Face = struct { .cap_height = cap_height, .ex_height = ex_height, - }); - - if (modifiers) |m| result.apply(m.*); - - // std.log.warn("font metrics={}", .{result}); - - return result; + }; } /// Copy the font table data for the given tag. @@ -843,16 +822,31 @@ test { // Generate all visible ASCII var i: u8 = 32; while (i < 127) : (i += 1) { - _ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex(i).?, .{}); + _ = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); } // Test resizing { - const g1 = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('A').?, .{}); + const g1 = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('A').?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); try testing.expectEqual(@as(u32, 11), g1.height); try ft_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - const g2 = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('A').?, .{}); + const g2 = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('A').?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); try testing.expectEqual(@as(u32, 20), g2.height); } } @@ -874,7 +868,12 @@ test "color emoji" { ); defer ft_font.deinit(); - _ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, .{}); + _ = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('🥸').?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); // Make sure this glyph has color { @@ -885,8 +884,11 @@ test "color emoji" { // resize { - const glyph = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, .{ - .grid_metrics = .{ + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('🥸').?, + .{ .grid_metrics = .{ .cell_width = 10, .cell_height = 24, .cell_baseline = 0, @@ -898,72 +900,12 @@ test "color emoji" { .overline_thickness = 0, .box_thickness = 0, .cursor_height = 0, - }, - }); + } }, + ); try testing.expectEqual(@as(u32, 24), glyph.height); } } -test "metrics" { - const testFont = font.embedded.inconsolata; - const alloc = testing.allocator; - - var lib = try Library.init(); - defer lib.deinit(); - - var atlas = try font.Atlas.init(alloc, 512, .grayscale); - defer atlas.deinit(alloc); - - var ft_font = try Face.init( - lib, - testFont, - .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, - ); - defer ft_font.deinit(); - - try testing.expectEqual(font.face.Metrics{ - .cell_width = 8, - // The cell height is 17 px because the calculation is - // - // ascender - descender + gap - // - // which, for inconsolata is - // - // 859 - -190 + 0 - // - // font units, at 1000 units per em that works out to 1.049 em, - // and 1em should be the point size * dpi scale, so 12 * (96/72) - // which is 16, and 16 * 1.049 = 16.784, which finally is rounded - // to 17. - .cell_height = 17, - .cell_baseline = 3, - .underline_position = 17, - .underline_thickness = 1, - .strikethrough_position = 10, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_height = 17, - }, ft_font.metrics); - - // Resize should change metrics - try ft_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - try testing.expectEqual(font.face.Metrics{ - .cell_width = 16, - .cell_height = 34, - .cell_baseline = 6, - .underline_position = 34, - .underline_thickness = 2, - .strikethrough_position = 19, - .strikethrough_thickness = 2, - .overline_position = 0, - .overline_thickness = 2, - .box_thickness = 2, - .cursor_height = 34, - }, ft_font.metrics); -} - test "mono to rgba" { const alloc = testing.allocator; const testFont = font.embedded.emoji; @@ -974,11 +916,16 @@ test "mono to rgba" { var atlas = try font.Atlas.init(alloc, 512, .rgba); defer atlas.deinit(alloc); - var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); defer ft_font.deinit(); // glyph 3 is mono in Noto - _ = try ft_font.renderGlyph(alloc, &atlas, 3, .{}); + _ = try ft_font.renderGlyph( + alloc, + &atlas, + 3, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); } test "svg font table" { @@ -988,7 +935,7 @@ test "svg font table" { var lib = try font.Library.init(); defer lib.deinit(); - var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); defer face.deinit(); const table = (try face.copyTable(alloc, "SVG ")).?; @@ -1037,7 +984,12 @@ test "bitmap glyph" { defer ft_font.deinit(); // glyph 77 = 'i' - const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{}); + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + 77, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); // should render crisp try testing.expectEqual(8, glyph.width); diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index 60846f350b..30540191d5 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -27,7 +27,7 @@ pub const Face = struct { presentation: font.Presentation, /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, + metrics: font.Metrics, /// The canvas element that we will reuse to render glyphs canvas: js.Object, @@ -273,7 +273,7 @@ pub const Face = struct { const underline_position = cell_height - 1; const underline_thickness: f32 = 1; - const result = font.face.Metrics{ + const result = font.Metrics{ .cell_width = @intFromFloat(cell_width), .cell_height = @intFromFloat(cell_height), .cell_baseline = @intFromFloat(cell_baseline), diff --git a/src/font/main.zig b/src/font/main.zig index 60e7593cb2..ffeb42f7a2 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -14,7 +14,7 @@ pub const Collection = @import("Collection.zig"); pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); -pub const Metrics = face.Metrics; +pub const Metrics = @import("Metrics.zig"); pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); pub const Shaper = shape.Shaper; diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 7c42fb3942..cebf44429b 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -52,7 +52,7 @@ pub fn renderGlyph( } } - const metrics = opts.grid_metrics orelse self.metrics; + const metrics = self.metrics; // We adjust our sprite width based on the cell width. const width = switch (opts.cell_width orelse 1) { diff --git a/src/global.zig b/src/global.zig index c00ce27a4b..d5a7af630e 100644 --- a/src/global.zig +++ b/src/global.zig @@ -111,6 +111,9 @@ pub const GlobalState = struct { } } + // Setup our signal handlers before logging + initSignals(); + // Output some debug information right away std.log.info("ghostty version={s}", .{build_config.version_string}); std.log.info("ghostty build optimize={s}", .{build_config.mode_string}); @@ -175,6 +178,28 @@ pub const GlobalState = struct { _ = value.deinit(); } } + + fn initSignals() void { + // Only posix systems. + if (comptime builtin.os.tag == .windows) return; + + const p = std.posix; + + var sa: p.Sigaction = .{ + .handler = .{ .handler = p.SIG.IGN }, + .mask = p.empty_sigset, + .flags = 0, + }; + + // We ignore SIGPIPE because it is a common signal we may get + // due to how we implement termio. When a terminal is closed we + // often write to a broken pipe to exit the read thread. This should + // be fixed one day but for now this helps make this a bit more + // robust. + p.sigaction(p.SIG.PIPE, &sa, null) catch |err| { + std.log.warn("failed to ignore SIGPIPE err={}", .{err}); + }; + } }; /// Maintains the Unix resource limits that we set for our process. This diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a655effb5d..a1e759bf86 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -230,7 +230,7 @@ pub const Action = union(enum) { unbind: void, /// Send a CSI sequence. The value should be the CSI sequence without the - /// CSI header (`ESC ]` or `\x1b]`). + /// CSI header (`ESC [` or `\x1b[`). csi: []const u8, /// Send an `ESC` sequence. @@ -259,6 +259,10 @@ pub const Action = union(enum) { paste_from_clipboard: void, paste_from_selection: void, + /// Copy the URL under the cursor to the clipboard. If there is no + /// URL under the cursor, this does nothing. + copy_url_to_clipboard: void, + /// Increase/decrease the font size by a certain amount. increase_font_size: f32, decrease_font_size: f32, @@ -336,7 +340,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, @@ -353,13 +357,8 @@ pub const Action = union(enum) { /// keybind = cmd+shift+d=new_split:right new_split: SplitDirection, - /// Focus a split in a given direction. - /// - /// Arguments: - /// - previous, next, up, left, down, right - /// - /// Example: Focus split on the right - /// keybind = cmd+right=goto_split:right + /// 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. @@ -403,6 +402,10 @@ pub const Action = union(enum) { /// configured. close_surface: void, + /// Close the current tab, regardless of how many splits there may be. + /// This will trigger close confirmation as configured. + close_tab: void, + /// Close the window, regardless of how many tabs or splits there may be. /// This will trigger close confirmation as configured. close_window: void, @@ -411,6 +414,9 @@ pub const Action = union(enum) { /// This only works for macOS currently. close_all_windows: void, + /// Toggle maximized window state. This only works on Linux. + toggle_maximize: void, + /// Toggle fullscreen mode of window. toggle_fullscreen: void, @@ -436,7 +442,7 @@ pub const Action = union(enum) { /// is preserved between appearances, so you can always press the keybinding /// to bring it back up. /// - /// To enable the quick terminally globally so that Ghostty doesn't + /// To enable the quick terminal globally so that Ghostty doesn't /// have to be focused, prefix your keybind with `global`. Example: /// /// ```ini @@ -461,10 +467,10 @@ pub const Action = union(enum) { toggle_quick_terminal: void, /// Show/hide all windows. If all windows become shown, we also ensure - /// Ghostty is focused. + /// Ghostty becomes focused. When hiding all windows, focus is yielded + /// to the next application as determined by the OS. /// - /// This currently only works on macOS. When hiding all windows, we do - /// not yield focus to the previous application. + /// This currently only works on macOS. toggle_visibility: void, /// Quit ghostty. @@ -734,6 +740,7 @@ pub const Action = union(enum) { .cursor_key, .reset, .copy_to_clipboard, + .copy_url_to_clipboard, .paste_from_clipboard, .paste_from_selection, .increase_font_size, @@ -753,7 +760,9 @@ pub const Action = union(enum) { .write_screen_file, .write_selection_file, .close_surface, + .close_tab, .close_window, + .toggle_maximize, .toggle_fullscreen, .toggle_window_decorations, .toggle_secure_input, @@ -1555,6 +1564,22 @@ pub const Set = struct { /// Remove a binding for a given trigger. pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void { + // Remove whatever this trigger is + self.removeExact(alloc, t); + + // If we have a physical we remove translated and vice versa. + const alternate: Trigger.Key = switch (t.key) { + .unicode => return, + .translated => |k| .{ .physical = k }, + .physical => |k| .{ .translated = k }, + }; + + var alt_t: Trigger = t; + alt_t.key = alternate; + self.removeExact(alloc, alt_t); + } + + fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { const entry = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); @@ -1586,7 +1611,7 @@ pub const Set = struct { }, } } else { - // No over trigger points to this action so we remove + // No other trigger points to this action so we remove // the reverse mapping completely. _ = self.reverse.remove(leaf.action); } @@ -2128,6 +2153,24 @@ test "set: parseAndPut removed binding" { try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); } +test "set: parseAndPut removed physical binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "physical:a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Creates forward mapping + { + const trigger: Trigger = .{ .key = .{ .physical = .a } }; + try testing.expect(s.get(trigger) == null); + } + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); +} + test "set: parseAndPut sequence" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index f9c6e98d15..54d49b0883 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())); } } diff --git a/src/inspector/page.zig b/src/inspector/page.zig index d74f07b1c1..bb95d59b9d 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 7fea4ecf85..1b499f9dd0 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -208,6 +208,20 @@ pub const VTEvent = struct { ); }, + .Union => |info| { + const Tag = info.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, v)); + inline for (info.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + if (field.type == void) { + break try md.put("data", tag_name); + } else { + break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name)); + } + } + } + }, + else => { @compileLog(T); @compileError("unsupported type, see log"); @@ -265,7 +279,7 @@ pub const VTEvent = struct { ), else => switch (Value) { - u8 => try md.put( + u8, u16 => try md.put( key, try std.fmt.allocPrintZ(alloc, "{}", .{value}), ), diff --git a/src/inspector/units.zig b/src/inspector/units.zig new file mode 100644 index 0000000000..28f928232f --- /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.zig b/src/main.zig index ecf38fbb30..121a3b7d20 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,6 +9,7 @@ const entrypoint = switch (build_config.exe_entrypoint) { .mdgen_ghostty_5 => @import("build/mdgen/main_ghostty_5.zig"), .webgen_config => @import("build/webgen/main_config.zig"), .webgen_actions => @import("build/webgen/main_actions.zig"), + .webgen_commands => @import("build/webgen/main_commands.zig"), .bench_parser => @import("bench/parser.zig"), .bench_stream => @import("bench/stream.zig"), .bench_codepoint_width => @import("bench/codepoint-width.zig"), diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 0a66c59878..bef101acc7 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -77,7 +77,22 @@ pub fn cloneInto(cgroup: []const u8) !posix.pid_t { // Get a file descriptor that refers to the cgroup directory in the cgroup // sysfs to pass to the kernel in clone3. const fd: linux.fd_t = fd: { - const rc = linux.open(path, linux.O{ .PATH = true, .DIRECTORY = true }, 0); + const rc = linux.open( + path, + .{ + // Self-explanatory: we expect to open a directory, and + // we only need the path-level permissions. + .PATH = true, + .DIRECTORY = true, + + // We don't want to leak this fd to the child process + // when we clone below since we're using this fd for + // a cgroup clone. + .CLOEXEC = true, + }, + 0, + ); + switch (posix.errno(rc)) { .SUCCESS => break :fd @as(linux.fd_t, @intCast(rc)), else => |errno| { diff --git a/src/os/desktop.zig b/src/os/desktop.zig index 3a61e2eaa4..c73f150e03 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -60,6 +60,9 @@ pub fn launchedFromDesktop() bool { }; } +/// The list of desktop environments that we detect. New Linux desktop +/// environments should only be added to this list if there's a specific reason +/// to differentiate between `gnome` and `other`. pub const DesktopEnvironment = enum { gnome, macos, @@ -67,21 +70,84 @@ pub const DesktopEnvironment = enum { 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. +/// Detect what desktop environment we are running under. This is mainly used +/// on Linux to enable or disable certain features 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 + + // 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; + if (posix.getenv("XDG_SESSION_DESKTOP")) |sd| { + if (std.ascii.eqlIgnoreCase("gnome", sd)) break :de .gnome; + if (std.ascii.eqlIgnoreCase("gnome-xorg", sd)) break :de .gnome; + } + + // If $XDG_SESSION_DESKTOP is not set, or doesn't match any known + // DE, check $XDG_CURRENT_DESKTOP. $XDG_CURRENT_DESKTOP is a + // colon-separated list of up to three desktop names, although we + // only look at the first. + // https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html + if (posix.getenv("XDG_CURRENT_DESKTOP")) |cd| { + var cd_it = std.mem.splitScalar(u8, cd, ':'); + const cd_first = cd_it.first(); + if (std.ascii.eqlIgnoreCase(cd_first, "gnome")) break :de .gnome; + } + break :de .other; }, else => .other, }; } + +test "desktop environment" { + const testing = std.testing; + + switch (builtin.os.tag) { + .macos => try testing.expectEqual(.macos, desktopEnvironment()), + .windows => try testing.expectEqual(.windows, desktopEnvironment()), + .linux => { + const getenv = std.posix.getenv; + const setenv = @import("env.zig").setenv; + const unsetenv = @import("env.zig").unsetenv; + + const xdg_current_desktop = getenv("XDG_CURRENT_DESKTOP"); + defer if (xdg_current_desktop) |v| { + _ = setenv("XDG_CURRENT_DESKTOP", v); + } else { + _ = unsetenv("XDG_CURRENT_DESKTOP"); + }; + _ = unsetenv("XDG_CURRENT_DESKTOP"); + + const xdg_session_desktop = getenv("XDG_SESSION_DESKTOP"); + defer if (xdg_session_desktop) |v| { + _ = setenv("XDG_SESSION_DESKTOP", v); + } else { + _ = unsetenv("XDG_SESSION_DESKTOP"); + }; + _ = unsetenv("XDG_SESSION_DESKTOP"); + + _ = setenv("XDG_SESSION_DESKTOP", "gnome"); + try testing.expectEqual(.gnome, desktopEnvironment()); + _ = setenv("XDG_SESSION_DESKTOP", "gnome-xorg"); + try testing.expectEqual(.gnome, desktopEnvironment()); + _ = setenv("XDG_SESSION_DESKTOP", "foobar"); + try testing.expectEqual(.other, desktopEnvironment()); + + _ = unsetenv("XDG_SESSION_DESKTOP"); + try testing.expectEqual(.other, desktopEnvironment()); + + _ = setenv("XDG_CURRENT_DESKTOP", "GNOME"); + try testing.expectEqual(.gnome, desktopEnvironment()); + _ = setenv("XDG_CURRENT_DESKTOP", "FOOBAR"); + try testing.expectEqual(.other, desktopEnvironment()); + _ = unsetenv("XDG_CURRENT_DESKTOP"); + try testing.expectEqual(.other, desktopEnvironment()); + }, + else => try testing.expectEqual(.other, DesktopEnvironment()), + } +} diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index faac4bd272..09570554ef 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -265,16 +265,12 @@ pub const FlatpakHostCommand = struct { } // Build our args - const args_ptr = c.g_ptr_array_new(); - { - errdefer _ = c.g_ptr_array_free(args_ptr, 1); - for (self.argv) |arg| { - const argZ = try arena.dupeZ(u8, arg); - c.g_ptr_array_add(args_ptr, argZ.ptr); - } + const args = try arena.alloc(?[*:0]u8, self.argv.len + 1); + for (0.., self.argv) |i, arg| { + const argZ = try arena.dupeZ(u8, arg); + args[i] = argZ.ptr; } - const args = c.g_ptr_array_free(args_ptr, 0); - defer c.g_free(@as(?*anyopaque, @ptrCast(args))); + args[args.len - 1] = null; // Get the cwd in case we don't have ours set. A small optimization // would be to do this only if we need it but this isn't a @@ -286,7 +282,7 @@ pub const FlatpakHostCommand = struct { const params = c.g_variant_new( "(^ay^aay@a{uh}@a{ss}u)", @as(*const anyopaque, if (self.cwd) |*cwd| cwd.ptr else g_cwd), - args, + args.ptr, c.g_variant_builder_end(fd_builder), c.g_variant_builder_end(env_builder), @as(c_int, 0), diff --git a/src/os/main.zig b/src/os/main.zig index e652a7981b..df6f894f50 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -21,6 +21,7 @@ pub const passwd = @import("passwd.zig"); pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); +pub const shell = @import("shell.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); @@ -48,3 +49,4 @@ pub const open = openpkg.open; pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; +pub const ShellEscapeWriter = shell.ShellEscapeWriter; diff --git a/src/os/pipe.zig b/src/os/pipe.zig index 392f720834..2cb7bd4a39 100644 --- a/src/os/pipe.zig +++ b/src/os/pipe.zig @@ -3,10 +3,11 @@ const builtin = @import("builtin"); const windows = @import("windows.zig"); const posix = std.posix; -/// pipe() that works on Windows and POSIX. +/// pipe() that works on Windows and POSIX. For POSIX systems, this sets +/// CLOEXEC on the file descriptors. pub fn pipe() ![2]posix.fd_t { switch (builtin.os.tag) { - else => return try posix.pipe(), + else => return try posix.pipe2(.{ .CLOEXEC = true }), .windows => { var read: windows.HANDLE = undefined; var write: windows.HANDLE = undefined; diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index c0f82dec5d..4ef256c1ac 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -21,7 +21,11 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // This is the sentinel value we look for in the path to know // we've found the resources directory. - const sentinel = "terminfo/ghostty.termcap"; + const sentinel = switch (comptime builtin.target.os.tag) { + .windows => "terminfo/ghostty.terminfo", + .macos => "terminfo/78/xterm-ghostty", + else => "terminfo/x/xterm-ghostty", + }; // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; diff --git a/src/os/shell.zig b/src/os/shell.zig new file mode 100644 index 0000000000..23648a82ae --- /dev/null +++ b/src/os/shell.zig @@ -0,0 +1,95 @@ +const std = @import("std"); +const testing = std.testing; + +/// Writer that escapes characters that shells treat specially to reduce the +/// risk of injection attacks or other such weirdness. Specifically excludes +/// linefeeds so that they can be used to delineate lists of file paths. +/// +/// T should be a Zig type that follows the `std.io.Writer` interface. +pub fn ShellEscapeWriter(comptime T: type) type { + return struct { + child_writer: T, + + fn write(self: *ShellEscapeWriter(T), data: []const u8) error{Error}!usize { + var count: usize = 0; + for (data) |byte| { + const buf = switch (byte) { + '\\', + '"', + '\'', + '$', + '`', + '*', + '?', + ' ', + '|', + => &[_]u8{ '\\', byte }, + else => &[_]u8{byte}, + }; + self.child_writer.writeAll(buf) catch return error.Error; + count += 1; + } + return count; + } + + const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write); + + pub fn writer(self: *ShellEscapeWriter(T)) Writer { + return .{ .context = self }; + } + }; +} + +test "shell escape 1" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("abc"); + try testing.expectEqualStrings("abc", fmt.getWritten()); +} + +test "shell escape 2" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a c"); + try testing.expectEqualStrings("a\\ c", fmt.getWritten()); +} + +test "shell escape 3" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a?c"); + try testing.expectEqualStrings("a\\?c", fmt.getWritten()); +} + +test "shell escape 4" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a\\c"); + try testing.expectEqualStrings("a\\\\c", fmt.getWritten()); +} + +test "shell escape 5" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a|c"); + try testing.expectEqualStrings("a\\|c", fmt.getWritten()); +} + +test "shell escape 6" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a\"c"); + try testing.expectEqualStrings("a\\\"c", fmt.getWritten()); +} diff --git a/src/pty.zig b/src/pty.zig index c0d082411c..b6dc2e145b 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -94,6 +94,9 @@ const PosixPty = struct { }; /// The file descriptors for the master and slave side of the pty. + /// The slave side is never closed automatically by this struct + /// so the caller is responsible for closing it if things + /// go wrong. master: Fd, slave: Fd, @@ -117,6 +120,24 @@ const PosixPty = struct { _ = posix.system.close(slave_fd); } + // Set CLOEXEC on the master fd, only the slave fd should be inherited + // by the child process (shell/command). + cloexec: { + const flags = std.posix.fcntl(master_fd, std.posix.F.GETFD, 0) catch |err| { + log.warn("error getting flags for master fd err={}", .{err}); + break :cloexec; + }; + + _ = std.posix.fcntl( + master_fd, + std.posix.F.SETFD, + flags | std.posix.FD_CLOEXEC, + ) catch |err| { + log.warn("error setting CLOEXEC on master fd err={}", .{err}); + break :cloexec; + }; + } + // Enable UTF-8 mode. I think this is on by default on Linux but it // is NOT on by default on macOS so we ensure that it is always set. var attrs: c.termios = undefined; @@ -126,7 +147,7 @@ const PosixPty = struct { if (c.tcsetattr(master_fd, c.TCSANOW, &attrs) != 0) return error.OpenptyFailed; - return Pty{ + return .{ .master = master_fd, .slave = slave_fd, }; @@ -134,7 +155,6 @@ const PosixPty = struct { pub fn deinit(self: *Pty) void { _ = posix.system.close(self.master); - _ = posix.system.close(self.slave); self.* = undefined; } @@ -181,6 +201,7 @@ const PosixPty = struct { try posix.sigaction(posix.SIG.HUP, &sa, null); try posix.sigaction(posix.SIG.ILL, &sa, null); try posix.sigaction(posix.SIG.INT, &sa, null); + try posix.sigaction(posix.SIG.PIPE, &sa, null); try posix.sigaction(posix.SIG.SEGV, &sa, null); try posix.sigaction(posix.SIG.TRAP, &sa, null); try posix.sigaction(posix.SIG.TERM, &sa, null); @@ -201,8 +222,6 @@ const PosixPty = struct { // Can close master/slave pair now posix.close(self.slave); posix.close(self.master); - - // TODO: reset signals } }; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index b37f440f48..52a5437c66 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -21,6 +21,7 @@ const renderer = @import("../renderer.zig"); const math = @import("../math.zig"); const Surface = @import("../Surface.zig"); const link = @import("link.zig"); +const graphics = macos.graphics; const fgMode = @import("cell.zig").fgMode; const isCovering = @import("cell.zig").isCovering; const shadertoy = @import("shadertoy.zig"); @@ -68,7 +69,7 @@ config: DerivedConfig, surface_mailbox: apprt.surface.Mailbox, /// Current font metrics defining our grid. -grid_metrics: font.face.Metrics, +grid_metrics: font.Metrics, /// The size of everything. size: renderer.Size, @@ -105,10 +106,6 @@ default_cursor_color: ?terminal.color.RGB, /// foreground color as the cursor color. cursor_invert: bool, -/// The current frame background color. This is only updated during -/// the updateFrame method. -current_background_color: terminal.color.RGB, - /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. @@ -151,6 +148,9 @@ layer: objc.Object, // CAMetalLayer /// a display link. display_link: ?DisplayLink = null, +/// The `CGColorSpace` that represents our current terminal color space +terminal_colorspace: *graphics.ColorSpace, + /// Custom shader state. This is only set if we have custom shaders. custom_shader_state: ?CustomShaderState = null, @@ -209,20 +209,31 @@ pub const GPUState = struct { } fn chooseDevice() error{NoMetalDevice}!objc.Object { - const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); - defer devices.release(); var chosen_device: ?objc.Object = null; - var iter = devices.iterate(); - while (iter.next()) |device| { - // We want a GPU that’s connected to a display. - if (device.getProperty(bool, "isHeadless")) continue; - chosen_device = device; - // If the user has an eGPU plugged in, they probably want - // to use it. Otherwise, integrated GPUs are better for - // battery life and thermals. - if (device.getProperty(bool, "isRemovable") or - device.getProperty(bool, "isLowPower")) break; + + switch (comptime builtin.os.tag) { + .macos => { + const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); + defer devices.release(); + + var iter = devices.iterate(); + while (iter.next()) |device| { + // We want a GPU that’s connected to a display. + if (device.getProperty(bool, "isHeadless")) continue; + chosen_device = device; + // If the user has an eGPU plugged in, they probably want + // to use it. Otherwise, integrated GPUs are better for + // battery life and thermals. + if (device.getProperty(bool, "isRemovable") or + device.getProperty(bool, "isLowPower")) break; + } + }, + .ios => { + chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); + }, + else => @compileError("unsupported target for Metal"), } + const device = chosen_device orelse return error.NoMetalDevice; return device.retain(); } @@ -360,6 +371,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, @@ -378,6 +390,8 @@ pub const DerivedConfig = struct { custom_shaders: configpkg.RepeatablePath, links: link.Set, vsync: bool, + colorspace: configpkg.Config.WindowColorspace, + blending: configpkg.Config.TextBlending, pub fn init( alloc_gpa: Allocator, @@ -410,6 +424,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, @@ -447,7 +462,8 @@ pub const DerivedConfig = struct { .custom_shaders = custom_shaders, .links = links, .vsync = config.@"window-vsync", - + .colorspace = config.@"window-colorspace", + .blending = config.@"text-blending", .arena = arena, }; } @@ -477,10 +493,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { } pub fn init(alloc: Allocator, options: renderer.Options) !Metal { - var arena = ArenaAllocator.init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - const ViewInfo = struct { view: objc.Object, scaleFactor: f64, @@ -499,7 +511,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { nswindow.getProperty(?*anyopaque, "contentView").?, ); const scaleFactor = nswindow.getProperty( - macos.graphics.c.CGFloat, + graphics.c.CGFloat, "backingScaleFactor", ); @@ -540,6 +552,40 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { layer.setProperty("opaque", options.config.background_opacity >= 1); layer.setProperty("displaySyncEnabled", options.config.vsync); + // Set our layer's pixel format appropriately. + layer.setProperty( + "pixelFormat", + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (options.config.blending.isLinear()) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); + + // Set our layer's color space to Display P3. + // This allows us to have "Apple-style" alpha blending, + // since it seems to be the case that Apple apps like + // Terminal and TextEdit render text in the display's + // color space using converted colors, which reduces, + // but does not fully eliminate blending artifacts. + const colorspace = try graphics.ColorSpace.createNamed(.displayP3); + defer colorspace.release(); + layer.setProperty("colorspace", colorspace); + + // Create a colorspace the represents our terminal colors + // this will allow us to create e.g. `CGColor`s for things + // like the current background color. + const terminal_colorspace = try graphics.ColorSpace.createNamed( + switch (options.config.colorspace) { + .@"display-p3" => .displayP3, + .srgb => .sRGB, + }, + ); + errdefer terminal_colorspace.release(); + // Make our view layer-backed with our Metal layer. On iOS views are // always layer backed so we don't need to do this. But on iOS the // caller MUST be sure to set the layerClass to CAMetalLayer. @@ -565,54 +611,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { }); errdefer font_shaper.deinit(); - // Load our custom shaders - const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( - arena_alloc, - options.config.custom_shaders, - .msl, - ) catch |err| err: { - log.warn("error loading custom shaders err={}", .{err}); - break :err &.{}; - }; - - // If we have custom shaders then setup our state - var custom_shader_state: ?CustomShaderState = state: { - if (custom_shaders.len == 0) break :state null; - - // Build our sampler for our texture - var sampler = try mtl_sampler.Sampler.init(gpu_state.device); - errdefer sampler.deinit(); - - break :state .{ - // Resolution and screen textures will be fixed up by first - // call to setScreenSize. Draw calls will bail out early if - // the screen size hasn't been set yet, so it won't error. - .front_texture = undefined, - .back_texture = undefined, - .sampler = sampler, - .uniforms = .{ - .resolution = .{ 0, 0, 1 }, - .time = 1, - .time_delta = 1, - .frame_rate = 1, - .frame = 1, - .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - .mouse = .{ 0, 0, 0, 0 }, - .date = .{ 0, 0, 0, 0 }, - .sample_rate = 1, - }, - - .first_frame_time = try std.time.Instant.now(), - .last_frame_time = try std.time.Instant.now(), - }; - }; - errdefer if (custom_shader_state) |*state| state.deinit(); - - // Initialize our shaders - var shaders = try Shaders.init(alloc, gpu_state.device, custom_shaders); - errdefer shaders.deinit(alloc); - // Initialize all the data that requires a critical font section. const font_critical: struct { metrics: font.Metrics, @@ -648,7 +646,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .cursor_color = null, .default_cursor_color = options.config.cursor_color, .cursor_invert = options.config.cursor_invert, - .current_background_color = options.config.background, // Render state .cells = .{}, @@ -661,7 +658,16 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .min_contrast = options.config.min_contrast, .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, .cursor_color = undefined, + .bg_color = .{ + options.config.background.r, + options.config.background.g, + options.config.background.b, + @intFromFloat(@round(options.config.background_opacity * 255.0)), + }, .cursor_wide = false, + .use_display_p3 = options.config.colorspace == .@"display-p3", + .use_linear_blending = options.config.blending.isLinear(), + .use_experimental_linear_correction = options.config.blending == .@"linear-corrected", }, // Fonts @@ -669,16 +675,19 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .font_shaper = font_shaper, .font_shaper_cache = font.ShaperCache.init(), - // Shaders - .shaders = shaders, + // Shaders (initialized below) + .shaders = undefined, // Metal stuff .layer = layer, .display_link = display_link, - .custom_shader_state = custom_shader_state, + .terminal_colorspace = terminal_colorspace, + .custom_shader_state = null, .gpu_state = gpu_state, }; + try result.initShaders(); + // Do an initialize screen size setup to ensure our undefined values // above are initialized. try result.setScreenSize(result.size); @@ -696,6 +705,8 @@ pub fn deinit(self: *Metal) void { } } + self.terminal_colorspace.release(); + self.cells.deinit(self.alloc); self.font_shaper.deinit(); @@ -710,11 +721,82 @@ pub fn deinit(self: *Metal) void { } self.image_placements.deinit(self.alloc); + self.deinitShaders(); + + self.* = undefined; +} + +fn deinitShaders(self: *Metal) void { if (self.custom_shader_state) |*state| state.deinit(); self.shaders.deinit(self.alloc); +} - self.* = undefined; +fn initShaders(self: *Metal) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Load our custom shaders + const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( + arena_alloc, + self.config.custom_shaders, + .msl, + ) catch |err| err: { + log.warn("error loading custom shaders err={}", .{err}); + break :err &.{}; + }; + + var custom_shader_state: ?CustomShaderState = state: { + if (custom_shaders.len == 0) break :state null; + + // Build our sampler for our texture + var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device); + errdefer sampler.deinit(); + + break :state .{ + // Resolution and screen textures will be fixed up by first + // call to setScreenSize. Draw calls will bail out early if + // the screen size hasn't been set yet, so it won't error. + .front_texture = undefined, + .back_texture = undefined, + .sampler = sampler, + .uniforms = .{ + .resolution = .{ 0, 0, 1 }, + .time = 1, + .time_delta = 1, + .frame_rate = 1, + .frame = 1, + .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, + .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, + .mouse = .{ 0, 0, 0, 0 }, + .date = .{ 0, 0, 0, 0 }, + .sample_rate = 1, + }, + + .first_frame_time = try std.time.Instant.now(), + .last_frame_time = try std.time.Instant.now(), + }; + }; + errdefer if (custom_shader_state) |*state| state.deinit(); + + var shaders = try Shaders.init( + self.alloc, + self.gpu_state.device, + custom_shaders, + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.config.blending.isLinear()) + mtl.MTLPixelFormat.bgra8unorm_srgb + else + mtl.MTLPixelFormat.bgra8unorm, + ); + errdefer shaders.deinit(self.alloc); + + self.shaders = shaders; + self.custom_shader_state = custom_shader_state; } /// This is called just prior to spinning up the renderer thread for @@ -964,19 +1046,6 @@ pub fn updateFrame( } } - // If our terminal screen size doesn't match our expected renderer - // size then we skip a frame. This can happen if the terminal state - // is resized between when the renderer mailbox is drained and when - // the state mutex is acquired inside this function. - // - // For some reason this doesn't seem to cause any significant issues - // with flickering while resizing. '\_('-')_/' - if (self.cells.size.rows != state.terminal.rows or - self.cells.size.columns != state.terminal.cols) - { - return; - } - // Get the viewport pin so that we can compare it to the current. const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; @@ -1098,7 +1167,38 @@ pub fn updateFrame( self.cells_viewport = critical.viewport_pin; // Update our background color - self.current_background_color = critical.bg; + self.uniforms.bg_color = .{ + critical.bg.r, + critical.bg.g, + critical.bg.b, + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; + + // Update the background color on our layer + // + // TODO: Is this expensive? Should we be checking if our + // bg color has changed first before doing this work? + { + const color = graphics.c.CGColorCreate( + @ptrCast(self.terminal_colorspace), + &[4]f64{ + @as(f64, @floatFromInt(critical.bg.r)) / 255.0, + @as(f64, @floatFromInt(critical.bg.g)) / 255.0, + @as(f64, @floatFromInt(critical.bg.b)) / 255.0, + self.config.background_opacity, + }, + ); + defer graphics.c.CGColorRelease(color); + + // We use a CATransaction so that Core Animation knows that we + // updated the background color property. Otherwise it behaves + // weird, not updating the color until we resize. + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, "begin", .{}); + defer CATransaction.msgSend(void, "commit", .{}); + + self.layer.setProperty("backgroundColor", color); + } // Go through our images and see if we need to setup any textures. { @@ -1220,10 +1320,10 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store)); attachment.setProperty("texture", screen_texture.value); attachment.setProperty("clearColor", mtl.MTLClearColor{ - .red = @as(f32, @floatFromInt(self.current_background_color.r)) / 255 * self.config.background_opacity, - .green = @as(f32, @floatFromInt(self.current_background_color.g)) / 255 * self.config.background_opacity, - .blue = @as(f32, @floatFromInt(self.current_background_color.b)) / 255 * self.config.background_opacity, - .alpha = self.config.background_opacity, + .red = 0.0, + .green = 0.0, + .blue = 0.0, + .alpha = 0.0, }); } @@ -1239,19 +1339,19 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); // Draw background images first - try self.drawImagePlacements(encoder, self.image_placements.items[0..self.image_bg_end]); + try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]); // Then draw background cells try self.drawCellBgs(encoder, frame); // Then draw images under text - try self.drawImagePlacements(encoder, self.image_placements.items[self.image_bg_end..self.image_text_end]); + try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]); // Then draw fg cells try self.drawCellFgs(encoder, frame, fg_count); // Then draw remaining images - try self.drawImagePlacements(encoder, self.image_placements.items[self.image_text_end..]); + try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]); } // If we have custom shaders, then we render them. @@ -1444,6 +1544,7 @@ fn drawPostShader( fn drawImagePlacements( self: *Metal, encoder: objc.Object, + frame: *const FrameState, placements: []const mtl_image.Placement, ) !void { if (placements.len == 0) return; @@ -1455,15 +1556,16 @@ fn drawImagePlacements( .{self.shaders.image_pipeline.value}, ); - // Set our uniform, which is the only shared buffer + // Set our uniforms encoder.msgSend( void, - objc.sel("setVertexBytes:length:atIndex:"), - .{ - @as(*const anyopaque, @ptrCast(&self.uniforms)), - @as(c_ulong, @sizeOf(@TypeOf(self.uniforms))), - @as(c_ulong, 1), - }, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, ); for (placements) |placement| { @@ -1575,6 +1677,11 @@ fn drawCellBgs( ); // Set our buffers + encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); encoder.msgSend( void, objc.sel("setFragmentBuffer:offset:atIndex:"), @@ -1634,18 +1741,17 @@ fn drawCellFgs( encoder.msgSend( void, objc.sel("setFragmentTexture:atIndex:"), - .{ - frame.grayscale.value, - @as(c_ulong, 0), - }, + .{ frame.grayscale.value, @as(c_ulong, 0) }, ); encoder.msgSend( void, objc.sel("setFragmentTexture:atIndex:"), - .{ - frame.color.value, - @as(c_ulong, 1), - }, + .{ frame.color.value, @as(c_ulong, 1) }, + ); + encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) }, ); encoder.msgSend( @@ -1990,17 +2096,73 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { // Set our new minimum contrast self.uniforms.min_contrast = config.min_contrast; + // Set our new color space and blending + self.uniforms.use_display_p3 = config.colorspace == .@"display-p3"; + self.uniforms.use_linear_blending = config.blending.isLinear(); + self.uniforms.use_experimental_linear_correction = config.blending == .@"linear-corrected"; + // Set our new colors self.default_background_color = config.background; self.default_foreground_color = config.foreground; self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + // Update our layer's opaqueness and display sync in case they changed. + { + // We use a CATransaction so that Core Animation knows that we + // updated the opaque property. Otherwise it behaves weird, not + // properly going from opaque to transparent unless we resize. + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, "begin", .{}); + defer CATransaction.msgSend(void, "commit", .{}); + + self.layer.setProperty("opaque", config.background_opacity >= 1); + self.layer.setProperty("displaySyncEnabled", config.vsync); + } + + // Update our terminal colorspace if it changed + if (self.config.colorspace != config.colorspace) { + const terminal_colorspace = try graphics.ColorSpace.createNamed( + switch (config.colorspace) { + .@"display-p3" => .displayP3, + .srgb => .sRGB, + }, + ); + errdefer terminal_colorspace.release(); + self.terminal_colorspace.release(); + self.terminal_colorspace = terminal_colorspace; + } + + const old_blending = self.config.blending; + const old_custom_shaders = self.config.custom_shaders; + self.config.deinit(); self.config = config.*; // Reset our viewport to force a rebuild, in case of a font change. self.cells_viewport = null; + + // We reinitialize our shaders if our + // blending or custom shaders changed. + if (old_blending != config.blending or + !old_custom_shaders.equal(config.custom_shaders)) + { + self.deinitShaders(); + try self.initShaders(); + // We call setScreenSize to reinitialize + // the textures used for custom shaders. + if (self.custom_shader_state != null) { + try self.setScreenSize(self.size); + } + // And we update our layer's pixel format appropriately. + self.layer.setProperty( + "pixelFormat", + if (config.blending.isLinear()) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); + } } /// Resize the screen. @@ -2044,7 +2206,7 @@ pub fn setScreenSize( } // Set the size of the drawable surface to the bounds - self.layer.setProperty("drawableSize", macos.graphics.Size{ + self.layer.setProperty("drawableSize", graphics.Size{ .width = @floatFromInt(size.screen.width), .height = @floatFromInt(size.screen.height), }); @@ -2076,7 +2238,11 @@ pub fn setScreenSize( .min_contrast = old.min_contrast, .cursor_pos = old.cursor_pos, .cursor_color = old.cursor_color, + .bg_color = old.bg_color, .cursor_wide = old.cursor_wide, + .use_display_p3 = old.use_display_p3, + .use_linear_blending = old.use_linear_blending, + .use_experimental_linear_correction = old.use_experimental_linear_correction, }; // Reset our cell contents if our grid size has changed. @@ -2111,7 +2277,17 @@ pub fn setScreenSize( const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); break :init id_init; }; - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm)); + desc.setProperty( + "pixelFormat", + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.config.blending.isLinear()) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); desc.setProperty( @@ -2141,7 +2317,17 @@ pub fn setScreenSize( const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); break :init id_init; }; - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm)); + desc.setProperty( + "pixelFormat", + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.config.blending.isLinear()) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); desc.setProperty( @@ -2238,12 +2424,22 @@ fn rebuildCells( } } - // Go row-by-row to build the cells. We go row by row because we do - // font shaping by row. In the future, we will also do dirty tracking - // by row. + // We rebuild the cells row-by-row because we + // do font shaping and dirty tracking by row. var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); - var y: terminal.size.CellCountInt = screen.pages.rows; + // If our cell contents buffer is shorter than the screen viewport, + // we render the rows that fit, starting from the bottom. If instead + // the viewport is shorter than the cell contents buffer, we align + // the top of the viewport with the top of the contents buffer. + var y: terminal.size.CellCountInt = @min( + screen.pages.rows, + self.cells.size.rows, + ); while (row_it.next()) |row| { + // The viewport may have more rows than our cell contents, + // so we need to break from the loop early if we hit y = 0. + if (y == 0) break; + y -= 1; if (!rebuild) { @@ -2302,7 +2498,11 @@ fn rebuildCells( var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells = row.cells(.all); + const row_cells_all = row.cells(.all); + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; for (row_cells, 0..) |*cell, x| { // If this cell falls within our preedit range then we @@ -2453,8 +2653,10 @@ fn rebuildCells( // Foreground alpha for this cell. const alpha: u8 = if (style.flags.faint) 175 else 255; - // If the cell has a background color, set it. - if (bg) |rgb| { + // Set the cell's background color. + { + const rgb = bg orelse self.background_color orelse self.default_background_color; + // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all // in an attempt to make transparency look the best for various @@ -2464,23 +2666,19 @@ fn rebuildCells( if (self.config.background_opacity >= 1) break :bg_alpha default; - // If we're selected, we do not apply background opacity + // Cells that are selected should be fully opaque. if (selected) break :bg_alpha default; - // If we're reversed, do not apply background opacity + // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; - // If we have a background and its not the default background - // then we apply background opacity - if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) { + // Cells that have an explicit bg color should be fully opaque. + if (bg_style != null) { break :bg_alpha default; } - // We apply background opacity. - var bg_alpha: f64 = @floatFromInt(default); - bg_alpha *= self.config.background_opacity; - bg_alpha = @ceil(bg_alpha); - break :bg_alpha @intFromFloat(bg_alpha); + // Otherwise, we use the configured background opacity. + break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0)); }; self.cells.bgCell(y, x).* = .{ @@ -2637,8 +2835,13 @@ fn rebuildCells( const style = cursor_style_ orelse break :cursor; const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { if (self.cursor_invert) { + // Use the foreground color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :color sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; + break :color if (sty.flags.inverse) + // If the cell is reversed, use background color instead. + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) + else + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); } else { break :color self.foreground_color orelse self.default_foreground_color; } @@ -2667,8 +2870,13 @@ fn rebuildCells( }; const uniform_color = if (self.cursor_invert) blk: { + // Use the background color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :blk sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color; + break :blk if (sty.flags.inverse) + // If the cell is reversed, use foreground color instead. + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) + else + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); } else if (self.config.cursor_text) |txt| txt else @@ -2837,6 +3045,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 5953d50a48..3e674c7155 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -49,7 +49,7 @@ alloc: std.mem.Allocator, config: DerivedConfig, /// Current font metrics defining our grid. -grid_metrics: font.face.Metrics, +grid_metrics: font.Metrics, /// The size of everything. size: renderer.Size, @@ -231,7 +231,7 @@ const SetScreenSize = struct { }; const SetFontSize = struct { - metrics: font.face.Metrics, + metrics: font.Metrics, fn apply(self: SetFontSize, r: *const OpenGL) !void { const gl_state = r.gl_state orelse return error.OpenGLUninitialized; @@ -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, @@ -704,8 +706,6 @@ pub fn updateFrame( // Update all our data as tightly as possible within the mutex. var critical: Critical = critical: { - const grid_size = self.size.grid(); - state.mutex.lock(); defer state.mutex.unlock(); @@ -746,19 +746,6 @@ pub fn updateFrame( } } - // If our terminal screen size doesn't match our expected renderer - // size then we skip a frame. This can happen if the terminal state - // is resized between when the renderer mailbox is drained and when - // the state mutex is acquired inside this function. - // - // For some reason this doesn't seem to cause any significant issues - // with flickering while resizing. '\_('-')_/' - if (grid_size.rows != state.terminal.rows or - grid_size.columns != state.terminal.cols) - { - return; - } - // Get the viewport pin so that we can compare it to the current. const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; @@ -1274,10 +1261,23 @@ pub fn rebuildCells( } } - // Build each cell + const grid_size = self.size.grid(); + + // We rebuild the cells row-by-row because we do font shaping by row. var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); - var y: terminal.size.CellCountInt = screen.pages.rows; + // If our cell contents buffer is shorter than the screen viewport, + // we render the rows that fit, starting from the bottom. If instead + // the viewport is shorter than the cell contents buffer, we align + // the top of the viewport with the top of the contents buffer. + var y: terminal.size.CellCountInt = @min( + screen.pages.rows, + grid_size.rows, + ); while (row_it.next()) |row| { + // The viewport may have more rows than our cell contents, + // so we need to break from the loop early if we hit y = 0. + if (y == 0) break; + y -= 1; // True if we want to do font shaping around the cursor. We want to @@ -1354,7 +1354,11 @@ pub fn rebuildCells( var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells = row.cells(.all); + const row_cells_all = row.cells(.all); + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const row_cells = row_cells_all[0..@min(row_cells_all.len, grid_size.columns)]; for (row_cells, 0..) |*cell, x| { // If this cell falls within our preedit range then we @@ -1735,8 +1739,13 @@ pub fn rebuildCells( const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { if (self.cursor_invert) { + // Use the foreground color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :color sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; + break :color if (sty.flags.inverse) + // If the cell is reversed, use background color instead. + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) + else + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); } else { break :color self.foreground_color orelse self.default_foreground_color; } @@ -1746,8 +1755,13 @@ pub fn rebuildCells( for (cursor_cells.items) |*cell| { if (cell.mode.isFg() and cell.mode != .fg_color) { const cell_color = if (self.cursor_invert) blk: { + // Use the background color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :blk sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color; + break :blk if (sty.flags.inverse) + // If the cell is reversed, use foreground color instead. + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) + else + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); } else if (self.config.cursor_text) |txt| txt else @@ -2093,6 +2107,7 @@ fn addGlyph( .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, }, ); @@ -2337,11 +2352,9 @@ pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void { } /// Draw the custom shaders. -fn drawCustomPrograms( - self: *OpenGL, - custom_state: *custom.State, -) !void { +fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void { _ = self; + assert(custom_state.programs.len > 0); // Bind our state that is global to all custom shaders const custom_bind = try custom_state.bind(); @@ -2352,10 +2365,10 @@ fn drawCustomPrograms( // Go through each custom shader and draw it. for (custom_state.programs) |program| { - // Bind our cell program state, buffers const bind = try program.bind(); defer bind.unbind(); try bind.draw(); + try custom_state.copyFramebuffer(); } } diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index bd4f407cdb..6ab42bbd68 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -74,6 +74,7 @@ pub const MTLPixelFormat = enum(c_ulong) { rgba8unorm = 70, rgba8uint = 73, bgra8unorm = 80, + bgra8unorm_srgb = 81, }; /// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc @@ -175,4 +176,8 @@ pub const MTLSize = extern struct { depth: c_ulong, }; +/// https://developer.apple.com/documentation/metal/1433367-mtlcopyalldevices pub extern "c" fn MTLCopyAllDevices() ?*anyopaque; + +/// https://developer.apple.com/documentation/metal/1433401-mtlcreatesystemdefaultdevice +pub extern "c" fn MTLCreateSystemDefaultDevice() ?*anyopaque; diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index b909a2f2a9..62d3631735 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -13,9 +13,7 @@ const log = std.log.scoped(.metal); pub const Shaders = struct { library: objc.Object, - /// The cell shader is the shader used to render the terminal cells. - /// It is a single shader that is used for both the background and - /// foreground. + /// Renders cell foreground elements (text, decorations). cell_text_pipeline: objc.Object, /// The cell background shader is the shader used to render the @@ -40,17 +38,18 @@ pub const Shaders = struct { alloc: Allocator, device: objc.Object, post_shaders: []const [:0]const u8, + pixel_format: mtl.MTLPixelFormat, ) !Shaders { const library = try initLibrary(device); errdefer library.msgSend(void, objc.sel("release"), .{}); - const cell_text_pipeline = try initCellTextPipeline(device, library); + const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format); errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{}); - const cell_bg_pipeline = try initCellBgPipeline(device, library); + const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format); errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{}); - const image_pipeline = try initImagePipeline(device, library); + const image_pipeline = try initImagePipeline(device, library, pixel_format); errdefer image_pipeline.msgSend(void, objc.sel("release"), .{}); const post_pipelines: []const objc.Object = initPostPipelines( @@ -58,6 +57,7 @@ pub const Shaders = struct { device, library, post_shaders, + pixel_format, ) catch |err| err: { // If an error happens while building postprocess shaders we // want to just not use any postprocess shaders since we don't @@ -137,9 +137,29 @@ pub const Uniforms = extern struct { cursor_pos: [2]u16 align(4), cursor_color: [4]u8 align(4), - // Whether the cursor is 2 cells wide. + /// The background color for the whole surface. + bg_color: [4]u8 align(4), + + /// Whether the cursor is 2 cells wide. cursor_wide: bool align(1), + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + use_display_p3: bool align(1), + + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// Metal itself will re-encode the colors for storage. + use_linear_blending: bool align(1), + + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + use_experimental_linear_correction: bool align(1) = false, + const PaddingExtend = packed struct(u8) { left: bool = false, right: bool = false, @@ -201,6 +221,7 @@ fn initPostPipelines( device: objc.Object, library: objc.Object, shaders: []const [:0]const u8, + pixel_format: mtl.MTLPixelFormat, ) ![]const objc.Object { // If we have no shaders, do nothing. if (shaders.len == 0) return &.{}; @@ -220,7 +241,12 @@ fn initPostPipelines( // Build each shader. Note we don't use "0.." to build our index // because we need to keep track of our length to clean up above. for (shaders) |source| { - pipelines[i] = try initPostPipeline(device, library, source); + pipelines[i] = try initPostPipeline( + device, + library, + source, + pixel_format, + ); i += 1; } @@ -232,6 +258,7 @@ fn initPostPipeline( device: objc.Object, library: objc.Object, data: [:0]const u8, + pixel_format: mtl.MTLPixelFormat, ) !objc.Object { // Create our library which has the shader source const post_library = library: { @@ -301,8 +328,7 @@ fn initPostPipeline( .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); } // Make our state @@ -343,7 +369,11 @@ pub const CellText = extern struct { }; /// Initialize the cell render pipeline for our shader library. -fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object { +fn initCellTextPipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { // Get our vertex and fragment functions const func_vert = func_vert: { const str = try macos.foundation.String.createWithBytes( @@ -427,8 +457,7 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); // Blending. This is required so that our text we render on top // of our drawable properly blends into the bg. @@ -458,11 +487,15 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object pub const CellBg = [4]u8; /// Initialize the cell background render pipeline for our shader library. -fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { +fn initCellBgPipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { // Get our vertex and fragment functions const func_vert = func_vert: { const str = try macos.foundation.String.createWithBytes( - "full_screen_vertex", + "cell_bg_vertex", .utf8, false, ); @@ -507,8 +540,7 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); // Blending. This is required so that our text we render on top // of our drawable properly blends into the bg. @@ -535,7 +567,11 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { } /// Initialize the image render pipeline for our shader library. -fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object { +fn initImagePipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { // Get our vertex and fragment functions const func_vert = func_vert: { const str = try macos.foundation.String.createWithBytes( @@ -619,8 +655,7 @@ fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object { .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); // Blending. This is required so that our text we render on top // of our drawable properly blends into the bg. diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig index 2cab0940c5..859277ce51 100644 --- a/src/renderer/opengl/custom.zig +++ b/src/renderer/opengl/custom.zig @@ -230,6 +230,21 @@ pub const State = struct { }; } + /// Copy the fbo's attached texture to the backbuffer. + pub fn copyFramebuffer(self: *State) !void { + const texbind = try self.fb_texture.bind(.@"2D"); + errdefer texbind.unbind(); + try texbind.copySubImage2D( + 0, + 0, + 0, + 0, + 0, + @intFromFloat(self.uniforms.resolution[0]), + @intFromFloat(self.uniforms.resolution[1]), + ); + } + pub const Binding = struct { vao: gl.VertexArray.Binding, ebo: gl.Buffer.Binding, @@ -251,7 +266,6 @@ pub const Program = struct { const program = try gl.Program.createVF( @embedFile("../shaders/custom.v.glsl"), src, - //@embedFile("../shaders/temp.f.glsl"), ); errdefer program.destroy(); diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 2a107402b2..17f811a197 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -18,7 +18,11 @@ struct Uniforms { float min_contrast; ushort2 cursor_pos; uchar4 cursor_color; + uchar4 bg_color; bool cursor_wide; + bool use_display_p3; + bool use_linear_blending; + bool use_experimental_linear_correction; }; //------------------------------------------------------------------- @@ -26,40 +30,82 @@ struct Uniforms { //------------------------------------------------------------------- #pragma mark - Colors -// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef -float luminance_component(float c) { - if (c <= 0.03928f) { - return c / 12.92f; - } else { - return pow((c + 0.055f) / 1.055f, 2.4f); - } +// D50-adapted sRGB to XYZ conversion matrix. +// http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html +constant float3x3 sRGB_XYZ = transpose(float3x3( + 0.4360747, 0.3850649, 0.1430804, + 0.2225045, 0.7168786, 0.0606169, + 0.0139322, 0.0971045, 0.7141733 +)); +// XYZ to Display P3 conversion matrix. +// http://endavid.com/index.php?entry=79 +constant float3x3 XYZ_DP3 = transpose(float3x3( + 2.40414768,-0.99010704,-0.39759019, + -0.84239098, 1.79905954, 0.01597023, + 0.04838763,-0.09752546, 1.27393636 +)); +// By composing the two above matrices we get +// our sRGB to Display P3 conversion matrix. +constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ; + +// Converts a color in linear sRGB to linear Display P3 +// +// TODO: The color matrix should probably be computed +// dynamically and passed as a uniform, rather +// than being hard coded above. +float3 srgb_to_display_p3(float3 srgb) { + return sRGB_DP3 * srgb; +} + +// Converts a color from sRGB gamma encoding to linear. +float4 linearize(float4 srgb) { + bool3 cutoff = srgb.rgb <= 0.04045; + float3 lower = srgb.rgb / 12.92; + float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4); + srgb.rgb = mix(higher, lower, float3(cutoff)); + + return srgb; +} + +// Converts a color from linear to sRGB gamma encoding. +float4 unlinearize(float4 linear) { + bool3 cutoff = linear.rgb <= 0.0031308; + float3 lower = linear.rgb * 12.92; + float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055; + linear.rgb = mix(higher, lower, float3(cutoff)); + + return linear; } -float relative_luminance(float3 color) { - color.r = luminance_component(color.r); - color.g = luminance_component(color.g); - color.b = luminance_component(color.b); - float3 weights = float3(0.2126f, 0.7152f, 0.0722f); - return dot(color, weights); +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(float3 color) { + return dot(color, float3(0.2126f, 0.7152f, 0.0722f)); } // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. float contrast_ratio(float3 color1, float3 color2) { - float l1 = relative_luminance(color1); - float l2 = relative_luminance(color2); + float l1 = luminance(color1); + float l2 = luminance(color2); return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f); } // Return the fg if the contrast ratio is greater than min, otherwise // return a color that satisfies the contrast ratio. Currently, the color // is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. float4 contrasted_color(float min, float4 fg, float4 bg) { - float3 fg_premult = fg.rgb * fg.a; - float3 bg_premult = bg.rgb * bg.a; - float ratio = contrast_ratio(fg_premult, bg_premult); + float ratio = contrast_ratio(fg.rgb, bg.rgb); if (ratio < min) { - float white_ratio = contrast_ratio(float3(1.0f), bg_premult); - float black_ratio = contrast_ratio(float3(0.0f), bg_premult); + float white_ratio = contrast_ratio(float3(1.0f), bg.rgb); + float black_ratio = contrast_ratio(float3(0.0f), bg.rgb); if (white_ratio > black_ratio) { return float4(1.0f); } else { @@ -70,6 +116,62 @@ float4 contrasted_color(float min, float4 fg, float4 bg) { return fg; } +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// Returns a color in the Display P3 color space. +// +// If `display_p3` is true, then the provided color is assumed to +// already be in the Display P3 color space, otherwise it's treated +// as an sRGB color and is appropriately converted to Display P3. +// +// `linear` controls whether the returned color is linear or gamma encoded. +float4 load_color( + uchar4 in_color, + bool display_p3, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + float4 color = float4(in_color) / 255.0f; + + // If our color is already in Display P3 and + // we aren't doing linear blending, then we + // already have the correct color here and + // can premultiply and return it. + if (display_p3 && !linear) { + color.rgb *= color.a; + return color; + } + + // The color is in either the sRGB or Display P3 color space, + // so in either case, it's a color space which uses the sRGB + // transfer function, so we can use one function in order to + // linearize it in either case. + // + // Even if we aren't doing linear blending, the color + // needs to be in linear space to convert color spaces. + color = linearize(color); + + // If we're *NOT* using display P3 colors, then we're dealing + // with an sRGB color, in which case we need to convert it in + // to the Display P3 color space, since our output is always + // Display P3. + if (!display_p3) { + color.rgb = srgb_to_display_p3(color.rgb); + } + + // If we're not doing linear blending, then we need to + // unlinearize after doing the color space conversion. + if (!linear) { + color = unlinearize(color); + } + + // Premultiply our color by its alpha. + color.rgb *= color.a; + + return color; +} + //------------------------------------------------------------------- // Full Screen Vertex Shader //------------------------------------------------------------------- @@ -112,25 +214,62 @@ vertex FullScreenVertexOut full_screen_vertex( //------------------------------------------------------------------- #pragma mark - Cell BG Shader +struct CellBgVertexOut { + float4 position [[position]]; + float4 bg_color; +}; + +vertex CellBgVertexOut cell_bg_vertex( + uint vid [[vertex_id]], + constant Uniforms& uniforms [[buffer(1)]] +) { + CellBgVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + out.position = position; + + // Convert the background color to Display P3 + out.bg_color = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); + + return out; +} + fragment float4 cell_bg_fragment( - FullScreenVertexOut in [[stage_in]], + CellBgVertexOut in [[stage_in]], constant uchar4 *cells [[buffer(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); + float4 bg = float4(0.0); + // If we have any background transparency then we render bg-colored cells as + // fully transparent, since the background is handled by the layer bg color + // and we don't want to double up our bg color, but if our bg color is fully + // opaque then our layer is opaque and can't handle transparency, so we need + // to return the bg color directly instead. + if (uniforms.bg_color.a == 255) { + bg = in.bg_color; + } + // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { if (uniforms.padding_extend & EXTEND_LEFT) { grid_pos.x = 0; } else { - return float4(0.0); + return bg; } } else if (grid_pos.x > uniforms.grid_size.x - 1) { if (uniforms.padding_extend & EXTEND_RIGHT) { grid_pos.x = uniforms.grid_size.x - 1; } else { - return float4(0.0); + return bg; } } @@ -139,18 +278,40 @@ fragment float4 cell_bg_fragment( if (uniforms.padding_extend & EXTEND_UP) { grid_pos.y = 0; } else { - return float4(0.0); + return bg; } } else if (grid_pos.y > uniforms.grid_size.y - 1) { if (uniforms.padding_extend & EXTEND_DOWN) { grid_pos.y = uniforms.grid_size.y - 1; } else { - return float4(0.0); + return bg; } } - // Retrieve color for cell and return it. - return float4(cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]) / 255.0; + // Load the color for the cell. + uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; + + // We have special case handling for when the cell color matches the bg color. + if (all(cell_color == uniforms.bg_color)) { + return bg; + } + + // Convert the color and return it. + // + // TODO: We may want to blend the color with the background + // color, rather than purely replacing it, this needs + // some consideration about config options though. + // + // TODO: It might be a good idea to do a pass before this + // to convert all of the bg colors, so we don't waste + // a bunch of work converting the cell color in every + // fragment of each cell. It's not the most epxensive + // operation, but it is still wasted work. + return load_color( + cell_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); } //------------------------------------------------------------------- @@ -222,7 +383,6 @@ vertex CellTextVertexOut cell_text_vertex( CellTextVertexOut out; out.mode = in.mode; - out.color = float4(in.color) / 255.0f; // === Grid Cell === // +X @@ -277,6 +437,14 @@ vertex CellTextVertexOut cell_text_vertex( // be sampled with pixel coordinate mode. out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner; + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out.color = load_color( + in.color, + uniforms.use_display_p3, + true + ); + // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast // with the background. @@ -285,7 +453,13 @@ vertex CellTextVertexOut cell_text_vertex( // and Powerline glyphs to be unaffected (else parts of the line would // have different colors as some parts are displayed via background colors). if (uniforms.min_contrast > 1.0f && in.mode == MODE_TEXT) { - float4 bg_color = float4(bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x]) / 255.0f; + // Get the BG color + float4 bg_color = load_color( + bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x], + uniforms.use_display_p3, + true + ); + // Ensure our minimum contrast out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color); } @@ -308,7 +482,8 @@ vertex CellTextVertexOut cell_text_vertex( fragment float4 cell_text_fragment( CellTextVertexOut in [[stage_in]], texture2d textureGrayscale [[texture(0)]], - texture2d textureColor [[texture(1)]] + texture2d textureColor [[texture(1)]], + constant Uniforms& uniforms [[buffer(2)]] ) { constexpr sampler textureSampler( coord::pixel, @@ -322,20 +497,63 @@ fragment float4 cell_text_fragment( case MODE_TEXT_CONSTRAINED: case MODE_TEXT_POWERLINE: case MODE_TEXT: { - // We premult the alpha to our whole color since our blend function - // uses One/OneMinusSourceAlpha to avoid blurry edges. - // We first premult our given color. - float4 premult = float4(in.color.rgb * in.color.a, in.color.a); - - // Then premult the texture color + // Our input color is always linear. + float4 color = in.color; + + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. + if (!uniforms.use_linear_blending) { + color.rgb /= color.a; + color = unlinearize(color); + color.rgb *= color.a; + } + + // Fetch our alpha mask for this pixel. float a = textureGrayscale.sample(textureSampler, in.tex_coord).r; - premult = premult * a; - return premult; + // Experimental linear blending weight correction. + if (uniforms.use_experimental_linear_correction) { + float l = luminance(color.rgb); + + // TODO: This is a dynamic dilation term that biases + // the alpha adjustment for small font sizes; + // it should be computed by dividing the font + // size in `pt`s by `13.0` and using that if + // it's less than `1.0`, but for now it's + // hard coded at 1.0, which has no effect. + float d = 13.0 / 13.0; + + a += pow(a, d + d * l) - pow(a, d + 1.0 - d * l); + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + return color; } case MODE_TEXT_COLOR: { - return textureColor.sample(textureSampler, in.tex_coord); + // For now, we assume that color glyphs are + // already premultiplied Display P3 colors. + float4 color = textureColor.sample(textureSampler, in.tex_coord); + + // If we aren't doing linear blending, we can return this right away. + if (!uniforms.use_linear_blending) { + return color; + } + + // Otherwise we need to linearize the color. Since the alpha is + // premultiplied, we need to divide it out before linearizing. + color.rgb /= color.a; + color = linearize(color); + color.rgb *= color.a; + + return color; } } } @@ -409,7 +627,8 @@ vertex ImageVertexOut image_vertex( fragment float4 image_fragment( ImageVertexOut in [[stage_in]], - texture2d image [[texture(0)]] + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] ) { constexpr sampler textureSampler(address::clamp_to_edge, filter::linear); @@ -418,10 +637,12 @@ fragment float4 image_fragment( // our texture to BGRA8Unorm. uint4 rgba = image.sample(textureSampler, in.tex_coord); - // Convert to float4 and premultiply the alpha. We should also probably - // premultiply the alpha in the texture. - float4 result = float4(rgba) / 255.0f; - result.rgb *= result.a; - return result; + return load_color( + uchar4(rgba), + // We assume all images are sRGB regardless of the configured colorspace + // TODO: Maybe support wide gamut images? + false, + uniforms.use_linear_blending + ); } diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 976cf49240..3d5159c711 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -6,7 +6,7 @@ supports. This README is meant as developer documentation and not as user documentation. For user documentation, see the main -README. +README or [ghostty.org](https://ghostty.org/docs) ## Implementation Details diff --git a/src/shell-integration/bash/bash-preexec.sh b/src/shell-integration/bash/bash-preexec.sh index 14a677888d..e07da0d1e5 100644 --- a/src/shell-integration/bash/bash-preexec.sh +++ b/src/shell-integration/bash/bash-preexec.sh @@ -250,10 +250,8 @@ __bp_preexec_invoke_exec() { fi local this_command - this_command=$( - export LC_ALL=C - HISTTIMEFORMAT='' builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //' - ) + this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + this_command="${this_command#*[[:digit:]][* ] }" # Sanity check to make sure we have something to invoke our function with. if [[ -z "$this_command" ]]; then @@ -297,10 +295,8 @@ __bp_install() { trap '__bp_preexec_invoke_exec "$_"' DEBUG # Preserve any prior DEBUG trap as a preexec function - local prior_trap - # we can't easily do this with variable expansion. Leaving as sed command. - # shellcheck disable=SC2001 - prior_trap=$(sed "s/[^']*'\(.*\)'[^']*/\1/" <<<"${__bp_trap_string:-}") + eval "local trap_argv=(${__bp_trap_string:-})" + local prior_trap=${trap_argv[2]:-} unset __bp_trap_string if [[ -n "$prior_trap" ]]; then eval '__bp_original_debug_trap() { diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 72ae455df8..7fae435a3a 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -1,71 +1,72 @@ -# This is originally based on the recommended bash integration from -# the semantic prompts proposal as well as some logic from Kitty's -# bash integration. +# Parts of this script are based on Kitty's bash integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . # We need to be in interactive mode and we need to have the Ghostty # resources dir set which also tells us we're running in Ghostty. if [[ "$-" != *i* ]] ; then builtin return; fi if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi -# When automatic shell integration is active, we need to manually -# load the normal bash startup files based on the injected state. +# When automatic shell integration is active, we were started in POSIX +# mode and need to manually recreate the bash startup sequence. if [ -n "$GHOSTTY_BASH_INJECT" ]; then - builtin declare ghostty_bash_inject="$GHOSTTY_BASH_INJECT" - builtin unset GHOSTTY_BASH_INJECT ENV - - # At this point, we're in POSIX mode and rely on the injected - # flags to guide is through the rest of the startup sequence. - - # POSIX mode was requested by the user so there's nothing - # more to do that optionally source their original $ENV. - # No other startup files are read, per the standard. - if [[ "$ghostty_bash_inject" == *"--posix"* ]]; then - if [ -n "$GHOSTTY_BASH_ENV" ]; then - builtin source "$GHOSTTY_BASH_ENV" - builtin export ENV="$GHOSTTY_BASH_ENV" + # Store a temporary copy of our startup flags and unset these global + # environment variables so we can safely handle reentrancy. + builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT" + builtin unset ENV GHOSTTY_BASH_INJECT + + # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', + # which doesn't happen as part of the 'posix' reset. + builtin set +o posix + builtin shopt -u inherit_errexit 2>/dev/null + + # Unexport HISTFILE if it was set by the shell integration code. + if [[ -n "$GHOSTTY_BASH_UNEXPORT_HISTFILE" ]]; then + builtin export -n HISTFILE + builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE + fi + + # Manually source the startup files. See INVOCATION in bash(1) and + # run_startup_files() in shell.c in the Bash source code. + if builtin shopt -q login_shell; then + if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then + [ -r /etc/profile ] && builtin source "/etc/profile" + for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + done fi else - # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', - # which doesn't happen as part of the 'posix' reset. - builtin set +o posix - builtin shopt -u inherit_errexit 2>/dev/null - - # Unexport HISTFILE if it was set by the shell integration code. - if [[ -n "$GHOSTTY_BASH_UNEXPORT_HISTFILE" ]]; then - builtin export -n HISTFILE - builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE - fi - - # Manually source the startup files, respecting the injected flags like - # --norc and --noprofile that we parsed with the shell integration code. - # - # See also: run_startup_files() in shell.c in the Bash source code - if builtin shopt -q login_shell; then - if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then - [ -r /etc/profile ] && builtin source "/etc/profile" - for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - fi - else - if [[ $ghostty_bash_inject != *"--norc"* ]]; then - # The location of the system bashrc is determined at bash build - # time via -DSYS_BASHRC and can therefore vary across distros: - # Arch, Debian, Ubuntu use /etc/bash.bashrc - # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC - # Void Linux uses /etc/bash/bashrc - # Nixos uses /etc/bashrc - for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi - [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" - fi + if [[ $__ghostty_bash_flags != *"--norc"* ]]; then + # The location of the system bashrc is determined at bash build + # time via -DSYS_BASHRC and can therefore vary across distros: + # Arch, Debian, Ubuntu use /etc/bash.bashrc + # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC + # Void Linux uses /etc/bash/bashrc + # Nixos uses /etc/bashrc + for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do + [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + done + if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi + [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" fi fi - builtin unset GHOSTTY_BASH_ENV GHOSTTY_BASH_RCFILE - builtin unset ghostty_bash_inject rcfile + builtin unset __ghostty_rcfile + builtin unset __ghostty_bash_flags + builtin unset GHOSTTY_BASH_RCFILE fi # Sudo @@ -104,15 +105,6 @@ builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" _ghostty_executing="" _ghostty_last_reported_cwd="" -function __ghostty_get_current_command() { - builtin local last_cmd - # shellcheck disable=SC1007 - last_cmd=$(HISTTIMEFORMAT= builtin history 1) - last_cmd="${last_cmd#*[[:digit:]]*[[:space:]]}" # remove leading history number - last_cmd="${last_cmd#"${last_cmd%%[![:space:]]*}"}" # remove remaining leading whitespace - builtin printf "\e]2;%s\a" "${last_cmd//[[:cntrl:]]}" # remove any control characters -} - function __ghostty_precmd() { local ret="$?" if test "$_ghostty_executing" != "0"; then @@ -137,11 +129,9 @@ function __ghostty_precmd() { PS0=$PS0'\[\e[0 q\]' fi + # Title (working directory) if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then - # Command and working directory - # shellcheck disable=SC2016 - PS0=$PS0'$(__ghostty_get_current_command)' - PS1=$PS1'\[\e]2;$PWD\a\]' + PS1=$PS1'\[\e]2;\w\a\]' fi fi @@ -164,9 +154,18 @@ function __ghostty_precmd() { } function __ghostty_preexec() { + builtin local cmd="$1" + PS0="$_GHOSTTY_SAVE_PS0" PS1="$_GHOSTTY_SAVE_PS1" PS2="$_GHOSTTY_SAVE_PS2" + + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" + fi + + # End of input, start of output. builtin printf "\e]133;C;\a" _ghostty_executing=1 } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 420a495286..cd4f56105b 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -71,11 +71,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x") # Wrap `sudo` command to ensure Ghostty terminfo is preserved function sudo -d "Wrap sudo to preserve terminfo" - set --local sudo_has_sudoedit_flags "no" + set --function sudo_has_sudoedit_flags "no" for arg in $argv # Check if argument is '-e' or '--edit' (sudoedit flags) if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg" - set --local sudo_has_sudoedit_flags "yes" + set --function sudo_has_sudoedit_flags "yes" break end # Check if argument is neither an option nor a key-value pair diff --git a/src/stb/stb_image.h b/src/stb/stb_image.h index 5e807a0a6e..3ae1815c16 100644 --- a/src/stb/stb_image.h +++ b/src/stb/stb_image.h @@ -4962,7 +4962,7 @@ static int stbi__expand_png_palette(stbi__png *a, stbi_uc *palette, int len, int p = (stbi_uc *) stbi__malloc_mad2(pixel_count, pal_img_n, 0); if (p == NULL) return stbi__err("outofmem", "Out of memory"); - // between here and free(out) below, exitting would leak + // between here and free(out) below, exiting would leak temp_out = p; if (pal_img_n == 3) { diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5fb49ea66d..b838332b0a 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -520,6 +520,7 @@ pub fn clone( assert(node.data.capacity.rows >= chunk.end - chunk.start); defer node.data.assertIntegrity(); node.data.size.rows = chunk.end - chunk.start; + node.data.size.cols = chunk.node.data.size.cols; try node.data.cloneFrom( &chunk.node.data, chunk.start, @@ -3281,7 +3282,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 +3390,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 +3487,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 +3509,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/Parser.zig b/src/terminal/Parser.zig index 9aebdbd3a5..a779c3350e 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -6,6 +6,7 @@ const Parser = @This(); const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const testing = std.testing; const table = @import("parse_table.zig").table; const osc = @import("osc.zig"); @@ -81,11 +82,15 @@ pub const Action = union(enum) { pub const CSI = struct { intermediates: []u8, params: []u16, + params_sep: SepList, final: u8, - sep: Sep, + + /// The list of separators used for CSI params. The value of the + /// bit can be mapped to Sep. + pub const SepList = std.StaticBitSet(MAX_PARAMS); /// The separator used for CSI params. - pub const Sep = enum { semicolon, colon }; + pub const Sep = enum(u1) { semicolon = 0, colon = 1 }; // Implement formatter for logging pub fn format( @@ -183,15 +188,6 @@ pub const Action = union(enum) { } }; -/// Keeps track of the parameter sep used for CSI params. We allow colons -/// to be used ONLY by the 'm' CSI action. -pub const ParamSepState = enum(u8) { - none = 0, - semicolon = ';', - colon = ':', - mixed = 1, -}; - /// Maximum number of intermediate characters during parsing. This is /// 4 because we also use the intermediates array for UTF8 decoding which /// can be at most 4 bytes. @@ -207,8 +203,8 @@ intermediates_idx: u8 = 0, /// Param tracking, building params: [MAX_PARAMS]u16 = undefined, +params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(), params_idx: u8 = 0, -params_sep: ParamSepState = .none, param_acc: u16 = 0, param_acc_idx: u8 = 0, @@ -312,13 +308,9 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // Ignore too many parameters if (self.params_idx >= MAX_PARAMS) break :param null; - // If this is our first time seeing a parameter, we track - // the separator used so that we can't mix separators later. - if (self.params_idx == 0) self.params_sep = @enumFromInt(c); - if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed; - // Set param final value self.params[self.params_idx] = self.param_acc; + if (c == ':') self.params_sep.set(self.params_idx); self.params_idx += 1; // Reset current param value to 0 @@ -359,29 +351,18 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { .csi_dispatch = .{ .intermediates = self.intermediates[0..self.intermediates_idx], .params = self.params[0..self.params_idx], + .params_sep = self.params_sep, .final = c, - .sep = switch (self.params_sep) { - .none, .semicolon => .semicolon, - .colon => .colon, - - // There is nothing that treats mixed separators specially - // afaik so we just treat it as a semicolon. - .mixed => .semicolon, - }, }, }; // We only allow colon or mixed separators for the 'm' command. - switch (self.params_sep) { - .none => {}, - .semicolon => {}, - .colon, .mixed => if (c != 'm') { - log.warn( - "CSI colon or mixed separators only allowed for 'm' command, got: {}", - .{result}, - ); - break :csi_dispatch null; - }, + if (c != 'm' and self.params_sep.count() > 0) { + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {}", + .{result}, + ); + break :csi_dispatch null; } break :csi_dispatch result; @@ -400,7 +381,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { pub fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; - self.params_sep = .none; + self.params_sep = Action.CSI.SepList.initEmpty(); self.param_acc = 0; self.param_acc_idx = 0; } @@ -507,10 +488,11 @@ test "csi: SGR ESC [ 38 : 2 m" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 2); try testing.expectEqual(@as(u16, 38), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expect(!d.params_sep.isSet(1)); } } @@ -581,13 +563,17 @@ test "csi: SGR ESC [ 48 : 2 m" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 5); try testing.expectEqual(@as(u16, 48), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expect(d.params_sep.isSet(1)); try testing.expectEqual(@as(u16, 240), d.params[2]); + try testing.expect(d.params_sep.isSet(2)); try testing.expectEqual(@as(u16, 143), d.params[3]); + try testing.expect(d.params_sep.isSet(3)); try testing.expectEqual(@as(u16, 104), d.params[4]); + try testing.expect(!d.params_sep.isSet(4)); } } @@ -608,10 +594,11 @@ test "csi: SGR ESC [4:3m colon" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 2); try testing.expectEqual(@as(u16, 4), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 3), d.params[1]); + try testing.expect(!d.params_sep.isSet(1)); } } @@ -634,14 +621,71 @@ test "csi: SGR with many blank and colon" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 6); try testing.expectEqual(@as(u16, 58), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expect(d.params_sep.isSet(1)); try testing.expectEqual(@as(u16, 0), d.params[2]); + try testing.expect(d.params_sep.isSet(2)); try testing.expectEqual(@as(u16, 240), d.params[3]); + try testing.expect(d.params_sep.isSet(3)); try testing.expectEqual(@as(u16, 143), d.params[4]); + try testing.expect(d.params_sep.isSet(4)); try testing.expectEqual(@as(u16, 104), d.params[5]); + try testing.expect(!d.params_sep.isSet(5)); + } +} + +// This is from a Kakoune actual SGR sequence. +test "csi: SGR mixed colon and semicolon with blank" { + var p = init(); + _ = p.next(0x1B); + for ("[;4:3;38;2;175;175;215;58:2::190:80:70") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expectEqual(14, d.params.len); + try testing.expectEqual(@as(u16, 0), d.params[0]); + try testing.expect(!d.params_sep.isSet(0)); + try testing.expectEqual(@as(u16, 4), d.params[1]); + try testing.expect(d.params_sep.isSet(1)); + try testing.expectEqual(@as(u16, 3), d.params[2]); + try testing.expect(!d.params_sep.isSet(2)); + try testing.expectEqual(@as(u16, 38), d.params[3]); + try testing.expect(!d.params_sep.isSet(3)); + try testing.expectEqual(@as(u16, 2), d.params[4]); + try testing.expect(!d.params_sep.isSet(4)); + try testing.expectEqual(@as(u16, 175), d.params[5]); + try testing.expect(!d.params_sep.isSet(5)); + try testing.expectEqual(@as(u16, 175), d.params[6]); + try testing.expect(!d.params_sep.isSet(6)); + try testing.expectEqual(@as(u16, 215), d.params[7]); + try testing.expect(!d.params_sep.isSet(7)); + try testing.expectEqual(@as(u16, 58), d.params[8]); + try testing.expect(d.params_sep.isSet(8)); + try testing.expectEqual(@as(u16, 2), d.params[9]); + try testing.expect(d.params_sep.isSet(9)); + try testing.expectEqual(@as(u16, 0), d.params[10]); + try testing.expect(d.params_sep.isSet(10)); + try testing.expectEqual(@as(u16, 190), d.params[11]); + try testing.expect(d.params_sep.isSet(11)); + try testing.expectEqual(@as(u16, 80), d.params[12]); + try testing.expect(d.params_sep.isSet(12)); + try testing.expectEqual(@as(u16, 70), d.params[13]); + try testing.expect(!d.params_sep.isSet(13)); } } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 19d8212a05..10ba5b5e77 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -67,7 +67,7 @@ pub const Command = union(enum) { /// End of current command. /// - /// The exit-code need not be specified if if there are no options, + /// The exit-code need not be specified if there are no options, /// or if the command was cancelled (no OSC "133;C"), such as by typing /// an interrupt/cancel character (typically ctrl-C) during line-editing. /// Otherwise, it must be an integer code, where 0 means the command @@ -158,12 +158,29 @@ pub const Command = union(enum) { /// End a hyperlink (OSC 8) hyperlink_end: void, + /// Sleep (OSC 9;1) + sleep: struct { + 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, progress: ?u8 = null, }, + /// Wait input (OSC 9;5) + wait_input: void, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -260,6 +277,7 @@ pub const Parser = struct { pub const State = enum { empty, invalid, + swallow, // Command prefixes. We could just accumulate and compare (mem.eql) // but the state space is small enough that we just build it up this way. @@ -353,6 +371,11 @@ pub const Parser = struct { osc_9, // ConEmu specific substates + conemu_sleep, + conemu_sleep_value, + conemu_message_box, + conemu_tab, + conemu_tab_txt, conemu_progress_prestate, conemu_progress_state, conemu_progress_prevalue, @@ -432,6 +455,8 @@ pub const Parser = struct { else => self.state = .invalid, }, + .swallow => {}, + .@"0" => switch (c) { ';' => { self.command = .{ .change_window_title = undefined }; @@ -777,9 +802,23 @@ pub const Parser = struct { }, .osc_9 => switch (c) { + '1' => { + self.state = .conemu_sleep; + }, + '2' => { + self.state = .conemu_message_box; + }, + '3' => { + self.state = .conemu_tab; + }, '4' => { self.state = .conemu_progress_prestate; }, + '5' => { + self.state = .swallow; + self.command = .{ .wait_input = {} }; + self.complete = true; + }, // Todo: parse out other ConEmu operating system commands. // Even if we don't support them we probably don't want @@ -788,6 +827,48 @@ pub const Parser = struct { else => self.showDesktopNotification(), }, + .conemu_sleep => switch (c) { + ';' => { + self.command = .{ .sleep = .{ .duration_ms = 100 } }; + 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, + }, + + .conemu_sleep_value => switch (c) { + 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 = .{ @@ -801,7 +882,7 @@ pub const Parser = struct { .conemu_progress_state => switch (c) { '0' => { self.command.progress.state = .remove; - self.state = .conemu_progress_prevalue; + self.state = .swallow; self.complete = true; }, '1' => { @@ -817,7 +898,7 @@ pub const Parser = struct { '3' => { self.command.progress.state = .indeterminate; self.complete = true; - self.state = .conemu_progress_prevalue; + self.state = .swallow; }, '4' => { self.command.progress.state = .pause; @@ -864,7 +945,10 @@ pub const Parser = struct { } }, - else => self.showDesktopNotification(), + else => { + self.state = .swallow; + self.complete = true; + }, }, .query_fg_color => switch (c) { @@ -1147,6 +1231,22 @@ pub const Parser = struct { self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; } + fn endConEmuSleepValue(self: *Parser) void { + switch (self.command) { + .sleep => |*v| v.duration_ms = value: { + const str = self.buf[self.buf_start..self.buf_idx]; + if (str.len == 0) break :value 100; + + if (std.fmt.parseUnsigned(u16, str, 10)) |num| { + break :value @min(num, 10_000); + } else |_| { + break :value 100; + } + }, + else => {}, + } + } + fn endKittyColorProtocolOption(self: *Parser, kind: enum { key_only, key_and_value }, final: bool) void { if (self.temp_state.key.len == 0) { log.warn("zero length key in kitty color protocol", .{}); @@ -1225,6 +1325,7 @@ pub const Parser = struct { .semantic_option_value => self.endSemanticOptionValue(), .hyperlink_uri => self.endHyperlink(), .string => self.endString(), + .conemu_sleep_value => self.endConEmuSleepValue(), .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), @@ -1634,6 +1735,62 @@ test "OSC: set palette color" { try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); } +test "OSC: conemu sleep" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;420"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(420, cmd.sleep.duration_ms); +} + +test "OSC: conemu sleep with no value default to 100ms" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(100, cmd.sleep.duration_ms); +} + +test "OSC: conemu sleep cannot exceed 10000ms" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;12345"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(10000, cmd.sleep.duration_ms); +} + +test "OSC: conemu sleep invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(100, cmd.sleep.duration_ms); +} + test "OSC: show desktop notification" { const testing = std.testing; @@ -1662,6 +1819,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; @@ -1718,18 +1979,18 @@ test "OSC: OSC9 progress set double digit" { try testing.expect(cmd.progress.progress == 94); } -test "OSC: OSC9 progress set extra semicolon triggers desktop notification" { +test "OSC: OSC9 progress set extra semicolon ignored" { const testing = std.testing; var p: Parser = .{}; - const input = "9;4;1;100;"; + const input = "9;4;1;100"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;1;100;"); + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .set); + try testing.expect(cmd.progress.progress == 100); } test "OSC: OSC9 progress remove with no progress" { @@ -1746,6 +2007,20 @@ test "OSC: OSC9 progress remove with no progress" { try testing.expect(cmd.progress.progress == null); } +test "OSC: OSC9 progress remove with double semicolon" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;0;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .remove); + try testing.expect(cmd.progress.progress == null); +} + test "OSC: OSC9 progress remove ignores progress" { const testing = std.testing; @@ -1769,9 +2044,8 @@ test "OSC: OSC9 progress remove extra semicolon" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;0;100;"); + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .remove); } test "OSC: OSC9 progress error" { @@ -1830,6 +2104,30 @@ test "OSC: OSC9 progress pause with progress" { try testing.expect(cmd.progress.progress == 100); } +test "OSC: OSC9 conemu wait input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .wait_input); +} + +test "OSC: OSC9 conemu wait ignores trailing characters" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;5;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .wait_input); +} + test "OSC: empty param" { const testing = std.testing; diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index cdf39657bd..52bfb2c31a 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -1,13 +1,17 @@ //! SGR (Select Graphic Rendition) attrinvbute parsing and types. const std = @import("std"); +const assert = std.debug.assert; const testing = std.testing; const color = @import("color.zig"); +const SepList = @import("Parser.zig").Action.CSI.SepList; /// Attribute type for SGR pub const Attribute = union(enum) { + pub const Tag = std.meta.FieldEnum(Attribute); + /// Unset all attributes - unset: void, + unset, /// Unknown attribute, the raw CSI command parameters are here. unknown: struct { @@ -19,43 +23,43 @@ pub const Attribute = union(enum) { }, /// Bold the text. - bold: void, - reset_bold: void, + bold, + reset_bold, /// Italic text. - italic: void, - reset_italic: void, + italic, + reset_italic, /// Faint/dim text. /// Note: reset faint is the same SGR code as reset bold - faint: void, + faint, /// Underline the text underline: Underline, - reset_underline: void, + reset_underline, underline_color: color.RGB, @"256_underline_color": u8, - reset_underline_color: void, + reset_underline_color, // Overline the text - overline: void, - reset_overline: void, + overline, + reset_overline, /// Blink the text - blink: void, - reset_blink: void, + blink, + reset_blink, /// Invert fg/bg colors. - inverse: void, - reset_inverse: void, + inverse, + reset_inverse, /// Invisible - invisible: void, - reset_invisible: void, + invisible, + reset_invisible, /// Strikethrough the text. - strikethrough: void, - reset_strikethrough: void, + strikethrough, + reset_strikethrough, /// Set foreground color as RGB values. direct_color_fg: color.RGB, @@ -68,8 +72,8 @@ pub const Attribute = union(enum) { @"8_fg": color.Name, /// Reset the fg/bg to their default values. - reset_fg: void, - reset_bg: void, + reset_fg, + reset_bg, /// Set the background/foreground as a named bright color attribute. @"8_bright_bg": color.Name, @@ -94,11 +98,9 @@ pub const Attribute = union(enum) { /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { params: []const u16, + params_sep: SepList = SepList.initEmpty(), idx: usize = 0, - /// True if the separator is a colon - colon: bool = false, - /// Next returns the next attribute or null if there are no more attributes. pub fn next(self: *Parser) ?Attribute { if (self.idx > self.params.len) return null; @@ -106,220 +108,261 @@ pub const Parser = struct { // Implicitly means unset if (self.params.len == 0) { self.idx += 1; - return Attribute{ .unset = {} }; + return .unset; } const slice = self.params[self.idx..self.params.len]; + const colon = self.params_sep.isSet(self.idx); self.idx += 1; // Our last one will have an idx be the last value. if (slice.len == 0) return null; + // If we have a colon separator then we need to ensure we're + // parsing a value that allows it. + if (colon) switch (slice[0]) { + 4, 38, 48, 58 => {}, + + else => { + // Consume all the colon separated values. + const start = self.idx; + while (self.params_sep.isSet(self.idx)) self.idx += 1; + self.idx += 1; + return .{ .unknown = .{ + .full = self.params, + .partial = slice[0 .. self.idx - start + 1], + } }; + }, + }; + switch (slice[0]) { - 0 => return Attribute{ .unset = {} }, - - 1 => return Attribute{ .bold = {} }, - - 2 => return Attribute{ .faint = {} }, - - 3 => return Attribute{ .italic = {} }, - - 4 => blk: { - if (self.colon) { - switch (slice.len) { - // 0 is unreachable because we're here and we read - // an element to get here. - 0 => unreachable, - - // 1 is possible if underline is the last element. - 1 => return Attribute{ .underline = .single }, - - // 2 means we have a specific underline style. - 2 => { - self.idx += 1; - switch (slice[1]) { - 0 => return Attribute{ .reset_underline = {} }, - 1 => return Attribute{ .underline = .single }, - 2 => return Attribute{ .underline = .double }, - 3 => return Attribute{ .underline = .curly }, - 4 => return Attribute{ .underline = .dotted }, - 5 => return Attribute{ .underline = .dashed }, - - // For unknown underline styles, just render - // a single underline. - else => return Attribute{ .underline = .single }, - } - }, - - // Colon-separated must only be 2. - else => break :blk, + 0 => return .unset, + + 1 => return .bold, + + 2 => return .faint, + + 3 => return .italic, + + 4 => underline: { + if (colon) { + assert(slice.len >= 2); + if (self.isColon()) { + self.consumeUnknownColon(); + break :underline; + } + + self.idx += 1; + switch (slice[1]) { + 0 => return .reset_underline, + 1 => return .{ .underline = .single }, + 2 => return .{ .underline = .double }, + 3 => return .{ .underline = .curly }, + 4 => return .{ .underline = .dotted }, + 5 => return .{ .underline = .dashed }, + + // For unknown underline styles, just render + // a single underline. + else => return .{ .underline = .single }, } } - return Attribute{ .underline = .single }; + return .{ .underline = .single }; }, - 5 => return Attribute{ .blink = {} }, + 5 => return .blink, - 6 => return Attribute{ .blink = {} }, + 6 => return .blink, - 7 => return Attribute{ .inverse = {} }, + 7 => return .inverse, - 8 => return Attribute{ .invisible = {} }, + 8 => return .invisible, - 9 => return Attribute{ .strikethrough = {} }, + 9 => return .strikethrough, - 21 => return Attribute{ .underline = .double }, + 21 => return .{ .underline = .double }, - 22 => return Attribute{ .reset_bold = {} }, + 22 => return .reset_bold, - 23 => return Attribute{ .reset_italic = {} }, + 23 => return .reset_italic, - 24 => return Attribute{ .reset_underline = {} }, + 24 => return .reset_underline, - 25 => return Attribute{ .reset_blink = {} }, + 25 => return .reset_blink, - 27 => return Attribute{ .reset_inverse = {} }, + 27 => return .reset_inverse, - 28 => return Attribute{ .reset_invisible = {} }, + 28 => return .reset_invisible, - 29 => return Attribute{ .reset_strikethrough = {} }, + 29 => return .reset_strikethrough, - 30...37 => return Attribute{ + 30...37 => return .{ .@"8_fg" = @enumFromInt(slice[0] - 30), }, 38 => if (slice.len >= 2) switch (slice[1]) { // `2` indicates direct-color (r, g, b). // We need at least 3 more params for this to make sense. - 2 => if (slice.len >= 5) { - self.idx += 4; - // When a colon separator is used, there may or may not be - // a color space identifier as the third param, which we - // need to ignore (it has no standardized behavior). - const rgb = if (slice.len == 5 or !self.colon) - slice[2..5] - else rgb: { - self.idx += 1; - break :rgb slice[3..6]; - }; + 2 => if (self.parseDirectColor( + .direct_color_fg, + slice, + colon, + )) |v| return v, - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_fg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - }, // `5` indicates indexed color. 5 => if (slice.len >= 3) { self.idx += 2; - return Attribute{ + return .{ .@"256_fg" = @truncate(slice[2]), }; }, else => {}, }, - 39 => return Attribute{ .reset_fg = {} }, + 39 => return .reset_fg, - 40...47 => return Attribute{ + 40...47 => return .{ .@"8_bg" = @enumFromInt(slice[0] - 40), }, 48 => if (slice.len >= 2) switch (slice[1]) { // `2` indicates direct-color (r, g, b). // We need at least 3 more params for this to make sense. - 2 => if (slice.len >= 5) { - self.idx += 4; - // When a colon separator is used, there may or may not be - // a color space identifier as the third param, which we - // need to ignore (it has no standardized behavior). - const rgb = if (slice.len == 5 or !self.colon) - slice[2..5] - else rgb: { - self.idx += 1; - break :rgb slice[3..6]; - }; + 2 => if (self.parseDirectColor( + .direct_color_bg, + slice, + colon, + )) |v| return v, - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_bg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - }, // `5` indicates indexed color. 5 => if (slice.len >= 3) { self.idx += 2; - return Attribute{ + return .{ .@"256_bg" = @truncate(slice[2]), }; }, else => {}, }, - 49 => return Attribute{ .reset_bg = {} }, + 49 => return .reset_bg, - 53 => return Attribute{ .overline = {} }, - 55 => return Attribute{ .reset_overline = {} }, + 53 => return .overline, + 55 => return .reset_overline, 58 => if (slice.len >= 2) switch (slice[1]) { // `2` indicates direct-color (r, g, b). // We need at least 3 more params for this to make sense. - 2 => if (slice.len >= 5) { - self.idx += 4; - // When a colon separator is used, there may or may not be - // a color space identifier as the third param, which we - // need to ignore (it has no standardized behavior). - const rgb = if (slice.len == 5 or !self.colon) - slice[2..5] - else rgb: { - self.idx += 1; - break :rgb slice[3..6]; - }; + 2 => if (self.parseDirectColor( + .underline_color, + slice, + colon, + )) |v| return v, - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .underline_color = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - }, // `5` indicates indexed color. 5 => if (slice.len >= 3) { self.idx += 2; - return Attribute{ + return .{ .@"256_underline_color" = @truncate(slice[2]), }; }, else => {}, }, - 59 => return Attribute{ .reset_underline_color = {} }, + 59 => return .reset_underline_color, - 90...97 => return Attribute{ + 90...97 => return .{ // 82 instead of 90 to offset to "bright" colors .@"8_bright_fg" = @enumFromInt(slice[0] - 82), }, - 100...107 => return Attribute{ + 100...107 => return .{ .@"8_bright_bg" = @enumFromInt(slice[0] - 92), }, else => {}, } - return Attribute{ .unknown = .{ .full = self.params, .partial = slice } }; + return .{ .unknown = .{ .full = self.params, .partial = slice } }; + } + + fn parseDirectColor( + self: *Parser, + comptime tag: Attribute.Tag, + slice: []const u16, + colon: bool, + ) ?Attribute { + // Any direct color style must have at least 5 values. + if (slice.len < 5) return null; + + // Only used for direct color sets (38, 48, 58) and subparam 2. + assert(slice[1] == 2); + + // Note: We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + + // If we don't have a colon, then we expect exactly 3 semicolon + // separated values. + if (!colon) { + self.idx += 4; + return @unionInit(Attribute, @tagName(tag), .{ + .r = @truncate(slice[2]), + .g = @truncate(slice[3]), + .b = @truncate(slice[4]), + }); + } + + // We have a colon, we might have either 5 or 6 values depending + // on if the colorspace is present. + const count = self.countColon(); + switch (count) { + 3 => { + self.idx += 4; + return @unionInit(Attribute, @tagName(tag), .{ + .r = @truncate(slice[2]), + .g = @truncate(slice[3]), + .b = @truncate(slice[4]), + }); + }, + + 4 => { + self.idx += 5; + return @unionInit(Attribute, @tagName(tag), .{ + .r = @truncate(slice[3]), + .g = @truncate(slice[4]), + .b = @truncate(slice[5]), + }); + }, + + else => { + self.consumeUnknownColon(); + return null; + }, + } + } + + /// Returns true if the present position has a colon separator. + /// This always returns false for the last value since it has no + /// separator. + fn isColon(self: *Parser) bool { + // The `- 1` here is because the last value has no separator. + if (self.idx >= self.params.len - 1) return false; + return self.params_sep.isSet(self.idx); + } + + fn countColon(self: *Parser) usize { + var count: usize = 0; + var idx = self.idx; + while (idx < self.params.len - 1 and self.params_sep.isSet(idx)) : (idx += 1) { + count += 1; + } + return count; + } + + /// Consumes all the remaining parameters separated by a colon and + /// returns an unknown attribute. + fn consumeUnknownColon(self: *Parser) void { + const count = self.countColon(); + self.idx += count + 1; } }; @@ -329,7 +372,7 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .colon = true }; + var p: Parser = .{ .params = params, .params_sep = SepList.initFull() }; return p.next().?; } @@ -366,6 +409,35 @@ test "sgr: Parser multiple" { try testing.expect(p.next() == null); } +test "sgr: unsupported with colon" { + var p: Parser = .{ + .params = &[_]u16{ 0, 4, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + break :sep list; + }, + }; + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: unsupported with multiple colon" { + var p: Parser = .{ + .params = &[_]u16{ 0, 4, 2, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + list.set(1); + break :sep list; + }, + }; + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + test "sgr: bold" { { const v = testParse(&[_]u16{1}); @@ -439,6 +511,37 @@ test "sgr: underline styles" { } } +test "sgr: underline style with more" { + var p: Parser = .{ + .params = &[_]u16{ 4, 2, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + break :sep list; + }, + }; + + try testing.expect(p.next().? == .underline); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: underline style with too many colons" { + var p: Parser = .{ + .params = &[_]u16{ 4, 2, 3, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + list.set(1); + break :sep list; + }, + }; + + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + test "sgr: blink" { { const v = testParse(&[_]u16{5}); @@ -592,13 +695,13 @@ test "sgr: underline, bg, and fg" { test "sgr: direct color fg missing color" { // This used to crash - var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false }; + var p: Parser = .{ .params = &[_]u16{ 38, 5 } }; while (p.next()) |_| {} } test "sgr: direct color bg missing color" { // This used to crash - var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false }; + var p: Parser = .{ .params = &[_]u16{ 48, 5 } }; while (p.next()) |_| {} } @@ -608,7 +711,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { // Colon version should skip the optional color space identifier { // 3 8 : 2 : Pi : Pr : Pg : Pb - const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 }); + const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_fg); try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); @@ -616,7 +719,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 4 8 : 2 : Pi : Pr : Pg : Pb - const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_bg); try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r); try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g); @@ -624,7 +727,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 5 8 : 2 : Pi : Pr : Pg : Pb - const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 }); try testing.expect(v == .underline_color); try testing.expectEqual(@as(u8, 1), v.underline_color.r); try testing.expectEqual(@as(u8, 2), v.underline_color.g); @@ -634,7 +737,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { // Semicolon version should not parse optional color space identifier { // 3 8 ; 2 ; Pr ; Pg ; Pb - const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 }); + const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_fg); try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r); try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g); @@ -642,7 +745,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 4 8 ; 2 ; Pr ; Pg ; Pb - const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_bg); try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r); try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g); @@ -650,10 +753,114 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 5 8 ; 2 ; Pr ; Pg ; Pb - const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3 }); try testing.expect(v == .underline_color); try testing.expectEqual(@as(u8, 0), v.underline_color.r); try testing.expectEqual(@as(u8, 1), v.underline_color.g); try testing.expectEqual(@as(u8, 2), v.underline_color.b); } } + +test "sgr: direct fg colon with too many colons" { + var p: Parser = .{ + .params = &[_]u16{ 38, 2, 0, 1, 2, 3, 4, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + for (0..6) |idx| list.set(idx); + break :sep list; + }, + }; + + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: direct fg colon with colorspace and extra param" { + var p: Parser = .{ + .params = &[_]u16{ 38, 2, 0, 1, 2, 3, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + for (0..5) |idx| list.set(idx); + break :sep list; + }, + }; + + { + const v = p.next().?; + std.log.warn("WHAT={}", .{v}); + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b); + } + + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: direct fg colon no colorspace and extra param" { + var p: Parser = .{ + .params = &[_]u16{ 38, 2, 1, 2, 3, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + for (0..4) |idx| list.set(idx); + break :sep list; + }, + }; + + { + const v = p.next().?; + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b); + } + + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +// Kakoune sent this complex SGR sequence that caused invalid behavior. +test "sgr: kakoune input" { + // This used to crash + var p: Parser = .{ + .params = &[_]u16{ 0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(1); + list.set(8); + list.set(9); + list.set(10); + list.set(11); + list.set(12); + break :sep list; + }, + }; + + { + const v = p.next().?; + try testing.expect(v == .unset); + } + { + const v = p.next().?; + try testing.expect(v == .underline); + try testing.expectEqual(Attribute.Underline.curly, v.underline); + } + { + const v = p.next().?; + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 175), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 175), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 215), v.direct_color_fg.b); + } + { + const v = p.next().?; + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 190), v.underline_color.r); + try testing.expectEqual(@as(u8, 80), v.underline_color.g); + try testing.expectEqual(@as(u8, 70), v.underline_color.b); + } + + //try testing.expect(p.next() == null); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a4a32e169a..eb5ab2c656 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -253,15 +253,11 @@ pub fn Stream(comptime Handler: type) type { // A parameter separator: ':', ';' => if (self.parser.params_idx < 16) { self.parser.params[self.parser.params_idx] = self.parser.param_acc; + if (c == ':') self.parser.params_sep.set(self.parser.params_idx); self.parser.params_idx += 1; self.parser.param_acc = 0; self.parser.param_acc_idx = 0; - - // Keep track of separator state. - const sep: Parser.ParamSepState = @enumFromInt(c); - if (self.parser.params_idx == 1) self.parser.params_sep = sep; - if (self.parser.params_sep != sep) self.parser.params_sep = .mixed; }, // Explicitly ignored: 0x7F => {}, @@ -937,7 +933,10 @@ pub fn Stream(comptime Handler: type) type { 'm' => switch (input.intermediates.len) { 0 => if (@hasDecl(T, "setAttribute")) { // log.info("parse SGR params={any}", .{action.params}); - var p: sgr.Parser = .{ .params = input.params, .colon = input.sep == .colon }; + var p: sgr.Parser = .{ + .params = input.params, + .params_sep = input.params_sep, + }; while (p.next()) |attr| { // log.info("SGR attribute: {}", .{attr}); try self.handler.setAttribute(attr); @@ -1605,7 +1604,7 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress => { + .progress, .sleep, .show_message_box, .change_conemu_tab_title, .wait_input => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 423ebfa283..4428b16e1b 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -179,8 +179,17 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { // Quit our read thread after exiting the subprocess so that // we don't get stuck waiting for data to stop flowing if it is // a particularly noisy process. - _ = posix.write(exec.read_thread_pipe, "x") catch |err| - log.warn("error writing to read thread quit pipe err={}", .{err}); + _ = posix.write(exec.read_thread_pipe, "x") catch |err| switch (err) { + // BrokenPipe means that our read thread is closed already, + // which is completely fine since that is what we were trying + // to achieve. + error.BrokenPipe => {}, + + else => log.warn( + "error writing to read thread quit pipe err={}", + .{err}, + ), + }; if (comptime builtin.os.tag == .windows) { // Interrupt the blocking read so the thread can see the quit message @@ -856,7 +865,11 @@ const Subprocess = struct { env.remove("GHOSTTY_MAC_APP"); } - // Don't leak these environment variables to child processes. + // VTE_VERSION is set by gnome-terminal and other VTE-based terminals. + // We don't want our child processes to think we're running under VTE. + env.remove("VTE_VERSION"); + + // Don't leak these GTK environment variables to child processes. if (comptime build_config.app_runtime == .gtk) { env.remove("GDK_DEBUG"); env.remove("GDK_DISABLE"); @@ -871,7 +884,11 @@ const Subprocess = struct { }; const force: ?shell_integration.Shell = switch (cfg.shell_integration) { - .none => break :shell .{ null, default_shell_command }, + .none => { + // Even if shell integration is none, we still want to set up the feature env vars + try shell_integration.setupFeatures(&env, cfg.shell_integration_features); + break :shell .{ null, default_shell_command }; + }, .detect => null, .bash => .bash, .elvish => .elvish, @@ -967,12 +984,12 @@ const Subprocess = struct { // which we may not want. If we specify "-l" then we can avoid // this behavior but now the shell isn't a login shell. // - // There is another issue: `login(1)` only checks for ".hushlogin" - // in the working directory. This means that if we specify "-l" - // then we won't get hushlogin honored if its in the home - // directory (which is standard). To get around this, we - // check for hushlogin ourselves and if present specify the - // "-q" flag to login(1). + // There is another issue: `login(1)` on macOS 14.3 and earlier + // checked for ".hushlogin" in the working directory. This means + // that if we specify "-l" then we won't get hushlogin honored + // if its in the home directory (which is standard). To get + // around this, we check for hushlogin ourselves and if present + // specify the "-q" flag to login(1). // // So to get all the behaviors we want, we specify "-l" but // execute "bash" (which is built-in to macOS). We then use @@ -1090,6 +1107,10 @@ const Subprocess = struct { }); self.pty = pty; errdefer { + if (comptime builtin.os.tag != .windows) { + _ = posix.close(pty.slave); + } + pty.deinit(); self.pty = null; } @@ -1174,6 +1195,13 @@ const Subprocess = struct { log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); } + if (comptime builtin.os.tag != .windows) { + // Once our subcommand is started we can close the slave + // side. This prevents the slave fd from being leaked to + // future children. + _ = posix.close(pty.slave); + } + self.command = cmd; return switch (builtin.os.tag) { .windows => .{ @@ -1448,6 +1476,13 @@ pub const ReadThread = struct { log.info("read thread got quit signal", .{}); return; } + + // If our pty fd is closed, then we're also done with our + // read thread. + if (pollfds[0].revents & posix.POLL.HUP != 0) { + log.info("pty fd closed, read thread exiting", .{}); + return; + } } } diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 634f6e960e..423e2f5186 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -58,67 +58,73 @@ pub fn setup( break :exe std.fs.path.basename(command[0..idx]); }; - const result: ShellIntegration = shell: { - if (std.mem.eql(u8, "bash", exe)) { - // Apple distributes their own patched version of Bash 3.2 - // on macOS that disables the ENV-based POSIX startup path. - // This means we're unable to perform our automatic shell - // integration sequence in this specific environment. - // - // If we're running "/bin/bash" on Darwin, we can assume - // we're using Apple's Bash because /bin is non-writable - // on modern macOS due to System Integrity Protection. - if (comptime builtin.target.isDarwin()) { - if (std.mem.eql(u8, "/bin/bash", command)) { - return null; - } - } + const result = try setupShell(alloc_arena, resource_dir, command, env, exe); - const new_command = try setupBash( - alloc_arena, - command, - resource_dir, - env, - ) orelse return null; - break :shell .{ - .shell = .bash, - .command = new_command, - }; - } + // Setup our feature env vars + try setupFeatures(env, features); - if (std.mem.eql(u8, "elvish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - break :shell .{ - .shell = .elvish, - .command = try alloc_arena.dupe(u8, command), - }; - } + return result; +} - if (std.mem.eql(u8, "fish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - break :shell .{ - .shell = .fish, - .command = try alloc_arena.dupe(u8, command), - }; +fn setupShell( + alloc_arena: Allocator, + resource_dir: []const u8, + command: []const u8, + env: *EnvMap, + exe: []const u8, +) !?ShellIntegration { + if (std.mem.eql(u8, "bash", exe)) { + // Apple distributes their own patched version of Bash 3.2 + // on macOS that disables the ENV-based POSIX startup path. + // This means we're unable to perform our automatic shell + // integration sequence in this specific environment. + // + // If we're running "/bin/bash" on Darwin, we can assume + // we're using Apple's Bash because /bin is non-writable + // on modern macOS due to System Integrity Protection. + if (comptime builtin.target.isDarwin()) { + if (std.mem.eql(u8, "/bin/bash", command)) { + return null; + } } - if (std.mem.eql(u8, "zsh", exe)) { - try setupZsh(resource_dir, env); - break :shell .{ - .shell = .zsh, - .command = try alloc_arena.dupe(u8, command), - }; - } + const new_command = try setupBash( + alloc_arena, + command, + resource_dir, + env, + ) orelse return null; + return .{ + .shell = .bash, + .command = new_command, + }; + } - return null; - }; + if (std.mem.eql(u8, "elvish", exe)) { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + return .{ + .shell = .elvish, + .command = try alloc_arena.dupe(u8, command), + }; + } - // Setup our feature env vars - if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); - if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); - if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); + if (std.mem.eql(u8, "fish", exe)) { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + return .{ + .shell = .fish, + .command = try alloc_arena.dupe(u8, command), + }; + } - return result; + if (std.mem.eql(u8, "zsh", exe)) { + try setupZsh(resource_dir, env); + return .{ + .shell = .zsh, + .command = try alloc_arena.dupe(u8, command), + }; + } + + return null; } test "force shell" { @@ -138,6 +144,58 @@ test "force shell" { } } +/// Setup shell integration feature environment variables without +/// performing full shell integration setup. +pub fn setupFeatures( + env: *EnvMap, + features: config.ShellIntegrationFeatures, +) !void { + if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); + if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); + if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); +} + +test "setup features" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Test: all features enabled (no environment variables should be set) + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true }); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") == null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE") == null); + } + + // Test: all features disabled + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false }); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO").?); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?); + } + + // Test: mixed features + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false }); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?); + } +} + /// Setup the bash automatic shell integration. This works by /// starting bash in POSIX mode and using the ENV environment /// variable to load our bash integration script. This prevents @@ -145,8 +203,6 @@ test "force shell" { /// our script's responsibility (along with disabling POSIX /// mode). /// -/// This approach requires bash version 4 or later. -/// /// This returns a new (allocated) shell command string that /// enables the integration or null if integration failed. fn setupBash( @@ -174,46 +230,56 @@ fn setupBash( try args.append("--posix"); // Stores the list of intercepted command line flags that will be passed - // to our shell integration script: --posix --norc --noprofile + // to our shell integration script: --norc --noprofile // We always include at least "1" so the script can differentiate between // being manually sourced or automatically injected (from here). var inject = try std.BoundedArray(u8, 32).init(0); try inject.appendSlice("1"); - var posix = false; - - // Some additional cases we don't yet cover: + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration script. // - // - If additional file arguments are provided (after a `-` or `--` flag), - // and the `i` shell option isn't being explicitly set, we can assume a - // non-interactive shell session and skip loading our shell integration. + // Unsupported options: + // -c -c is always non-interactive + // --posix POSIX mode (a la /bin/sh) + var rcfile: ?[]const u8 = null; while (iter.next()) |arg| { if (std.mem.eql(u8, arg, "--posix")) { - try inject.appendSlice(" --posix"); - posix = true; + return null; } else if (std.mem.eql(u8, arg, "--norc")) { try inject.appendSlice(" --norc"); } else if (std.mem.eql(u8, arg, "--noprofile")) { try inject.appendSlice(" --noprofile"); } else if (std.mem.eql(u8, arg, "--rcfile") or std.mem.eql(u8, arg, "--init-file")) { - if (iter.next()) |rcfile| { - try env.put("GHOSTTY_BASH_RCFILE", rcfile); - } + rcfile = iter.next(); } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') { // '-c command' is always non-interactive if (std.mem.indexOfScalar(u8, arg, 'c') != null) { return null; } try args.append(arg); + } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + try args.append(arg); + while (iter.next()) |remaining_arg| { + try args.append(remaining_arg); + } + break; } else { try args.append(arg); } } try env.put("GHOSTTY_BASH_INJECT", inject.slice()); + if (rcfile) |v| { + try env.put("GHOSTTY_BASH_RCFILE", v); + } // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're // staying in POSIX mode (--posix), change it back to ~/.bash_history. - if (!posix and env.get("HISTFILE") == null) { + if (env.get("HISTFILE") == null) { var home_buf: [1024]u8 = undefined; if (try homedir.home(&home_buf)) |home| { var histfile_buf: [std.fs.max_path_bytes]u8 = undefined; @@ -227,13 +293,6 @@ fn setupBash( } } - // Preserve the existing ENV value when staying in POSIX mode (--posix). - if (env.get("ENV")) |old| { - if (posix) { - try env.put("GHOSTTY_BASH_ENV", old); - } - } - // Set our new ENV to point to our integration script. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( @@ -262,21 +321,32 @@ test "bash" { try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); } -test "bash: inject flags" { +test "bash: unsupported options" { const testing = std.testing; const alloc = testing.allocator; - // bash --posix - { + const cmdlines = [_][]const u8{ + "bash --posix", + "bash --rcfile script.sh --posix", + "bash --init-file script.sh --posix", + "bash -c script.sh", + "bash -ic script.sh", + }; + + for (cmdlines) |cmdline| { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expectEqualStrings("bash --posix", command.?); - try testing.expectEqualStrings("1 --posix", env.get("GHOSTTY_BASH_INJECT").?); + try testing.expect(try setupBash(alloc, cmdline, ".", &env) == null); + try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null); + try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null); + try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); } +} + +test "bash: inject flags" { + const testing = std.testing; + const alloc = testing.allocator; // bash --norc { @@ -329,17 +399,6 @@ test "bash: rcfile" { } } -test "bash: -c command" { - const testing = std.testing; - const alloc = testing.allocator; - - var env = EnvMap.init(alloc); - defer env.deinit(); - - try testing.expect(try setupBash(alloc, "bash -c script.sh", ".", &env) == null); - try testing.expect(try setupBash(alloc, "bash -ic script.sh", ".", &env) == null); -} - test "bash: HISTFILE" { const testing = std.testing; const alloc = testing.allocator; @@ -369,67 +428,29 @@ test "bash: HISTFILE" { try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); } - - // HISTFILE unset (POSIX mode) - { - var env = EnvMap.init(alloc); - defer env.deinit(); - - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expect(env.get("HISTFILE") == null); - try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); - } - - // HISTFILE set (POSIX mode) - { - var env = EnvMap.init(alloc); - defer env.deinit(); - - try env.put("HISTFILE", "my_history"); - - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); - try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); - } } -test "bash: preserve ENV" { +test "bash: additional arguments" { const testing = std.testing; const alloc = testing.allocator; var env = EnvMap.init(alloc); defer env.deinit(); - const original_env = "original-env.bash"; - - // POSIX mode + // "-" argument separator { - try env.put("ENV", original_env); - const command = try setupBash(alloc, "bash --posix", ".", &env); + const command = try setupBash(alloc, "bash - --arg file1 file2", ".", &env); defer if (command) |c| alloc.free(c); - try testing.expect(std.mem.indexOf(u8, command.?, "--posix") != null); - try testing.expect(std.mem.indexOf(u8, env.get("GHOSTTY_BASH_INJECT").?, "posix") != null); - try testing.expectEqualStrings(original_env, env.get("GHOSTTY_BASH_ENV").?); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?); } - env.remove("GHOSTTY_BASH_ENV"); - - // Not POSIX mode + // "--" argument separator { - try env.put("ENV", original_env); - const command = try setupBash(alloc, "bash", ".", &env); + const command = try setupBash(alloc, "bash -- --arg file1 file2", ".", &env); defer if (command) |c| alloc.free(c); - try testing.expect(std.mem.indexOf(u8, command.?, "--posix") != null); - try testing.expect(std.mem.indexOf(u8, env.get("GHOSTTY_BASH_INJECT").?, "posix") == null); - try testing.expect(env.get("GHOSTTY_BASH_ENV") == null); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?); } } diff --git a/src/unicode/props.zig b/src/unicode/props.zig index d77bf4c8ae..8c7621b795 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -131,7 +131,9 @@ pub fn get(cp: u21) Properties { /// Runnable binary to generate the lookup tables and output to stdout. pub fn main() !void { - const alloc = std.heap.c_allocator; + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const alloc = arena_state.allocator(); const gen: lut.Generator( Properties,