diff --git a/README.md b/README.md index 3c3a2460d..4cafc6ac1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- Logo + Logo
Ghostty

@@ -107,25 +107,40 @@ palette = 7=#a89984 palette = 15=#fbf1c7 ``` -You can view all available configuration options and their documentation -by executing the command `ghostty +show-config --default --docs`. Note that -this will output the full default configuration with docs to stdout, so -you may want to pipe that through a pager, an editor, etc. +#### Configuration Documentation + +There are multiple places to find documentation on the configuration options. +All locations are identical (they're all generated from the same source): + +1. There are HTML and Markdown formatted docs in the + `$prefix/share/ghostty/docs` directory. This directory is created + when you build or install Ghostty. The `$prefix` is `zig-out` if you're + building from source (or the specified `--prefix` flag). On macOS, + `$prefix` is the `Contents/Resources` subdirectory of the `.app` bundle. + +2. There are man pages in the `$prefix/share/man` directory. This directory + is created when you build or install Ghostty. + +3. In the CLI, you can run `ghostty +show-config --default --docs`. + Note that this will output the full default configuration with docs to + stdout, so you may want to pipe that through a pager, an editor, etc. + +4. In the source code, you can find the configuration structure in the + [Config structure](https://github.com/ghostty-org/ghostty/blob/main/src/config/Config.zig). + The available keys are the keys verbatim, and their possible values are typically + documented in the comments. + +5. Not documentation per se, but you can search for the + [public config files](https://github.com/search?q=path%3Aghostty%2Fconfig&type=code) + of many Ghostty users for examples and inspiration. > [!NOTE] > -> You'll see a lot of weird blank configurations like `font-family =`. This +> You may see strange looking blank configurations like `font-family =`. This > is a valid syntax to specify the default behavior (no value). The > `+show-config` outputs it so it's clear that key is defaulting and also > to have something to attach the doc comment to. -You can also see and read all available configuration options in the source -[Config structure](https://github.com/ghostty-org/ghostty/blob/main/src/config/Config.zig). -The available keys are the keys verbatim, and their possible values are typically -documented in the comments. You also can search for the -[public config files](https://github.com/search?q=path%3Aghostty%2Fconfig&type=code) -of many Ghostty users for examples and inspiration. - > [!NOTE] > > Configuration can be reloaded on the fly with the `reload_config` diff --git a/build.zig b/build.zig index 15fed7ed6..093afe481 100644 --- a/build.zig +++ b/build.zig @@ -573,17 +573,20 @@ pub fn build(b: *std.Build) !void { 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_16x16.png", "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_32x32.png", "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_128x128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_256x256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_512x512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_16x16@2x@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_32x32@2x@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_128x128@2x@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_256x256@2x@2x.png", "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png"); + 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"); + 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) diff --git a/build.zig.zon b/build.zig.zon index c5e2d8f70..35365af8a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .dependencies = .{ // Zig libs .libxev = .{ - .url = "https://github.com/mitchellh/libxev/archive/b8d1d93e5c899b27abbaa7df23b496c3e6a178c7.tar.gz", - .hash = "1220612bc023c21d75234882ec9a8c6a1cbd9d642da3dfb899297f14bb5bd7b6cd78", + .url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz", + .hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf", }, .mach_glfw = .{ .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop new file mode 100755 index 000000000..5e8351390 --- /dev/null +++ b/dist/linux/ghostty_dolphin.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Service +ServiceTypes=KonqPopupMenu/Plugin +MimeType=inode/directory +Actions=RunGhosttyDir + +[Desktop Action RunGhosttyDir] +Name=Open Ghostty Here +Icon=com.mitchellh.ghostty +Exec=ghostty --working-directory=%F --gtk-single-instance=false + diff --git a/dist/macos/Ghostty.icns b/dist/macos/Ghostty.icns old mode 100755 new mode 100644 index 52365a405..44a44711a Binary files a/dist/macos/Ghostty.icns and b/dist/macos/Ghostty.icns differ diff --git a/dist/windows/ghostty.ico b/dist/windows/ghostty.ico index 1fe7ed98d..1c5afc258 100644 Binary files a/dist/windows/ghostty.ico and b/dist/windows/ghostty.ico differ diff --git a/flake.lock b/flake.lock index b5e75bae7..f517f07e4 100644 --- a/flake.lock +++ b/flake.lock @@ -20,27 +20,27 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1726062281, - "narHash": "sha256-PyFVySdGj3enKqm8RQuo4v1KLJLmNLOq2yYOHsI6e2Q=", + "lastModified": 1733423277, + "narHash": "sha256-TxabjxEgkNbCGFRHgM/b9yZWlBj60gUOUnRT/wbVQR8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "e65aa8301ba4f0ab8cb98f944c14aa9da07394f8", + "rev": "e36963a147267afc055f7cf65225958633e536bf", "type": "github" }, "original": { "owner": "nixos", - "ref": "release-24.05", + "ref": "release-24.11", "repo": "nixpkgs", "type": "github" } }, "nixpkgs-unstable": { "locked": { - "lastModified": 1719082008, - "narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=", + "lastModified": 1733229606, + "narHash": "sha256-FLYY5M0rpa5C2QAE3CKLYAM6TwbKicdRK6qNrSHlNrE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9693852a2070b398ee123a329e68f0dab5526681", + "rev": "566e53c2ad750c84f6d31f9ccb9d00f823165550", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 01acca063..d52f96d72 100644 --- a/flake.nix +++ b/flake.nix @@ -7,7 +7,7 @@ # We want to stay as up to date as possible but need to be careful that the # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. - nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.05"; + nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11"; zig = { url = "github:mitchellh/zig-overlay"; @@ -36,7 +36,6 @@ packages.${system} = let mkArgs = optimize: { - inherit (pkgs-unstable) zig_0_13 stdenv; inherit optimize; revision = self.shortRev or self.dirtyShortRev or "dirty"; diff --git a/images/icons/icon_1024.png b/images/icons/icon_1024.png new file mode 100644 index 000000000..a0b716c87 Binary files /dev/null and b/images/icons/icon_1024.png differ diff --git a/images/icons/icon_128.png b/images/icons/icon_128.png new file mode 100644 index 000000000..bad0eb891 Binary files /dev/null and b/images/icons/icon_128.png differ diff --git a/images/icons/icon_128@2x.png b/images/icons/icon_128@2x.png new file mode 100644 index 000000000..46c3f7050 Binary files /dev/null and b/images/icons/icon_128@2x.png differ diff --git a/images/icons/icon_128x128.png b/images/icons/icon_128x128.png deleted file mode 100755 index 7b581449b..000000000 Binary files a/images/icons/icon_128x128.png and /dev/null differ diff --git a/images/icons/icon_128x128@2x@2x.png b/images/icons/icon_128x128@2x@2x.png deleted file mode 100755 index 1c29b7011..000000000 Binary files a/images/icons/icon_128x128@2x@2x.png and /dev/null differ diff --git a/images/icons/icon_16.png b/images/icons/icon_16.png new file mode 100644 index 000000000..cacff7a54 Binary files /dev/null and b/images/icons/icon_16.png differ diff --git a/images/icons/icon_16@2x.png b/images/icons/icon_16@2x.png new file mode 100644 index 000000000..b35e66641 Binary files /dev/null and b/images/icons/icon_16@2x.png differ diff --git a/images/icons/icon_16x16.png b/images/icons/icon_16x16.png deleted file mode 100755 index 6a9dbbfde..000000000 Binary files a/images/icons/icon_16x16.png and /dev/null differ diff --git a/images/icons/icon_16x16@2x@2x.png b/images/icons/icon_16x16@2x@2x.png deleted file mode 100755 index 5e738dfd3..000000000 Binary files a/images/icons/icon_16x16@2x@2x.png and /dev/null differ diff --git a/images/icons/icon_256.png b/images/icons/icon_256.png new file mode 100644 index 000000000..803224416 Binary files /dev/null and b/images/icons/icon_256.png differ diff --git a/images/icons/icon_256@2x.png b/images/icons/icon_256@2x.png new file mode 100644 index 000000000..b51b8d7dc Binary files /dev/null and b/images/icons/icon_256@2x.png differ diff --git a/images/icons/icon_256x256.png b/images/icons/icon_256x256.png deleted file mode 100755 index 2dda0d6d3..000000000 Binary files a/images/icons/icon_256x256.png and /dev/null differ diff --git a/images/icons/icon_256x256@2x@2x.png b/images/icons/icon_256x256@2x@2x.png deleted file mode 100755 index b0b5c70aa..000000000 Binary files a/images/icons/icon_256x256@2x@2x.png and /dev/null differ diff --git a/images/icons/icon_32.png b/images/icons/icon_32.png new file mode 100644 index 000000000..b647bcf35 Binary files /dev/null and b/images/icons/icon_32.png differ diff --git a/images/icons/icon_32@2x.png b/images/icons/icon_32@2x.png new file mode 100644 index 000000000..e394a5170 Binary files /dev/null and b/images/icons/icon_32@2x.png differ diff --git a/images/icons/icon_32x32.png b/images/icons/icon_32x32.png deleted file mode 100755 index 5e738dfd3..000000000 Binary files a/images/icons/icon_32x32.png and /dev/null differ diff --git a/images/icons/icon_32x32@2x@2x.png b/images/icons/icon_32x32@2x@2x.png deleted file mode 100755 index c7cbb7f58..000000000 Binary files a/images/icons/icon_32x32@2x@2x.png and /dev/null differ diff --git a/images/icons/icon_512.png b/images/icons/icon_512.png new file mode 100644 index 000000000..b51b8d7dc Binary files /dev/null and b/images/icons/icon_512.png differ diff --git a/images/icons/icon_512x512.png b/images/icons/icon_512x512.png deleted file mode 100755 index b0b5c70aa..000000000 Binary files a/images/icons/icon_512x512.png and /dev/null differ diff --git a/images/icons/icon_512x512@2x@2x.png b/images/icons/icon_512x512@2x@2x.png deleted file mode 100755 index 0368b4a42..000000000 Binary files a/images/icons/icon_512x512@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Assets.xcassets/AppIcon.appiconset/Contents.json index eb3bbadd8..9c6bc2e81 100644 --- a/macos/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,67 +1,67 @@ { "images" : [ { - "filename" : "icon_512x512@2x@2x 1.png", + "filename" : "macOS-AppIcon-1024px.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { - "filename" : "icon_16x16.png", + "filename" : "macOS-AppIcon-16px-16pt@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "icon_16x16@2x@2x.png", + "filename" : "macOS-AppIcon-32px-16pt@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "icon_32x32.png", + "filename" : "macOS-AppIcon-32px-32pt@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "icon_32x32@2x@2x.png", + "filename" : "macOS-AppIcon-64px-32pt@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "icon_128x128.png", + "filename" : "macOS-AppIcon-128px-128pt@1x.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "icon_128x128@2x@2x.png", + "filename" : "macOS-AppIcon-256px-128pt@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "icon_256x256.png", + "filename" : "macOS-AppIcon-256px-128pt@2x 1.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "icon_256x256@2x@2x.png", + "filename" : "macOS-AppIcon-512px-256pt@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "icon_512x512.png", + "filename" : "macOS-AppIcon-512px.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "icon_512x512@2x@2x.png", + "filename" : "macOS-AppIcon-1024px 1.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_128x128.png deleted file mode 100644 index 7b581449b..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png deleted file mode 100644 index 1c29b7011..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_16x16.png deleted file mode 100644 index d7cb16795..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x@2x.png deleted file mode 100644 index 5e738dfd3..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_256x256.png deleted file mode 100644 index 2dda0d6d3..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png deleted file mode 100644 index b0b5c70aa..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_32x32.png deleted file mode 100644 index 5e738dfd3..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png deleted file mode 100644 index c7cbb7f58..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512.png deleted file mode 100644 index b0b5c70aa..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png deleted file mode 100644 index 0368b4a42..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x 1.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png deleted file mode 100644 index 0368b4a42..000000000 Binary files a/macos/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png new file mode 100644 index 000000000..a0b716c87 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png new file mode 100644 index 000000000..a0b716c87 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png new file mode 100644 index 000000000..bad0eb891 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png new file mode 100644 index 000000000..cacff7a54 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png new file mode 100644 index 000000000..46c3f7050 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png new file mode 100644 index 000000000..46c3f7050 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png new file mode 100644 index 000000000..c8011a605 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png new file mode 100644 index 000000000..5e68d5fd0 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png new file mode 100644 index 000000000..b51b8d7dc Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png new file mode 100644 index 000000000..f302b40bb Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png differ diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png new file mode 100644 index 000000000..e394a5170 Binary files /dev/null and b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png differ diff --git a/macos/Assets.xcassets/AppIconImage.imageset/Contents.json b/macos/Assets.xcassets/AppIconImage.imageset/Contents.json index 44659dc1a..2711a9584 100644 --- a/macos/Assets.xcassets/AppIconImage.imageset/Contents.json +++ b/macos/Assets.xcassets/AppIconImage.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "icon_128x128.png", + "filename" : "macOS-AppIcon-256px-128pt@2x.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "icon_128x128@2x@2x.png", + "filename" : "macOS-AppIcon-512px.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "icon_256x256@2x@2x.png", + "filename" : "macOS-AppIcon-1024px.png", "idiom" : "universal", "scale" : "3x" } diff --git a/macos/Assets.xcassets/AppIconImage.imageset/icon_128x128.png b/macos/Assets.xcassets/AppIconImage.imageset/icon_128x128.png deleted file mode 100644 index 7b581449b..000000000 Binary files a/macos/Assets.xcassets/AppIconImage.imageset/icon_128x128.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIconImage.imageset/icon_128x128@2x@2x.png b/macos/Assets.xcassets/AppIconImage.imageset/icon_128x128@2x@2x.png deleted file mode 100644 index 1c29b7011..000000000 Binary files a/macos/Assets.xcassets/AppIconImage.imageset/icon_128x128@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIconImage.imageset/icon_256x256@2x@2x.png b/macos/Assets.xcassets/AppIconImage.imageset/icon_256x256@2x@2x.png deleted file mode 100644 index b0b5c70aa..000000000 Binary files a/macos/Assets.xcassets/AppIconImage.imageset/icon_256x256@2x@2x.png and /dev/null differ diff --git a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png new file mode 100644 index 000000000..a0b716c87 Binary files /dev/null and b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png differ diff --git a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png new file mode 100644 index 000000000..46c3f7050 Binary files /dev/null and b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png differ diff --git a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png new file mode 100644 index 000000000..6d44fc9f3 Binary files /dev/null and b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png differ diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 18549eea1..b5e65d76e 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -57,6 +57,7 @@ class QuickTerminalController: BaseTerminalController { // MARK: NSWindowController override func windowDidLoad() { + super.windowDidLoad() guard let window = self.window else { return } // The controller is the window delegate so we can detect events such as diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 721248013..68c243004 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -404,7 +404,21 @@ class BaseTerminalController: NSWindowController, } } - //MARK: - NSWindowDelegate + // MARK: NSWindowController + + override func windowDidLoad() { + guard let window else { return } + + // We always initialize our fullscreen style to native if we can because + // initialization sets up some state (i.e. observers). If its set already + // somehow we don't do this. + if fullscreenStyle == nil { + fullscreenStyle = NativeFullscreen(window) + fullscreenStyle?.delegate = self + } + } + + // MARK: NSWindowDelegate // This is called when performClose is called on a window (NOT when close() // is called directly). performClose is called primarily when UI elements such diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 81c74987b..67e7259f3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -94,6 +94,16 @@ class TerminalController: BaseTerminalController { } } + + override func fullscreenDidChange() { + super.fullscreenDidChange() + + // When our fullscreen state changes, we resync our appearance because some + // properties change when fullscreen or not. + guard let focusedSurface else { return } + syncAppearance(focusedSurface.derivedConfig) + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -204,7 +214,13 @@ class TerminalController: BaseTerminalController { } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (surfaceConfig.backgroundOpacity < 1) { + + // Window transparency only takes effect if our window is not native fullscreen. + // In native fullscreen we disable transparency/opacity because the background + // becomes gray and widgets show through. + if (!window.styleMask.contains(.fullScreen) && + surfaceConfig.backgroundOpacity < 1 + ) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -259,6 +275,7 @@ class TerminalController: BaseTerminalController { } override func windowDidLoad() { + super.windowDidLoad() guard let window = window as? TerminalWindow else { return } // I copy this because we may change the source in the future but also because diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 496d19700..503e76791 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -5,10 +5,7 @@ class TerminalWindow: NSWindow { lazy var titlebarColor: NSColor = backgroundColor { didSet { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - + guard let titlebarContainer else { return } titlebarContainer.wantsLayer = true titlebarContainer.layer?.backgroundColor = titlebarColor.cgColor } @@ -68,6 +65,48 @@ class TerminalWindow: NSWindow { bindings.forEach() { $0.invalidate() } } + // MARK: Titlebar Helpers + // These helpers are generic to what we're trying to achieve (i.e. titlebar + // style tabs, titlebar styling, etc.). They're just here to make it easier. + + private var titlebarContainer: NSView? { + // If we aren't fullscreen then the titlebar container is part of our window. + if !styleMask.contains(.fullScreen) { + guard let view = contentView?.superview ?? contentView else { return nil } + return titlebarContainerView(in: view) + } + + // If we are fullscreen, the titlebar container view is part of a separate + // "fullscreen window", we need to find the window and then get the view. + for window in NSApplication.shared.windows { + // This is the private window class that contains the toolbar + guard window.className == "NSToolbarFullScreenWindow" else { continue } + + // The parent will match our window. This is used to filter the correct + // fullscreen window if we have multiple. + guard window.parent == self else { continue } + + guard let view = window.contentView else { continue } + return titlebarContainerView(in: view) + } + + return nil + } + + private func titlebarContainerView(in view: NSView) -> NSView? { + if view.className == "NSTitlebarContainerView" { + return view + } + + for subview in view.subviews { + if let found = titlebarContainerView(in: subview) { + return found + } + } + + return nil + } + // MARK: - NSWindow override var title: String { @@ -152,9 +191,8 @@ class TerminalWindow: NSWindow { // would be an opaque color. When the titlebar isn't transparent, however, the system applies // a compositing effect to the unselected tab backgrounds, which makes them blend with the // titlebar's/window's background. - if let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }), let effectView = titlebarContainer.descendants(withClassName: "NSVisualEffectView").first { + if let effectView = titlebarContainer?.descendants( + withClassName: "NSVisualEffectView").first { effectView.isHidden = titlebarTabs || !titlebarTabs && !hasVeryDarkBackground } @@ -223,10 +261,7 @@ class TerminalWindow: NSWindow { // window's key status changes in terms of becoming less prominent visually, // so we need to do it manually. private func updateNewTabButtonOpacity() { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } + guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { $0 as? NSImageView != nil }) as? NSImageView else { return } @@ -237,10 +272,7 @@ class TerminalWindow: NSWindow { // Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, // just as it does in the stock tab bar. private func updateNewTabButtonImage() { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - guard let newTabButton: NSButton = titlebarContainer.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } + guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { $0 as? NSImageView != nil }) as? NSImageView else { return } @@ -272,10 +304,7 @@ class TerminalWindow: NSWindow { private func updateTabsForVeryDarkBackgrounds() { guard hasVeryDarkBackground else { return } - - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } + guard let titlebarContainer else { return } if let tabGroup = tabGroup, tabGroup.isTabBarVisible { guard let activeTabBackgroundView = titlebarContainer.firstDescendant(withClassName: "NSTabButton")?.superview?.subviews.last?.firstDescendant(withID: "_backgroundView") @@ -301,8 +330,7 @@ class TerminalWindow: NSWindow { }() private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil } - + guard let titlebarContainer else { return nil } let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height) let view = NSView(frame: NSRect(origin: .zero, size: size)) @@ -390,9 +418,7 @@ class TerminalWindow: NSWindow { // Find the NSTextField responsible for displaying the titlebar's title. private var titlebarTextField: NSTextField? { - guard let titlebarContainer = contentView?.superview?.subviews - .first(where: { $0.className == "NSTitlebarContainerView" }) else { return nil } - guard let titlebarView = titlebarContainer.subviews + guard let titlebarView = titlebarContainer?.subviews .first(where: { $0.className == "NSTitlebarView" }) else { return nil } return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField } @@ -450,10 +476,7 @@ class TerminalWindow: NSWindow { // For titlebar tabs, we want to hide the separator view so that we get rid // of an aesthetically unpleasing shadow. private func hideTitleBarSeparators() { - guard let titlebarContainer = contentView?.superview?.subviews.first(where: { - $0.className == "NSTitlebarContainerView" - }) else { return } - + guard let titlebarContainer else { return } for v in titlebarContainer.descendants(withClassName: "NSTitlebarSeparatorView") { v.isHidden = true } diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 43bf8d096..0a279ea1f 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -50,7 +50,8 @@ extension Ghostty { } case GHOSTTY_TRIGGER_UNICODE: - equiv = String(trigger.key.unicode) + guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } + equiv = String(scalar) default: return nil diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index d4f82620c..272cdabdb 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -429,12 +429,34 @@ extension Ghostty { /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't /// figure it out so we're going to do this hacky thing to bring focus back to the terminal /// that should have it. - static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) { - DispatchQueue.main.async { + static func moveFocus( + to: SurfaceView, + from: SurfaceView? = nil, + delay: TimeInterval? = nil + ) { + // The whole delay machinery is a bit of a hack to work around a + // situation where the window is destroyed and the surface view + // will never be attached to a window. Realistically, we should + // handle this upstream but we also don't want this function to be + // a source of infinite loops. + + // Our max delay before we give up + let maxDelay: TimeInterval = 0.5 + guard (delay ?? 0) < maxDelay else { return } + + // We start at a 50 millisecond delay and do a doubling backoff + let nextDelay: TimeInterval = if let delay { + delay * 2 + } else { + // 100 milliseconds + 0.05 + } + + let work: DispatchWorkItem = .init { // If the callback runs before the surface is attached to a view // then the window will be nil. We just reschedule in that case. guard let window = to.window else { - moveFocus(to: to, from: from) + moveFocus(to: to, from: from, delay: nextDelay) return } @@ -448,5 +470,12 @@ extension Ghostty { window.makeFirstResponder(to) } + + let queue = DispatchQueue.main + if let delay { + queue.asyncAfter(deadline: .now() + delay, execute: work) + } else { + queue.async(execute: work) + } } } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index f5df43ec2..56912a28a 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -45,20 +45,53 @@ extension FullscreenDelegate { func fullscreenDidChange() {} } +/// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own. +class FullscreenBase { + let window: NSWindow + weak var delegate: FullscreenDelegate? + + required init?(_ window: NSWindow) { + self.window = window + + // We want to trigger delegate methods on window native fullscreen + // changes (didEnterFullScreenNotification, etc.) no matter what our + // fullscreen style is. + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(didEnterFullScreenNotification), + name: NSWindow.didEnterFullScreenNotification, + object: window) + center.addObserver( + self, + selector: #selector(didExitFullScreenNotification), + name: NSWindow.didExitFullScreenNotification, + object: window) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func didEnterFullScreenNotification(_ notification: Notification) { + delegate?.fullscreenDidChange() + } + + @objc private func didExitFullScreenNotification(_ notification: Notification) { + delegate?.fullscreenDidChange() + } +} + /// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen /// button on regular titlebars. -class NativeFullscreen: FullscreenStyle { - private let window: NSWindow - - weak var delegate: FullscreenDelegate? +class NativeFullscreen: FullscreenBase, FullscreenStyle { var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var supportsTabs: Bool { true } required init?(_ window: NSWindow) { // TODO: There are many requirements for native fullscreen we should // check here such as the stylemask. - - self.window = window + super.init(window) } func enter() { @@ -72,8 +105,9 @@ class NativeFullscreen: FullscreenStyle { // Enter fullscreen window.toggleFullScreen(self) - // Notify the delegate - delegate?.fullscreenDidChange() + // Note: we don't call our delegate here because the base class + // will always trigger the delegate on native fullscreen notifications + // and we don't want to double notify. } func exit() { @@ -84,14 +118,13 @@ class NativeFullscreen: FullscreenStyle { window.toggleFullScreen(nil) - // Notify the delegate - delegate?.fullscreenDidChange() + // Note: we don't call our delegate here because the base class + // will always trigger the delegate on native fullscreen notifications + // and we don't want to double notify. } } -class NonNativeFullscreen: FullscreenStyle { - weak var delegate: FullscreenDelegate? - +class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Non-native fullscreen never supports tabs because tabs require // the "titled" style and we don't have it for non-native fullscreen. var supportsTabs: Bool { false } @@ -110,13 +143,8 @@ class NonNativeFullscreen: FullscreenStyle { var hideMenu: Bool = true } - private let window: NSWindow private var savedState: SavedState? - required init?(_ window: NSWindow) { - self.window = window - } - func enter() { // If we are in fullscreen we don't do it again. guard !isFullscreen else { return } @@ -170,6 +198,10 @@ class NonNativeFullscreen: FullscreenStyle { // Being untitled let's our content take up the full frame. window.styleMask.remove(.titled) + // We dont' want the non-native fullscreen window to be resizable + // from the edges. + window.styleMask.remove(.resizable) + // Focus window window.makeKeyAndOrderFront(nil) @@ -187,8 +219,12 @@ class NonNativeFullscreen: FullscreenStyle { guard isFullscreen else { return } guard let savedState else { return } - // Remove all our notifications - NotificationCenter.default.removeObserver(self) + // Remove all our notifications. We remove them one by one because + // we don't want to remove the observers that our superclass sets. + let center = NotificationCenter.default + center.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window) + center.removeObserver(self, name: NSWindow.didResignMainNotification, object: window) + center.removeObserver(self, name: NSWindow.didChangeScreenNotification, object: window) // Unhide our elements if savedState.dock { diff --git a/nix/devShell.nix b/nix/devShell.nix index 7f0e206b7..b2502d92d 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -159,11 +159,20 @@ in # it to be "portable" across the system. LD_LIBRARY_PATH = lib.makeLibraryPath rpathLibs; - # On Linux we need to setup the environment so that all GTK data - # is available (namely icons). - shellHook = lib.optionalString stdenv.hostPlatform.isLinux '' - # Minimal subset of env set by wrapGAppsHook4 for icons and global settings - export XDG_DATA_DIRS=$XDG_DATA_DIRS:${hicolor-icon-theme}/share:${gnome.adwaita-icon-theme}/share - export XDG_DATA_DIRS=$XDG_DATA_DIRS:$GSETTINGS_SCHEMAS_PATH # from glib setup hook - ''; + shellHook = + (lib.optionalString stdenv.hostPlatform.isLinux '' + # On Linux we need to setup the environment so that all GTK data + # is available (namely icons). + + # Minimal subset of env set by wrapGAppsHook4 for icons and global settings + export XDG_DATA_DIRS=$XDG_DATA_DIRS:${hicolor-icon-theme}/share:${gnome.adwaita-icon-theme}/share + export XDG_DATA_DIRS=$XDG_DATA_DIRS:$GSETTINGS_SCHEMAS_PATH # from glib setup hook + '') + + (lib.optionalString stdenv.hostPlatform.isDarwin '' + # On macOS, we unset the macOS SDK env vars that Nix sets up because + # we rely on a system installation. Nix only provides a macOS SDK + # and we need iOS too. + unset SDKROOT + unset DEVELOPER_DIR + ''); } diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 55cb4a0f3..162f65500 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-D1SQIlmdP9x1PDgRVOy1qJGmu9osDbuyxGOcFj646N4=" +"sha256-c3MQJG7vwQBOaxHQ8cYP0HxdsLqlgsVmAiT1d7gq6js=" diff --git a/src/Surface.zig b/src/Surface.zig index eef2eb8b3..3e7300d08 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1182,6 +1182,14 @@ pub fn updateConfig( log.warn("failed to notify renderer of config change err={}", .{err}); }; + // If we have a title set then we update our window to have the + // newly configured title. + if (config.title) |title| try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + // Notify the window try self.rt_app.performAction( .{ .surface = self }, @@ -2336,7 +2344,7 @@ pub fn scrollCallback( // If we're scrolling up or down, then send a mouse event. if (self.io.terminal.flags.mouse_event != .none) { - if (y.delta != 0) { + for (0..@abs(y.delta)) |_| { const pos = try self.rt_surface.getCursorPos(); try self.mouseReport(switch (y.direction()) { .up_right => .four, @@ -2344,7 +2352,7 @@ pub fn scrollCallback( }, .press, self.mouse.mods, pos); } - if (x.delta != 0) { + for (0..@abs(x.delta)) |_| { const pos = try self.rt_surface.getCursorPos(); try self.mouseReport(switch (x.direction()) { .up_right => .six, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index e793615d5..bf4c44ad0 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -724,7 +724,7 @@ pub const Surface = struct { /// Set the shape of the cursor. fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { if ((comptime builtin.target.isDarwin()) and - !internal_os.macosVersionAtLeast(13, 0, 0)) + !internal_os.macos.isAtLeastVersion(13, 0, 0)) { // We only set our cursor if we're NOT on Mac, or if we are then the // macOS version is >= 13 (Ventura). On prior versions, glfw crashes diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 0cee1938e..6329644be 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -14,6 +14,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); +const build_config = @import("../../build_config.zig"); const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); const input = @import("../../input.zig"); @@ -99,9 +100,13 @@ 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 - _ = internal_os.setenv("GDK_DISABLE", "gles-api"); + // 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"); } else if (version.atLeast(4, 14, 0)) { // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. @@ -110,11 +115,31 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // reassess... // // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles"); + // + // 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"); + + // Wayland-EGL on GTK 4.14 causes "Failed to create EGL context" errors. + // This can be fixed by forcing the backend to prefer X11. This issue + // appears to be fixed in GTK 4.16 but I wasn't able to bisect why. + // The "*" at the end says that if X11 fails, try all remaining + // backends. + _ = internal_os.setenv("GDK_BACKEND", "x11,*"); + } 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 + // We need to export GSK_RENDERER to opengl because GTK uses ngl by + // default after 4.14 _ = internal_os.setenv("GSK_RENDERER", "opengl"); } @@ -181,7 +206,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } - const default_id = "com.mitchellh.ghostty"; + const default_id = comptime build_config.bundle_id; break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; }; @@ -377,22 +402,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { if (config.@"initial-window") c.g_application_activate(gapp); - // Register for dbus events - if (c.g_application_get_dbus_connection(gapp)) |dbus_connection| { - _ = c.g_dbus_connection_signal_subscribe( - dbus_connection, - null, - "org.freedesktop.portal.Settings", - "SettingChanged", - "/org/freedesktop/portal/desktop", - "org.freedesktop.appearance", - c.G_DBUS_SIGNAL_FLAGS_MATCH_ARG0_NAMESPACE, - >kNotifyColorScheme, - core_app, - null, - ); - } - // Internally, GTK ensures that only one instance of this provider exists in the provider list // for the display. const css_provider = c.gtk_css_provider_new(); @@ -401,12 +410,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { @ptrCast(css_provider), c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION + 3, ); - loadRuntimeCss(core_app.alloc, &config, css_provider) catch |err| switch (err) { - error.OutOfMemory => log.warn( - "out of memory loading runtime CSS, no runtime CSS applied", - .{}, - ), - }; return .{ .core_app = core_app, @@ -462,7 +465,7 @@ pub fn performAction( .equalize_splits => self.equalizeSplits(target), .goto_split => self.gotoSplit(target, value), .open_config => try configpkg.edit.open(self.core_app.alloc), - .config_change => self.configChange(value.config), + .config_change => self.configChange(target, value.config), .reload_config => try self.reloadConfig(target, value), .inspector => self.controlInspector(target, value), .desktop_notification => self.showDesktopNotification(target, value), @@ -818,18 +821,38 @@ fn showDesktopNotification( c.g_application_send_notification(g_app, n.body.ptr, notification); } -fn configChange(self: *App, new_config: *const Config) void { - _ = new_config; +fn configChange( + self: *App, + target: apprt.Target, + 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 => {}, - self.syncConfigChanges() catch |err| { - log.warn("error handling configuration changes err={}", .{err}); - }; + .app => { + // We clone (to take ownership) and update our configuration. + if (new_config.clone(self.core_app.alloc)) |config_clone| { + self.config.deinit(); + self.config = config_clone; + } else |err| { + log.warn("error cloning configuration err={}", .{err}); + } - if (adwaita.enabled(&self.config)) { - if (self.core_app.focusedSurface()) |core_surface| { - const surface = core_surface.rt_surface; - if (surface.container.window()) |window| window.onConfigReloaded(); - } + self.syncConfigChanges() catch |err| { + log.warn("error handling configuration changes err={}", .{err}); + }; + + // App changes needs to show a toast that our configuration + // has reloaded. + if (adwaita.enabled(&self.config)) { + if (self.core_app.focusedSurface()) |core_surface| { + const surface = core_surface.rt_surface; + if (surface.container.window()) |window| window.onConfigReloaded(); + } + } + }, } } @@ -870,7 +893,7 @@ fn syncConfigChanges(self: *App) !void { // Load our runtime CSS. If this fails then our window is just stuck // with the old CSS but we don't want to fail the entire sync operation. - loadRuntimeCss(self.core_app.alloc, &self.config, self.css_provider) catch |err| switch (err) { + self.loadRuntimeCss() catch |err| switch (err) { error.OutOfMemory => log.warn( "out of memory loading runtime CSS, no runtime CSS applied", .{}, @@ -934,15 +957,14 @@ fn syncActionAccelerator( } fn loadRuntimeCss( - alloc: Allocator, - config: *const Config, - provider: *c.GtkCssProvider, + self: *const App, ) Allocator.Error!void { - var stack_alloc = std.heap.stackFallback(4096, alloc); + var stack_alloc = std.heap.stackFallback(4096, self.core_app.alloc); var buf = std.ArrayList(u8).init(stack_alloc.get()); defer buf.deinit(); const writer = buf.writer(); + const config: *const Config = &self.config; const window_theme = config.@"window-theme"; const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; const headerbar_background = config.background; @@ -1005,7 +1027,7 @@ fn loadRuntimeCss( // Clears any previously loaded CSS from this provider c.gtk_css_provider_load_from_data( - provider, + self.css_provider, buf.items.ptr, @intCast(buf.items.len), ); @@ -1054,11 +1076,17 @@ 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. + self.initDbus(); + // Setup our menu items self.initActions(); 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. @@ -1092,6 +1120,26 @@ pub fn run(self: *App) !void { } } +fn initDbus(self: *App) void { + const dbus = c.g_application_get_dbus_connection(@ptrCast(self.app)) orelse { + log.warn("unable to get dbus connection, not setting up events", .{}); + return; + }; + + _ = c.g_dbus_connection_signal_subscribe( + dbus, + null, + "org.freedesktop.portal.Settings", + "SettingChanged", + "/org/freedesktop/portal/desktop", + "org.freedesktop.appearance", + c.G_DBUS_SIGNAL_FLAGS_MATCH_ARG0_NAMESPACE, + >kNotifyColorScheme, + self, + null, + ); +} + // This timeout function is started when no surfaces are open. It can be // cancelled if a new surface is opened before the timer expires. pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean { @@ -1372,7 +1420,7 @@ fn gtkNotifyColorScheme( parameters: ?*c.GVariant, user_data: ?*anyopaque, ) callconv(.C) void { - const core_app: *CoreApp = @ptrCast(@alignCast(user_data orelse { + const self: *App = @ptrCast(@alignCast(user_data orelse { log.err("style change notification: userdata is null", .{}); return; })); @@ -1404,9 +1452,20 @@ fn gtkNotifyColorScheme( else .light; - for (core_app.surfaces.items) |surface| { - surface.core_surface.colorSchemeCallback(color_scheme) catch |err| { - log.err("unable to tell surface about color scheme change: {}", .{err}); + self.colorSchemeEvent(color_scheme); +} + +fn colorSchemeEvent( + self: *App, + scheme: apprt.ColorScheme, +) void { + self.core_app.colorSchemeEvent(self, scheme) catch |err| { + log.err("error updating app color scheme err={}", .{err}); + }; + + for (self.core_app.surfaces.items) |surface| { + surface.core_surface.colorSchemeCallback(scheme) catch |err| { + log.err("unable to tell surface about color scheme change err={}", .{err}); }; } } diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index 6d4cda21b..3ff52908e 100644 --- a/src/apprt/gtk/ConfigErrorsWindow.zig +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -3,6 +3,7 @@ const ConfigErrors = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; +const build_config = @import("../../build_config.zig"); const configpkg = @import("../../config.zig"); const Config = configpkg.Config; @@ -53,7 +54,7 @@ fn init(self: *ConfigErrors, app: *App) !void { c.gtk_window_set_title(gtk_window, "Configuration Errors"); 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, "com.mitchellh.ghostty"); + c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); // Set some state diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index aef67b308..9a361c228 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -5,6 +5,7 @@ const Surface = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; +const build_config = @import("../../build_config.zig"); const configpkg = @import("../../config.zig"); const apprt = @import("../../apprt.zig"); const font = @import("../../font/main.zig"); @@ -1149,7 +1150,7 @@ pub fn showDesktopNotification( defer c.g_object_unref(notification); c.g_notification_set_body(notification, body.ptr); - const icon = c.g_themed_icon_new("com.mitchellh.ghostty"); + const icon = c.g_themed_icon_new(build_config.bundle_id); defer c.g_object_unref(icon); c.g_notification_set_icon(notification, icon); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index e220ac03b..23265c101 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -103,7 +103,7 @@ pub fn init(self: *Window, app: *App) !void { // to disable this so that terminal programs can capture F10 (such as htop) c.gtk_window_set_handle_menubar_accel(gtk_window, 0); - c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); + c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); // Apply class to color headerbar if window-theme is set to `ghostty` and // GTK version is before 4.16. The conditional is because above 4.16 diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig index db987cbea..07baa65c6 100644 --- a/src/apprt/gtk/gresource.zig +++ b/src/apprt/gtk/gresource.zig @@ -13,39 +13,39 @@ const icons = [_]struct { }{ .{ .alias = "16x16", - .source = "16x16", + .source = "16", }, .{ .alias = "16x16@2", - .source = "16x16@2x@2x", + .source = "16@2x", }, .{ .alias = "32x32", - .source = "32x32", + .source = "32", }, .{ .alias = "32x32@2", - .source = "32x32@2x@2x", + .source = "32@2x", }, .{ .alias = "128x128", - .source = "128x128", + .source = "128", }, .{ .alias = "128x128@2", - .source = "128x128@2x@2x", + .source = "128@2x", }, .{ .alias = "256x256", - .source = "256x256", + .source = "256", }, .{ .alias = "256x256@2", - .source = "256x256@2x@2x", + .source = "256@2x", }, .{ .alias = "512x512", - .source = "512x512", + .source = "512", }, }; diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index f5bdf8a24..119e20a6c 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const build_config = @import("../../build_config.zig"); const App = @import("App.zig"); const Surface = @import("Surface.zig"); const TerminalWindow = @import("Window.zig"); @@ -141,7 +142,7 @@ const Window = struct { self.window = gtk_window; 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, "com.mitchellh.ghostty"); + c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); // Initialize our imgui widget try self.imgui_widget.init(); diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index e576b9c3a..587d276c1 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -42,7 +42,7 @@ pub fn create(b: *std.Build, opts: Options) *MetallibStep { b, b.fmt("metal {s}", .{opts.name}), ); - run_ir.addArgs(&.{ "xcrun", "-sdk", sdk, "metal", "-o" }); + run_ir.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metal", "-o" }); const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name})); run_ir.addArgs(&.{"-c"}); for (opts.sources) |source| run_ir.addFileArg(source); @@ -62,7 +62,7 @@ pub fn create(b: *std.Build, opts: Options) *MetallibStep { b, b.fmt("metallib {s}", .{opts.name}), ); - run_lib.addArgs(&.{ "xcrun", "-sdk", sdk, "metallib", "-o" }); + run_lib.addArgs(&.{ "/usr/bin/xcrun", "-sdk", sdk, "metallib", "-o" }); const output_lib = run_lib.addOutputFileArg(b.fmt("{s}.metallib", .{opts.name})); run_lib.addFileArg(output_ir); run_lib.step.dependOn(&run_ir.step); diff --git a/src/build_config.zig b/src/build_config.zig index 715552e03..1448f9de5 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -103,6 +103,20 @@ pub const app_runtime: apprt.Runtime = config.app_runtime; pub const font_backend: font.Backend = config.font_backend; pub const renderer: rendererpkg.Impl = config.renderer; +/// The bundle ID for the app. This is used in many places and is currently +/// hardcoded here. We could make this configurable in the future if there +/// is a reason to do so. +/// +/// On macOS, this must match the App bundle ID. We can get that dynamically +/// via an API but I don't want to pay the cost of that at runtime. +/// +/// On GTK, this should match the various folders with resources. +/// +/// There are many places that don't use this variable so simply swapping +/// this variable is NOT ENOUGH to change the bundle ID. I just wanted to +/// avoid it in Zig coe as much as possible. +pub const bundle_id = "com.mitchellh.ghostty"; + /// True if we should have "slow" runtime safety checks. The initial motivation /// for this was terminal page/pagelist integrity checks. These were VERY /// slow but very thorough. But they made it so slow that the terminal couldn't diff --git a/src/cli/args.zig b/src/cli/args.zig index 3e378f347..454ca360e 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -104,7 +104,7 @@ pub fn parse( try dst._diagnostics.append(arena_alloc, .{ .key = try arena_alloc.dupeZ(u8, arg), .message = "invalid field", - .location = diags.Location.fromIter(iter), + .location = try diags.Location.fromIter(iter, arena_alloc), }); continue; @@ -145,7 +145,7 @@ pub fn parse( try dst._diagnostics.append(arena_alloc, .{ .key = try arena_alloc.dupeZ(u8, key), .message = message, - .location = diags.Location.fromIter(iter), + .location = try diags.Location.fromIter(iter, arena_alloc), }); }; } @@ -1140,7 +1140,7 @@ pub fn ArgsIterator(comptime Iterator: type) type { } /// Returns a location for a diagnostic message. - pub fn location(self: *const Self) ?diags.Location { + pub fn location(self: *const Self, _: Allocator) error{}!?diags.Location { return .{ .cli = self.index }; } }; @@ -1262,12 +1262,15 @@ pub fn LineIterator(comptime ReaderType: type) type { } /// Returns a location for a diagnostic message. - pub fn location(self: *const Self) ?diags.Location { + pub fn location( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!?diags.Location { // If we have no filepath then we have no location. if (self.filepath.len == 0) return null; return .{ .file = .{ - .path = self.filepath, + .path = try alloc.dupe(u8, self.filepath), .line = self.line, } }; } diff --git a/src/cli/diagnostics.zig b/src/cli/diagnostics.zig index e4d390c03..40fed3001 100644 --- a/src/cli/diagnostics.zig +++ b/src/cli/diagnostics.zig @@ -34,6 +34,14 @@ pub const Diagnostic = struct { try writer.print("{s}", .{self.message}); } + + pub fn clone(self: *const Diagnostic, alloc: Allocator) Allocator.Error!Diagnostic { + return .{ + .location = try self.location.clone(alloc), + .key = try alloc.dupeZ(u8, self.key), + .message = try alloc.dupeZ(u8, self.message), + }; + } }; /// The possible locations for a diagnostic message. This is used @@ -48,7 +56,7 @@ pub const Location = union(enum) { pub const Key = @typeInfo(Location).Union.tag_type.?; - pub fn fromIter(iter: anytype) Location { + pub fn fromIter(iter: anytype, alloc: Allocator) Allocator.Error!Location { const Iter = t: { const T = @TypeOf(iter); break :t switch (@typeInfo(T)) { @@ -59,7 +67,20 @@ pub const Location = union(enum) { }; if (!@hasDecl(Iter, "location")) return .none; - return iter.location() orelse .none; + return (try iter.location(alloc)) orelse .none; + } + + pub fn clone(self: *const Location, alloc: Allocator) Allocator.Error!Location { + return switch (self.*) { + .none, + .cli, + => self.*, + + .file => |v| .{ .file = .{ + .path = try alloc.dupe(u8, v.path), + .line = v.line, + } }, + }; } }; @@ -88,11 +109,45 @@ pub const DiagnosticList = struct { // We specifically want precompute for libghostty. .lib => true, }; + const Precompute = if (precompute_enabled) struct { messages: std.ArrayListUnmanaged([:0]const u8) = .{}, + + pub fn clone( + self: *const Precompute, + alloc: Allocator, + ) Allocator.Error!Precompute { + var result: Precompute = .{}; + try result.messages.ensureTotalCapacity(alloc, self.messages.items.len); + for (self.messages.items) |msg| { + result.messages.appendAssumeCapacity( + try alloc.dupeZ(u8, msg), + ); + } + return result; + } } else void; + const precompute_init: Precompute = if (precompute_enabled) .{} else {}; + pub fn clone( + self: *const DiagnosticList, + alloc: Allocator, + ) Allocator.Error!DiagnosticList { + var result: DiagnosticList = .{}; + + try result.list.ensureTotalCapacity(alloc, self.list.items.len); + for (self.list.items) |*diag| result.list.appendAssumeCapacity( + try diag.clone(alloc), + ); + + if (comptime precompute_enabled) { + result.precompute = try self.precompute.clone(alloc); + } + + return result; + } + pub fn append( self: *DiagnosticList, alloc: Allocator, diff --git a/src/config/Config.zig b/src/config/Config.zig index 55cd55606..fa531dc7e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -527,6 +527,10 @@ palette: Palette = .{}, /// The opacity level (opposite of transparency) of the background. A value of /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 /// or greater than 1 will be clamped to the nearest valid value. +/// +/// On macOS, background opacity is disabled when the terminal enters native +/// fullscreen. This is because the background becomes gray and it can cause +/// widgets to show through which isn't generally desirable. @"background-opacity": f64 = 1.0, /// A positive value enables blurring of the background when background-opacity @@ -664,9 +668,6 @@ link: RepeatableLink = .{}, /// does not apply to tabs, splits, etc. However, this setting will apply to all /// new windows, not just the first one. /// -/// On macOS, this always creates the window in native fullscreen. Non-native -/// fullscreen is not currently supported with this setting. -/// /// On macOS, this setting does not work if window-decoration is set to /// "false", because native fullscreen on macOS requires window decorations /// to be set. @@ -675,6 +676,12 @@ fullscreen: bool = false, /// The title Ghostty will use for the window. This will force the title of the /// window to be this title at all times and Ghostty will ignore any set title /// escape sequences programs (such as Neovim) may send. +/// +/// This configuration can be reloaded at runtime. If it is set, the title +/// will update for all windows. If it is unset, the next title change escape +/// sequence will be honored but previous changes will not retroactively +/// be set. This latter case may require you restart programs such as neovim +/// to get the new title. title: ?[:0]const u8 = null, /// The setting that will change the application class value. @@ -1793,6 +1800,10 @@ _diagnostics: cli.DiagnosticList = .{}, /// determine if a conditional configuration matches or not. _conditional_state: conditional.State = .{}, +/// The conditional keys that are used at any point during the configuration +/// loading. This is used to speed up the conditional evaluation process. +_conditional_set: std.EnumSet(conditional.Key) = .{}, + /// The steps we can use to reload the configuration after it has been loaded /// without reopening the files. This is used in very specific cases such /// as loadTheme which has more details on why. @@ -1809,9 +1820,10 @@ pub fn deinit(self: *Config) void { /// Load the configuration according to the default rules: /// /// 1. Defaults -/// 2. XDG Config File -/// 3. CLI flags -/// 4. Recursively defined configuration files +/// 2. XDG config dir +/// 3. "Application Support" directory (macOS only) +/// 4. CLI flags +/// 5. Recursively defined configuration files /// pub fn load(alloc_gpa: Allocator) !Config { var result = try default(alloc_gpa); @@ -2394,25 +2406,37 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { try self.expandPaths(std.fs.path.dirname(path).?); } -/// Load the configuration from the default configuration file. The default -/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. -pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { - const config_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); - defer alloc.free(config_path); - - self.loadFile(alloc, config_path) catch |err| switch (err) { +/// Load optional configuration file from `path`. All errors are ignored. +pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void { + self.loadFile(alloc, path) catch |err| switch (err) { error.FileNotFound => std.log.info( - "homedir config not found, not loading path={s}", - .{config_path}, + "optional config file not found, not loading path={s}", + .{path}, ), - else => std.log.warn( - "error reading config file, not loading err={} path={s}", - .{ err, config_path }, + "error reading optional config file, not loading err={} path={s}", + .{ err, path }, ), }; } +/// Load configurations from the default configuration files. The default +/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. +/// +/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config` +/// is also loaded. +pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { + const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); + defer alloc.free(xdg_path); + self.loadOptionalFile(alloc, xdg_path); + + if (comptime builtin.os.tag == .macos) { + const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); + defer alloc.free(app_support_path); + self.loadOptionalFile(alloc, app_support_path); + } +} + /// Load and parse the CLI args. pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { @@ -2444,7 +2468,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // First, we add an artificial "-e" so that if we // replay the inputs to rebuild the config (i.e. if // a theme is set) then we will get the same behavior. - try self._replay_steps.append(arena_alloc, .{ .arg = "-e" }); + try self._replay_steps.append(arena_alloc, .@"-e"); // Next, take all remaining args and use that to build up // a command to execute. @@ -2552,6 +2576,24 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { const cwd = std.fs.cwd(); + // We need to insert all of our loaded config-file values + // PRIOR to the "-e" in our replay steps, since everything + // after "-e" becomes an "initial-command". To do this, we + // dupe the values if we find it. + var replay_suffix = std.ArrayList(Replay.Step).init(alloc_gpa); + defer replay_suffix.deinit(); + for (self._replay_steps.items, 0..) |step, i| if (step == .@"-e") { + // We don't need to clone the steps because they should + // all be allocated in our arena and we're keeping our + // arena. + try replay_suffix.appendSlice(self._replay_steps.items[i..]); + + // Remove our old values. Again, don't need to free any + // memory here because its all part of our arena. + self._replay_steps.shrinkRetainingCapacity(i); + break; + }; + // We must use a while below and not a for(items) because we // may add items to the list while iterating for recursive // config-file entries. @@ -2603,6 +2645,14 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { try self.loadIter(alloc_gpa, &iter); try self.expandPaths(std.fs.path.dirname(path).?); } + + // If we have a suffix, add that back. + if (replay_suffix.items.len > 0) { + try self._replay_steps.appendSlice( + arena_alloc, + replay_suffix.items, + ); + } } /// Change the state of conditionals and reload the configuration @@ -2610,6 +2660,10 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { /// on the new state. The caller must free the old configuration if they /// wish. /// +/// This returns null if the conditional state would result in no changes +/// to the configuration. In this case, the caller can continue to use +/// the existing configuration or clone if they want a copy. +/// /// This doesn't re-read any files, it just re-applies the same /// configuration with the new conditional state. Importantly, this means /// that if you change the conditional state and the user in the interim @@ -2618,7 +2672,30 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { pub fn changeConditionalState( self: *const Config, new: conditional.State, -) !Config { +) !?Config { + // If the conditional state between the old and new is the same, + // then we don't need to do anything. + relevant: { + inline for (@typeInfo(conditional.Key).Enum.fields) |field| { + const key: conditional.Key = @field(conditional.Key, field.name); + + // Conditional set contains the keys that this config uses. So we + // only continue if we use this key. + if (self._conditional_set.contains(key) and !equalField( + @TypeOf(@field(self._conditional_state, field.name)), + @field(self._conditional_state, field.name), + @field(new, field.name), + )) { + break :relevant; + } + } + + // If we got here, then we didn't find any differences between + // the old and new conditional state that would affect the + // configuration. + return null; + } + // Create our new configuration const alloc_gpa = self._arena.?.child_allocator; var new_config = try self.cloneEmpty(alloc_gpa); @@ -2703,39 +2780,46 @@ fn loadTheme(self: *Config, theme: Theme) !void { try new_config.loadIter(alloc_gpa, &iter); // Setup our replay to be conditional. - for (new_config._replay_steps.items) |*item| switch (item.*) { - .expand => {}, + conditional: for (new_config._replay_steps.items) |*item| { + switch (item.*) { + .expand => {}, - // Change our arg to be conditional on our theme. - .arg => |v| { - const alloc_arena = new_config._arena.?.allocator(); - const conds = try alloc_arena.alloc(Conditional, 1); - conds[0] = .{ - .key = .theme, - .op = .eq, - .value = @tagName(self._conditional_state.theme), - }; - item.* = .{ .conditional_arg = .{ - .conditions = conds, - .arg = v, - } }; - }, + // If we see "-e" then we do NOT make the following arguments + // conditional since they are supposed to be part of the + // initial command. + .@"-e" => break :conditional, - .conditional_arg => |v| { - const alloc_arena = new_config._arena.?.allocator(); - const conds = try alloc_arena.alloc(Conditional, v.conditions.len + 1); - conds[0] = .{ - .key = .theme, - .op = .eq, - .value = @tagName(self._conditional_state.theme), - }; - @memcpy(conds[1..], v.conditions); - item.* = .{ .conditional_arg = .{ - .conditions = conds, - .arg = v.arg, - } }; - }, - }; + // Change our arg to be conditional on our theme. + .arg => |v| { + const alloc_arena = new_config._arena.?.allocator(); + const conds = try alloc_arena.alloc(Conditional, 1); + conds[0] = .{ + .key = .theme, + .op = .eq, + .value = @tagName(self._conditional_state.theme), + }; + item.* = .{ .conditional_arg = .{ + .conditions = conds, + .arg = v, + } }; + }, + + .conditional_arg => |v| { + const alloc_arena = new_config._arena.?.allocator(); + const conds = try alloc_arena.alloc(Conditional, v.conditions.len + 1); + conds[0] = .{ + .key = .theme, + .op = .eq, + .value = @tagName(self._conditional_state.theme), + }; + @memcpy(conds[1..], v.conditions); + item.* = .{ .conditional_arg = .{ + .conditions = conds, + .arg = v.arg, + } }; + }, + } + } // Replay our previous inputs so that we can override values // from the theme. @@ -2765,6 +2849,9 @@ pub fn finalize(self: *Config) !void { // This setting doesn't make sense with different light/dark themes // because it'll force the theme based on the Ghostty theme. if (self.@"window-theme" == .auto) self.@"window-theme" = .system; + + // Mark that we use a conditional theme + self._conditional_set.insert(.theme); } } @@ -2924,10 +3011,12 @@ pub fn parseManuallyHook( arg: []const u8, iter: anytype, ) !bool { - // Keep track of our input args no matter what.. - try self._replay_steps.append(alloc, .{ .arg = try alloc.dupe(u8, arg) }); - if (std.mem.eql(u8, arg, "-e")) { + // Add the special -e marker. This prevents: + // (1) config-file from adding args to the end (see #2908) + // (2) dark/light theme from making this conditional + try self._replay_steps.append(alloc, .@"-e"); + // Build up the command. We don't clean this up because we take // ownership in our allocator. var command = std.ArrayList(u8).init(alloc); @@ -2941,7 +3030,7 @@ pub fn parseManuallyHook( if (command.items.len == 0) { try self._diagnostics.append(alloc, .{ - .location = cli.Location.fromIter(iter), + .location = try cli.Location.fromIter(iter, alloc), .message = try std.fmt.allocPrintZ( alloc, "missing command after {s}", @@ -2963,6 +3052,12 @@ pub fn parseManuallyHook( return false; } + // Keep track of our input args for replay + try self._replay_steps.append( + alloc, + .{ .arg = try alloc.dupe(u8, arg) }, + ); + // If we didn't find a special case, continue parsing normally return true; } @@ -3016,6 +3111,9 @@ pub fn clone( ); } + // Copy our diagnostics + result._diagnostics = try self._diagnostics.clone(alloc_arena); + // Preserve our replay steps. We copy them exactly to also preserve // the exact conditionals required for some steps. try result._replay_steps.ensureTotalCapacity( @@ -3029,6 +3127,9 @@ pub fn clone( } assert(result._replay_steps.items.len == self._replay_steps.items.len); + // Copy the conditional set + result._conditional_set = self._conditional_set; + return result; } @@ -3224,11 +3325,22 @@ const Replay = struct { arg: []const u8, }, + /// 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" + /// because there are some behaviors unique to this (i.e. + /// we want to keep this at the end for config-file). + /// + /// Note: when "-e" is used, ONLY this is present and + /// not an additional "arg" with "-e" value. + @"-e", + fn clone( self: Step, alloc: Allocator, ) Allocator.Error!Step { return switch (self) { + .@"-e" => self, .arg => |v| .{ .arg = try alloc.dupe(u8, v) }, .expand => |v| .{ .expand = try alloc.dupe(u8, v) }, .conditional_arg => |v| conditional: { @@ -3264,10 +3376,6 @@ const Replay = struct { log.warn("error expanding paths err={}", .{err}); }, - .arg => |arg| { - return arg; - }, - .conditional_arg => |v| conditional: { // All conditions must match. for (v.conditions) |cond| { @@ -3278,6 +3386,9 @@ const Replay = struct { return v.arg; }, + + .arg => |arg| return arg, + .@"-e" => return "-e", } } } @@ -4560,17 +4671,33 @@ pub const RepeatableLink = struct { } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const Self, alloc: Allocator) error{}!Self { - _ = self; - _ = alloc; - return .{}; + pub fn clone( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Self { + // Note: we don't do any errdefers below since the allocation + // is expected to be arena allocated. + + var list = try std.ArrayListUnmanaged(inputpkg.Link).initCapacity( + alloc, + self.links.items.len, + ); + for (self.links.items) |item| { + const copy = try item.clone(alloc); + list.appendAssumeCapacity(copy); + } + + return .{ .links = list }; } /// Compare if two of our value are requal. Required by Config. pub fn equal(self: Self, other: Self) bool { - _ = self; - _ = other; - return true; + const itemsA = self.links.items; + const itemsB = other.links.items; + if (itemsA.len != itemsB.len) return false; + for (itemsA, itemsB) |*a, *b| { + if (!a.equal(b)) return false; + } else return true; } /// Used by Formatter @@ -5258,14 +5385,13 @@ test "clone preserves conditional state" { var a = try Config.default(alloc); defer a.deinit(); - var b = try a.changeConditionalState(.{ .theme = .dark }); - defer b.deinit(); - try testing.expectEqual(.dark, b._conditional_state.theme); - var dest = try b.clone(alloc); + a._conditional_state.theme = .dark; + try testing.expectEqual(.dark, a._conditional_state.theme); + var dest = try a.clone(alloc); defer dest.deinit(); // Should have no changes - var it = b.changeIterator(&dest); + var it = a.changeIterator(&dest); try testing.expectEqual(@as(?Key, null), it.next()); // Should have the same conditional state @@ -5315,7 +5441,7 @@ test "clone can then change conditional state" { try cfg_light.loadIter(alloc, &it); try cfg_light.finalize(); - var cfg_dark = try cfg_light.changeConditionalState(.{ .theme = .dark }); + var cfg_dark = (try cfg_light.changeConditionalState(.{ .theme = .dark })).?; defer cfg_dark.deinit(); try testing.expectEqual(Color{ @@ -5332,7 +5458,7 @@ test "clone can then change conditional state" { .b = 0xEE, }, cfg_clone.background); - var cfg_light2 = try cfg_clone.changeConditionalState(.{ .theme = .light }); + var cfg_light2 = (try cfg_clone.changeConditionalState(.{ .theme = .light })).?; defer cfg_light2.deinit(); try testing.expectEqual(Color{ .r = 0xFF, @@ -5341,6 +5467,25 @@ test "clone can then change conditional state" { }, cfg_light2.background); } +test "clone preserves conditional set" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + "--window-theme=auto", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var clone1 = try cfg.clone(alloc); + defer clone1.deinit(); + + try testing.expect(clone1._conditional_set.contains(.theme)); +} + test "changed" { const testing = std.testing; const alloc = testing.allocator; @@ -5355,6 +5500,44 @@ test "changed" { try testing.expect(!source.changed(&dest, .@"font-size")); } +test "changeConditionalState ignores irrelevant changes" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=foo", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expect(try cfg.changeConditionalState( + .{ .theme = .dark }, + ) == null); + } +} + +test "changeConditionalState applies relevant changes" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var cfg2 = (try cfg.changeConditionalState(.{ .theme = .dark })).?; + defer cfg2.deinit(); + + try testing.expect(cfg2._conditional_set.contains(.theme)); + } +} test "theme loading" { const testing = std.testing; const alloc = testing.allocator; @@ -5386,6 +5569,9 @@ test "theme loading" { .g = 0x3A, .b = 0xBC, }, cfg.background); + + // Not a conditional theme + try testing.expect(!cfg._conditional_set.contains(.theme)); } test "theme loading preserves conditional state" { @@ -5534,7 +5720,7 @@ test "theme loading correct light/dark" { try cfg.loadIter(alloc, &it); try cfg.finalize(); - var new = try cfg.changeConditionalState(.{ .theme = .dark }); + var new = (try cfg.changeConditionalState(.{ .theme = .dark })).?; defer new.deinit(); try testing.expectEqual(Color{ .r = 0xEE, @@ -5561,3 +5747,22 @@ test "theme specifying light/dark changes window-theme from auto" { try testing.expect(cfg.@"window-theme" == .system); } } + +test "theme specifying light/dark sets theme usage in conditional state" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--theme=light:foo,dark:bar", + "--window-theme=auto", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + try testing.expect(cfg.@"window-theme" == .system); + try testing.expect(cfg._conditional_set.contains(.theme)); + } +} diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index ccee41801..065bf6a1d 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -45,10 +45,26 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.idx += 1; return &self.buf.storage[storage_idx]; } + + /// Seek the iterator by a given amount. This will clamp + /// the values to the bounds of the buffer so overflows are + /// not possible. + pub fn seekBy(self: *Iterator, amount: isize) void { + if (amount > 0) { + self.idx +|= @intCast(amount); + } else { + self.idx -|= @intCast(@abs(amount)); + } + } + + /// Reset the iterator back to the first value. + pub fn reset(self: *Iterator) void { + self.idx = 0; + } }; /// Initialize a new circular buffer that can store size elements. - pub fn init(alloc: Allocator, size: usize) !Self { + pub fn init(alloc: Allocator, size: usize) Allocator.Error!Self { const buf = try alloc.alloc(T, size); @memset(buf, default); @@ -56,7 +72,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { .storage = buf, .head = 0, .tail = 0, - .full = false, + .full = size == 0, }; } @@ -67,7 +83,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// Append a single value to the buffer. If the buffer is full, /// an error will be returned. - pub fn append(self: *Self, v: T) !void { + pub fn append(self: *Self, v: T) Allocator.Error!void { if (self.full) return error.OutOfMemory; self.storage[self.head] = v; self.head += 1; @@ -75,6 +91,19 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.full = self.head == self.tail; } + /// Append a slice to the buffer. If the buffer cannot fit the + /// entire slice then an error will be returned. It is up to the + /// caller to rotate the circular buffer if they want to overwrite + /// the oldest data. + pub fn appendSlice( + self: *Self, + slice: []const T, + ) Allocator.Error!void { + const storage = self.getPtrSlice(self.len(), slice.len); + fastmem.copy(T, storage[0], slice[0..storage[0].len]); + fastmem.copy(T, storage[1], slice[storage[0].len..]); + } + /// Clear the buffer. pub fn clear(self: *Self) void { self.head = 0; @@ -91,6 +120,34 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { }; } + /// Get the first (oldest) value in the buffer. + pub fn first(self: Self) ?*T { + // Note: this can be more efficient by not using the + // iterator, but this was an easy way to implement it. + var it = self.iterator(.forward); + return it.next(); + } + + /// Get the last (newest) value in the buffer. + pub fn last(self: Self) ?*T { + // Note: this can be more efficient by not using the + // iterator, but this was an easy way to implement it. + var it = self.iterator(.reverse); + return it.next(); + } + + /// Ensures that there is enough capacity to store amount more + /// items via append. + pub fn ensureUnusedCapacity( + self: *Self, + alloc: Allocator, + amount: usize, + ) Allocator.Error!void { + const new_cap = self.len() + amount; + if (new_cap <= self.capacity()) return; + try self.resize(alloc, new_cap); + } + /// Resize the buffer to the given size (larger or smaller). /// If larger, new values will be set to the default value. pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void { @@ -256,7 +313,7 @@ test { try testing.expectEqual(@as(usize, 0), buf.len()); } -test "append" { +test "CircBuf append" { const testing = std.testing; const alloc = testing.allocator; @@ -273,7 +330,7 @@ test "append" { try testing.expectError(error.OutOfMemory, buf.append(5)); } -test "forward iterator" { +test "CircBuf forward iterator" { const testing = std.testing; const alloc = testing.allocator; @@ -319,7 +376,7 @@ test "forward iterator" { } } -test "reverse iterator" { +test "CircBuf reverse iterator" { const testing = std.testing; const alloc = testing.allocator; @@ -365,7 +422,95 @@ test "reverse iterator" { } } -test "getPtrSlice fits" { +test "CircBuf first/last" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 3); + defer buf.deinit(alloc); + + try buf.append(1); + try buf.append(2); + try buf.append(3); + try testing.expectEqual(3, buf.last().?.*); + try testing.expectEqual(1, buf.first().?.*); +} + +test "CircBuf first/last empty" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 0); + defer buf.deinit(alloc); + + try testing.expect(buf.first() == null); + try testing.expect(buf.last() == null); +} + +test "CircBuf first/last empty with cap" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 3); + defer buf.deinit(alloc); + + try testing.expect(buf.first() == null); + try testing.expect(buf.last() == null); +} + +test "CircBuf append slice" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + try buf.appendSlice("hello"); + { + var it = buf.iterator(.forward); + try testing.expect(it.next().?.* == 'h'); + try testing.expect(it.next().?.* == 'e'); + try testing.expect(it.next().?.* == 'l'); + try testing.expect(it.next().?.* == 'l'); + try testing.expect(it.next().?.* == 'o'); + try testing.expect(it.next() == null); + } +} + +test "CircBuf append slice with wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill the buffer + _ = buf.getPtrSlice(0, buf.capacity()); + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 4), buf.len()); + + // Delete + buf.deleteOldest(2); + try testing.expect(!buf.full); + try testing.expectEqual(@as(usize, 2), buf.len()); + + try buf.appendSlice("AB"); + { + var it = buf.iterator(.forward); + try testing.expect(it.next().?.* == 0); + try testing.expect(it.next().?.* == 0); + try testing.expect(it.next().?.* == 'A'); + try testing.expect(it.next().?.* == 'B'); + try testing.expect(it.next() == null); + } +} + +test "CircBuf getPtrSlice fits" { const testing = std.testing; const alloc = testing.allocator; @@ -379,7 +524,7 @@ test "getPtrSlice fits" { try testing.expectEqual(@as(usize, 11), buf.len()); } -test "getPtrSlice wraps" { +test "CircBuf getPtrSlice wraps" { const testing = std.testing; const alloc = testing.allocator; @@ -435,7 +580,7 @@ test "getPtrSlice wraps" { } } -test "rotateToZero" { +test "CircBuf rotateToZero" { const testing = std.testing; const alloc = testing.allocator; @@ -447,7 +592,7 @@ test "rotateToZero" { try buf.rotateToZero(alloc); } -test "rotateToZero offset" { +test "CircBuf rotateToZero offset" { const testing = std.testing; const alloc = testing.allocator; @@ -471,7 +616,7 @@ test "rotateToZero offset" { try testing.expectEqual(@as(usize, 1), buf.head); } -test "rotateToZero wraps" { +test "CircBuf rotateToZero wraps" { const testing = std.testing; const alloc = testing.allocator; @@ -511,7 +656,7 @@ test "rotateToZero wraps" { } } -test "rotateToZero full no wrap" { +test "CircBuf rotateToZero full no wrap" { const testing = std.testing; const alloc = testing.allocator; @@ -549,7 +694,32 @@ test "rotateToZero full no wrap" { } } -test "resize grow" { +test "CircBuf resize grow from zero" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 0); + defer buf.deinit(alloc); + try testing.expect(buf.full); + + // Resize + try buf.resize(alloc, 2); + try testing.expect(!buf.full); + try testing.expectEqual(@as(usize, 0), buf.len()); + try testing.expectEqual(@as(usize, 2), buf.capacity()); + + try buf.append(1); + try buf.append(2); + + { + const slices = buf.getPtrSlice(0, 2); + try testing.expectEqual(@as(u8, 1), slices[0][0]); + try testing.expectEqual(@as(u8, 2), slices[0][1]); + } +} + +test "CircBuf resize grow" { const testing = std.testing; const alloc = testing.allocator; @@ -582,7 +752,7 @@ test "resize grow" { } } -test "resize shrink" { +test "CircBuf resize shrink" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index ca0ed96e8..e1cd12f00 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -280,6 +280,8 @@ const Kind = enum { // Powerline fonts 0xE0B0, + 0xE0B1, + 0xE0B3, 0xE0B4, 0xE0B6, 0xE0B2, diff --git a/src/font/sprite/Powerline.zig b/src/font/sprite/Powerline.zig index fdb13870b..8a435a3e8 100644 --- a/src/font/sprite/Powerline.zig +++ b/src/font/sprite/Powerline.zig @@ -93,6 +93,11 @@ fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) 0xE0BE, => try self.draw_wedge_triangle(canvas, cp), + // Soft Dividers + 0xE0B1, + 0xE0B3, + => try self.draw_chevron(canvas, cp), + // Half-circles 0xE0B4, 0xE0B6, @@ -107,6 +112,50 @@ fn draw(self: Powerline, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) } } +fn draw_chevron(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void { + const width = self.width; + const height = self.height; + + var p1_x: u32 = 0; + var p1_y: u32 = 0; + var p2_x: u32 = 0; + var p2_y: u32 = 0; + var p3_x: u32 = 0; + var p3_y: u32 = 0; + + + switch (cp) { + 0xE0B1 => { + p1_x = 0; + p1_y = 0; + p2_x = width; + p2_y = height / 2; + p3_x = 0; + p3_y = height; + }, + 0xE0B3 => { + p1_x = width; + p1_y = 0; + p2_x = 0; + p2_y = height / 2; + p3_x = width; + p3_y = height; + }, + + else => unreachable, + + } + + try canvas.triangle_outline(.{ + .p0 = .{ .x = @floatFromInt(p1_x), .y = @floatFromInt(p1_y) }, + .p1 = .{ .x = @floatFromInt(p2_x), .y = @floatFromInt(p2_y) }, + .p2 = .{ .x = @floatFromInt(p3_x), .y = @floatFromInt(p3_y) }, + }, + @floatFromInt(Thickness.light.height(self.thickness)), + .on); + +} + fn draw_wedge_triangle(self: Powerline, canvas: *font.sprite.Canvas, cp: u32) !void { const width = self.width; const height = self.height; @@ -501,6 +550,8 @@ test "all" { 0xE0B6, 0xE0D2, 0xE0D4, + 0xE0B1, + 0xE0B3, }; for (cps) |cp| { var atlas_grayscale = try font.Atlas.init(alloc, 512, .grayscale); diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig index 81f9095b3..3d472538c 100644 --- a/src/font/sprite/canvas.zig +++ b/src/font/sprite/canvas.zig @@ -231,10 +231,35 @@ pub const Canvas = struct { try path.lineTo(t.p1.x, t.p1.y); try path.lineTo(t.p2.x, t.p2.y); try path.close(); - + try ctx.fill(self.alloc, path); } + pub fn triangle_outline(self: *Canvas, t: Triangle(f64), thickness: f64, color: Color) !void { + var ctx: z2d.Context = .{ + .surface = self.sfc, + .pattern = .{ + .opaque_pattern = .{ + .pixel = .{ .alpha8 = .{ .a = @intFromEnum(color) } }, + }, + }, + .line_width = thickness, + .line_cap_mode = .round, + }; + + var path = z2d.Path.init(self.alloc); + defer path.deinit(); + + try path.moveTo(t.p0.x, t.p0.y); + try path.lineTo(t.p1.x, t.p1.y); + try path.lineTo(t.p2.x, t.p2.y); + // try path.close(); + + try ctx.stroke(self.alloc, path); + // try ctx.fill(self.alloc, path); + + } + /// Stroke a line. pub fn line(self: *Canvas, l: Line(f64), thickness: f64, color: Color) !void { var ctx: z2d.Context = .{ diff --git a/src/input/Binding.zig b/src/input/Binding.zig index fa719d981..a467bfc2b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1454,21 +1454,30 @@ pub const Set = struct { }; // If we have any leaders we need to clone them. - var it = result.bindings.iterator(); - while (it.next()) |entry| switch (entry.value_ptr.*) { - // Leaves could have data to clone (i.e. text actions - // contain allocated strings). - .leaf => |*s| s.* = try s.clone(alloc), + { + var it = result.bindings.iterator(); + while (it.next()) |entry| switch (entry.value_ptr.*) { + // Leaves could have data to clone (i.e. text actions + // contain allocated strings). + .leaf => |*s| s.* = try s.clone(alloc), - // Must be deep cloned. - .leader => |*s| { - const ptr = try alloc.create(Set); - errdefer alloc.destroy(ptr); - ptr.* = try s.*.clone(alloc); - errdefer ptr.deinit(alloc); - s.* = ptr; - }, - }; + // Must be deep cloned. + .leader => |*s| { + const ptr = try alloc.create(Set); + errdefer alloc.destroy(ptr); + ptr.* = try s.*.clone(alloc); + errdefer ptr.deinit(alloc); + s.* = ptr; + }, + }; + } + + // We need to clone the action keys in the reverse map since + // they may contain allocated values. + { + var it = result.reverse.keyIterator(); + while (it.next()) |action| action.* = try action.clone(alloc); + } return result; } diff --git a/src/input/Link.zig b/src/input/Link.zig index adc52a270..37b45dbd1 100644 --- a/src/input/Link.zig +++ b/src/input/Link.zig @@ -4,6 +4,8 @@ //! action types. const Link = @This(); +const std = @import("std"); +const Allocator = std.mem.Allocator; const oni = @import("oniguruma"); const Mods = @import("key.zig").Mods; @@ -59,3 +61,19 @@ pub fn oniRegex(self: *const Link) !oni.Regex { null, ); } + +/// Deep clone the link. +pub fn clone(self: *const Link, alloc: Allocator) Allocator.Error!Link { + return .{ + .regex = try alloc.dupe(u8, self.regex), + .action = self.action, + .highlight = self.highlight, + }; +} + +/// Check if two links are equal. +pub fn equal(self: *const Link, other: *const Link) bool { + return std.meta.eql(self.action, other.action) and + std.meta.eql(self.highlight, other.highlight) and + std.mem.eql(u8, self.regex, other.regex); +} diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 071d4d530..b3df80538 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -141,7 +141,7 @@ fn logFn( // Initialize a logger. This is slow to do on every operation // but we shouldn't be logging too much. - const logger = macos.os.Log.create("com.mitchellh.ghostty", @tagName(scope)); + const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); defer logger.release(); logger.log(std.heap.c_allocator, mac_level, format, args); } diff --git a/src/os/macos.zig b/src/os/macos.zig new file mode 100644 index 000000000..53dfd1719 --- /dev/null +++ b/src/os/macos.zig @@ -0,0 +1,118 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); +const assert = std.debug.assert; +const objc = @import("objc"); +const Allocator = std.mem.Allocator; + +/// Verifies that the running macOS system version is at least the given version. +pub fn isAtLeastVersion(major: i64, minor: i64, patch: i64) bool { + comptime assert(builtin.target.isDarwin()); + + const NSProcessInfo = objc.getClass("NSProcessInfo").?; + const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{}); + return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{ + NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch }, + }); +} + +pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed}; + +/// Return the path to the application support directory for Ghostty +/// with the given sub path joined. This allocates the result using the +/// given allocator. +pub fn appSupportDir( + alloc: Allocator, + sub_path: []const u8, +) AppSupportDirError![]u8 { + comptime assert(builtin.target.isDarwin()); + + const NSFileManager = objc.getClass("NSFileManager").?; + const manager = NSFileManager.msgSend( + objc.Object, + objc.sel("defaultManager"), + .{}, + ); + + const url = manager.msgSend( + objc.Object, + objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), + .{ + NSSearchPathDirectory.NSApplicationSupportDirectory, + NSSearchPathDomainMask.NSUserDomainMask, + @as(?*anyopaque, null), + true, + @as(?*anyopaque, null), + }, + ); + + // I don't think this is possible but just in case. + if (url.value == null) return error.AppleAPIFailed; + + // Get the UTF-8 string from the URL. + const path = url.getProperty(objc.Object, "path"); + const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse + return error.AppleAPIFailed; + const app_support_dir = std.mem.sliceTo(c_str, 0); + + return try std.fs.path.join(alloc, &.{ + app_support_dir, + build_config.bundle_id, + sub_path, + }); +} + +pub const SetQosClassError = error{ + // The thread can't have its QoS class changed usually because + // a different pthread API was called that makes it an invalid + // target. + ThreadIncompatible, +}; + +/// Set the QoS class of the running thread. +/// +/// https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon?preferredLanguage=occ +pub fn setQosClass(class: QosClass) !void { + return switch (std.posix.errno(pthread_set_qos_class_self_np( + class, + 0, + ))) { + .SUCCESS => {}, + .PERM => error.ThreadIncompatible, + + // EPERM is the only known error that can happen based on + // the man pages for pthread_set_qos_class_self_np. I haven't + // checked the XNU source code to see if there are other + // possible errors. + else => @panic("unexpected pthread_set_qos_class_self_np error"), + }; +} + +/// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheTaskLevel.html#//apple_ref/doc/uid/TP40013929-CH35-SW1 +pub const QosClass = enum(c_uint) { + user_interactive = 0x21, + user_initiated = 0x19, + default = 0x15, + utility = 0x11, + background = 0x09, + unspecified = 0x00, +}; + +extern "c" fn pthread_set_qos_class_self_np( + qos_class: QosClass, + relative_priority: c_int, +) c_int; + +pub const NSOperatingSystemVersion = extern struct { + major: i64, + minor: i64, + patch: i64, +}; + +pub const NSSearchPathDirectory = enum(c_ulong) { + NSApplicationSupportDirectory = 14, +}; + +pub const NSSearchPathDomainMask = enum(c_ulong) { + NSUserDomainMask = 1, +}; diff --git a/src/os/macos_version.zig b/src/os/macos_version.zig deleted file mode 100644 index e0b21560e..000000000 --- a/src/os/macos_version.zig +++ /dev/null @@ -1,21 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; -const objc = @import("objc"); - -/// Verifies that the running macOS system version is at least the given version. -pub fn macosVersionAtLeast(major: i64, minor: i64, patch: i64) bool { - assert(builtin.target.isDarwin()); - - const NSProcessInfo = objc.getClass("NSProcessInfo").?; - const info = NSProcessInfo.msgSend(objc.Object, objc.sel("processInfo"), .{}); - return info.msgSend(bool, objc.sel("isOperatingSystemAtLeastVersion:"), .{ - NSOperatingSystemVersion{ .major = major, .minor = minor, .patch = patch }, - }); -} - -pub const NSOperatingSystemVersion = extern struct { - major: i64, - minor: i64, - patch: i64, -}; diff --git a/src/os/main.zig b/src/os/main.zig index 22765f546..073129300 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -8,7 +8,6 @@ const file = @import("file.zig"); const flatpak = @import("flatpak.zig"); const homedir = @import("homedir.zig"); const locale = @import("locale.zig"); -const macos_version = @import("macos_version.zig"); const mouse = @import("mouse.zig"); const openpkg = @import("open.zig"); const pipepkg = @import("pipe.zig"); @@ -21,6 +20,7 @@ pub const hostname = @import("hostname.zig"); pub const passwd = @import("passwd.zig"); pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); +pub const macos = @import("macos.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); @@ -37,7 +37,6 @@ pub const freeTmpDir = file.freeTmpDir; pub const isFlatpak = flatpak.isFlatpak; pub const home = homedir.home; pub const ensureLocale = locale.ensureLocale; -pub const macosVersionAtLeast = macos_version.macosVersionAtLeast; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; pub const pipe = pipepkg.pipe; diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 91e355480..cc63889fa 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -4,8 +4,10 @@ pub const Thread = @This(); const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const xev = @import("xev"); const crash = @import("../crash/main.zig"); +const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); @@ -92,6 +94,10 @@ flags: packed struct { /// This is true when the view is visible. This is used to determine /// if we should be rendering or not. visible: bool = true, + + /// This is true when the view is focused. This defaults to true + /// and it is up to the apprt to set the correct value. + focused: bool = true, } = .{}, pub const DerivedConfig = struct { @@ -199,6 +205,9 @@ fn threadMain_(self: *Thread) !void { }; defer crash.sentry.thread_state = null; + // Setup our thread QoS + self.setQosClass(); + // Run our loop start/end callbacks if the renderer cares. const has_loop = @hasDecl(renderer.Renderer, "loopEnter"); if (has_loop) try self.renderer.loopEnter(self); @@ -237,6 +246,36 @@ fn threadMain_(self: *Thread) !void { _ = try self.loop.run(.until_done); } +fn setQosClass(self: *const Thread) void { + // Thread QoS classes are only relevant on macOS. + if (comptime !builtin.target.isDarwin()) return; + + const class: internal_os.macos.QosClass = class: { + // If we aren't visible (our view is fully occluded) then we + // always drop our rendering priority down because it's just + // mostly wasted work. + // + // The renderer itself should be doing this as well (for example + // Metal will stop our DisplayLink) but this also helps with + // general forced updates and CPU usage i.e. a rebuild cells call. + if (!self.flags.visible) break :class .utility; + + // If we're not focused, but we're visible, then we set a higher + // than default priority because framerates still matter but it isn't + // as important as when we're focused. + if (!self.flags.focused) break :class .user_initiated; + + // We are focused and visible, we are the definition of user interactive. + break :class .user_interactive; + }; + + if (internal_os.macos.setQosClass(class)) { + log.debug("thread QoS class set class={}", .{class}); + } else |err| { + log.warn("error setting QoS class err={}", .{err}); + } +} + fn startDrawTimer(self: *Thread) void { // If our renderer doesn't support animations then we never run this. if (!@hasDecl(renderer.Renderer, "hasAnimations")) return; @@ -273,10 +312,16 @@ fn drainMailbox(self: *Thread) !void { switch (message) { .crash => @panic("crash request, crashing intentionally"), - .visible => |v| { + .visible => |v| visible: { + // If our state didn't change we do nothing. + if (self.flags.visible == v) break :visible; + // Set our visible state self.flags.visible = v; + // Visibility affects our QoS class + self.setQosClass(); + // If we became visible then we immediately trigger a draw. // We don't need to update frame data because that should // still be happening. @@ -293,7 +338,16 @@ fn drainMailbox(self: *Thread) !void { // check the visible state themselves to control their behavior. }, - .focus => |v| { + .focus => |v| focus: { + // If our state didn't change we do nothing. + if (self.flags.focused == v) break :focus; + + // Set our state + self.flags.focused = v; + + // Focus affects our QoS class + self.setQosClass(); + // Set it on the renderer try self.renderer.setFocus(v); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 70f972ebe..f8afc801a 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -330,6 +330,96 @@ pub fn deinit(self: *PageList) void { } } +/// Reset the PageList back to an empty state. This is similar to +/// deinit and reinit but it importantly preserves the pointer +/// stability of tracked pins (they're moved to the top-left since +/// all contents are cleared). +/// +/// This can't fail because we always retain at least enough allocated +/// memory to fit the active area. +pub fn reset(self: *PageList) void { + // We need enough pages/nodes to keep our active area. This should + // never fail since we by definition have allocated a page already + // that fits our size but I'm not confident to make that assertion. + const cap = std_capacity.adjust( + .{ .cols = self.cols }, + ) catch @panic("reset: std_capacity.adjust failed"); + assert(cap.rows > 0); // adjust should never return 0 rows + + // The number of pages we need is the number of rows in the active + // area divided by the row capacity of a page. + const page_count = std.math.divCeil( + usize, + self.rows, + cap.rows, + ) catch unreachable; + + // Before resetting our pools we need to free any pages that + // are non-standard size since those were allocated outside + // the pool. + { + const page_alloc = self.pool.pages.arena.child_allocator; + var it = self.pages.first; + while (it) |node| : (it = node.next) { + if (node.data.memory.len > std_size) { + page_alloc.free(node.data.memory); + } + } + } + + // Reset our pools to free as much memory as possible while retaining + // the capacity for at least the minimum number of pages we need. + // The return value is whether memory was reclaimed or not, but in + // either case the pool is left in a valid state. + _ = self.pool.pages.reset(.{ + .retain_with_limit = page_count * PagePool.item_size, + }); + _ = self.pool.nodes.reset(.{ + .retain_with_limit = page_count * NodePool.item_size, + }); + + // Our page pool relies on mmap to zero our page memory. Since we're + // retaining a certain amount of memory, it won't use mmap and won't + // be zeroed. This block zeroes out all the memory in the pool arena. + { + // Note: we only have to do this for the page pool because the + // nodes are always fully overwritten on each allocation. + const page_arena = &self.pool.pages.arena; + var it = page_arena.state.buffer_list.first; + while (it) |node| : (it = node.next) { + // The fully allocated buffer + const alloc_buf = @as([*]u8, @ptrCast(node))[0..node.data]; + + // The buffer minus our header + const BufNode = @TypeOf(page_arena.state.buffer_list).Node; + const data_buf = alloc_buf[@sizeOf(BufNode)..]; + @memset(data_buf, 0); + } + } + + // Initialize our pages. This should not be able to fail since + // we retained the capacity for the minimum number of pages we need. + self.pages, self.page_size = initPages( + &self.pool, + self.cols, + self.rows, + ) catch @panic("initPages failed"); + + // Update all our tracked pins to point to our first page top-left + { + var it = self.tracked_pins.iterator(); + while (it.next()) |entry| { + const p: *Pin = entry.key_ptr.*; + p.node = self.pages.first.?; + p.x = 0; + p.y = 0; + } + } + + // Move our viewport back to the active area since everything is gone. + self.viewport = .active; +} + pub const Clone = struct { /// The top and bottom (inclusive) points of the region to clone. /// The x coordinate is ignored; the full row is always cloned. @@ -2356,7 +2446,11 @@ pub fn countTrackedPins(self: *const PageList) usize { /// Checks if a pin is valid for this pagelist. This is a very slow and /// expensive operation since we traverse the entire linked list in the /// worst case. Only for runtime safety/debug. -fn pinIsValid(self: *const PageList, p: Pin) bool { +pub fn pinIsValid(self: *const PageList, p: Pin) bool { + // This is very slow so we want to ensure we only ever + // call this during slow runtime safety builds. + comptime assert(build_config.slow_runtime_safety); + var it = self.pages.first; while (it) |node| : (it = node.next) { if (node != p.node) continue; @@ -2450,6 +2544,50 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { }; } +pub const EncodeUtf8Options = struct { + /// The start and end points of the dump, both inclusive. The x will + /// be ignored and the full row will always be dumped. + tl: Pin, + br: ?Pin = null, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + unwrap: bool = true, + + /// See Page.EncodeUtf8Options. + cell_map: ?*Page.CellMap = null, +}; + +/// Encode the pagelist to utf8 to the given writer. +/// +/// The writer should be buffered; this function does not attempt to +/// efficiently write and often writes one byte at a time. +/// +/// Note: this is tested using Screen.dumpString. This is a function that +/// predates this and is a thin wrapper around it so the tests all live there. +pub fn encodeUtf8( + self: *const PageList, + writer: anytype, + opts: EncodeUtf8Options, +) anyerror!void { + // We don't currently use self at all. There is an argument that this + // function should live on Pin instead but there is some future we might + // need state on here so... letting it go. + _ = self; + + var page_opts: Page.EncodeUtf8Options = .{ + .unwrap = opts.unwrap, + .cell_map = opts.cell_map, + }; + var iter = opts.tl.pageIterator(.right_down, opts.br); + while (iter.next()) |chunk| { + const page: *const Page = &chunk.node.data; + page_opts.start_y = chunk.start; + page_opts.end_y = chunk.end; + page_opts.preceding = try page.encodeUtf8(writer, page_opts); + } +} + /// Log a debug diagram of the page list to the provided writer. /// /// EXAMPLE: @@ -8191,3 +8329,66 @@ test "PageList resize reflow wrap moves kitty placeholder" { } try testing.expect(it.next() == null); } + +test "PageList reset" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + s.reset(); + try testing.expect(s.viewport == .active); + try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Active area should be the top + try testing.expectEqual(Pin{ + .node = s.pages.first.?, + .y = 0, + .x = 0, + }, s.getTopLeft(.active)); +} + +test "PageList reset across two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // Find a cap that makes it so that rows don't fit on one page. + const rows = 100; + const cap = cap: { + var cap = try std_capacity.adjust(.{ .cols = 50 }); + while (cap.rows >= rows) cap = try std_capacity.adjust(.{ + .cols = cap.cols + 50, + }); + + break :cap cap; + }; + + // Init + var s = try init(alloc, cap.cols, rows, null); + defer s.deinit(); + s.reset(); + try testing.expect(s.viewport == .active); + try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); +} + +test "PageList clears history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(30); + s.reset(); + try testing.expect(s.viewport == .active); + try testing.expect(s.pages.first != null); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Active area should be the top + try testing.expectEqual(Pin{ + .node = s.pages.first.?, + .y = 0, + .x = 0, + }, s.getTopLeft(.active)); +} diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4f3fe270e..ac9483742 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -83,8 +83,8 @@ pub const Dirty = packed struct { /// The cursor position and style. pub const Cursor = struct { // The x/y position within the viewport. - x: size.CellCountInt, - y: size.CellCountInt, + x: size.CellCountInt = 0, + y: size.CellCountInt = 0, /// The visual style of the cursor. This defaults to block because /// it has to default to something, but users of this struct are @@ -249,6 +249,50 @@ pub fn assertIntegrity(self: *const Screen) void { } } +/// Reset the screen according to the logic of a DEC RIS sequence. +/// +/// - Clears the screen and attempts to reclaim memory. +/// - Moves the cursor to the top-left. +/// - Clears any cursor state: style, hyperlink, etc. +/// - Resets the charset +/// - Clears the selection +/// - Deletes all Kitty graphics +/// - Resets Kitty Keyboard settings +/// - Disables protection mode +/// +pub fn reset(self: *Screen) void { + // Reset our pages + self.pages.reset(); + + // The above reset preserves tracked pins so we can still use + // our cursor pin, which should be at the top-left already. + const cursor_pin: *PageList.Pin = self.cursor.page_pin; + assert(cursor_pin.node == self.pages.pages.first.?); + assert(cursor_pin.x == 0); + assert(cursor_pin.y == 0); + const cursor_rac = cursor_pin.rowAndCell(); + self.cursor.deinit(self.alloc); + self.cursor = .{ + .page_pin = cursor_pin, + .page_row = cursor_rac.row, + .page_cell = cursor_rac.cell, + }; + + // Clear kitty graphics + self.kitty_images.delete( + self.alloc, + undefined, // All image deletion doesn't need the terminal + .{ .all = true }, + ); + + // Reset our basic state + self.saved_cursor = null; + self.charset = .{}; + self.kitty_keyboard = .{}; + self.protected_mode = .off; + self.clearSelection(); +} + /// Clone the screen. /// /// This will copy: @@ -2687,95 +2731,15 @@ pub fn promptPath( return .{ .x = to_x - from_x, .y = to_y - from_y }; } -pub const DumpString = struct { - /// The start and end points of the dump, both inclusive. The x will - /// be ignored and the full row will always be dumped. - tl: Pin, - br: ?Pin = null, - - /// If true, this will unwrap soft-wrapped lines. If false, this will - /// dump the screen as it is visually seen in a rendered window. - unwrap: bool = true, -}; - /// Dump the screen to a string. The writer given should be buffered; /// this function does not attempt to efficiently write and generally writes /// one byte at a time. pub fn dumpString( self: *const Screen, writer: anytype, - opts: DumpString, -) !void { - var blank_rows: usize = 0; - var blank_cells: usize = 0; - - var iter = opts.tl.rowIterator(.right_down, opts.br); - while (iter.next()) |row_offset| { - const rac = row_offset.rowAndCell(); - const row = rac.row; - const cells = cells: { - const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); - break :cells cells[0..self.pages.cols]; - }; - - if (!pagepkg.Cell.hasTextAny(cells)) { - blank_rows += 1; - continue; - } - if (blank_rows > 0) { - for (0..blank_rows) |_| try writer.writeByte('\n'); - blank_rows = 0; - } - - if (!row.wrap or !opts.unwrap) { - // If we're not wrapped, we always add a newline. - // If we are wrapped, we only add a new line if we're unwrapping - // soft-wrapped lines. - blank_rows += 1; - } - - if (!row.wrap_continuation or !opts.unwrap) { - // We should also reset blank cell counts at the start of each row - // unless we're unwrapping and this row is a wrap continuation. - blank_cells = 0; - } - - for (cells) |*cell| { - // Skip spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - // If we have a zero value, then we accumulate a counter. We - // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (!cell.hasText()) { - blank_cells += 1; - continue; - } - if (blank_cells > 0) { - try writer.writeByteNTimes(' ', blank_cells); - blank_cells = 0; - } - - switch (cell.content_tag) { - .codepoint => { - try writer.print("{u}", .{cell.content.codepoint}); - }, - - .codepoint_grapheme => { - try writer.print("{u}", .{cell.content.codepoint}); - const cps = row_offset.node.data.lookupGrapheme(cell).?; - for (cps) |cp| { - try writer.print("{u}", .{cp}); - } - }, - - else => unreachable, - } - } - } + opts: PageList.EncodeUtf8Options, +) anyerror!void { + try self.pages.encodeUtf8(writer, opts); } /// You should use dumpString, this is a restricted version mostly for @@ -8504,3 +8468,81 @@ test "Screen: adjustCapacity cursor style ref count" { ); } } + +test "Screen UTF8 cell map with newlines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("A\n\nB\n\nC"); + + var cell_map = Page.CellMap.init(alloc); + defer cell_map.deinit(); + var builder = std.ArrayList(u8).init(alloc); + defer builder.deinit(); + try s.dumpString(builder.writer(), .{ + .tl = s.pages.getTopLeft(.screen), + .br = s.pages.getBottomRight(.screen), + .cell_map = &cell_map, + }); + + try testing.expectEqual(7, builder.items.len); + try testing.expectEqualStrings("A\n\nB\n\nC", builder.items); + try testing.expectEqual(builder.items.len, cell_map.items.len); + try testing.expectEqual(Page.CellMapEntry{ + .x = 0, + .y = 0, + }, cell_map.items[0]); + try testing.expectEqual(Page.CellMapEntry{ + .x = 1, + .y = 0, + }, cell_map.items[1]); + try testing.expectEqual(Page.CellMapEntry{ + .x = 0, + .y = 1, + }, cell_map.items[2]); + try testing.expectEqual(Page.CellMapEntry{ + .x = 0, + .y = 2, + }, cell_map.items[3]); +} + +test "Screen UTF8 cell map with blank prefix" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + s.cursorAbsolute(2, 1); + try s.testWriteString("B"); + + var cell_map = Page.CellMap.init(alloc); + defer cell_map.deinit(); + var builder = std.ArrayList(u8).init(alloc); + defer builder.deinit(); + try s.dumpString(builder.writer(), .{ + .tl = s.pages.getTopLeft(.screen), + .br = s.pages.getBottomRight(.screen), + .cell_map = &cell_map, + }); + + try testing.expectEqualStrings("\n B", builder.items); + try testing.expectEqual(builder.items.len, cell_map.items.len); + try testing.expectEqual(Page.CellMapEntry{ + .x = 0, + .y = 0, + }, cell_map.items[0]); + try testing.expectEqual(Page.CellMapEntry{ + .x = 0, + .y = 1, + }, cell_map.items[1]); + try testing.expectEqual(Page.CellMapEntry{ + .x = 1, + .y = 1, + }, cell_map.items[2]); + try testing.expectEqual(Page.CellMapEntry{ + .x = 2, + .y = 1, + }, cell_map.items[3]); +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f5784b6ab..a11028304 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -193,6 +193,10 @@ pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt, max_scrollback: usize = 10_000, + + /// The default mode state. When the terminal gets a reset, it + /// will revert back to this state. + default_modes: modes.ModePacked = .{}, }; /// Initialize a new terminal. @@ -216,6 +220,10 @@ pub fn init( .right = cols - 1, }, .pwd = std.ArrayList(u8).init(alloc), + .modes = .{ + .values = opts.default_modes, + .default = opts.default_modes, + }, }; } @@ -1955,13 +1963,9 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { } pub fn eraseChars(self: *Terminal, count_req: usize) void { - const count = @max(count_req, 1); - - // Our last index is at most the end of the number of chars we have - // in the current line. - const end = end: { + const count = end: { const remaining = self.cols - self.screen.cursor.x; - var end = @min(remaining, count); + var end = @min(remaining, @max(count_req, 1)); // If our last cell is a wide char then we need to also clear the // cell beyond it since we can't just split a wide char. @@ -1979,7 +1983,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // protected modes. We need to figure out how to make `clearCells` or at // least `clearUnprotectedCells` handle boundary conditions... self.screen.splitCellBoundary(self.screen.cursor.x); - self.screen.splitCellBoundary(end); + self.screen.splitCellBoundary(self.screen.cursor.x + count); // Reset our row's soft-wrap. self.screen.cursorResetWrap(); @@ -1997,7 +2001,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { self.screen.clearCells( &self.screen.cursor.page_pin.node.data, self.screen.cursor.page_row, - cells[0..end], + cells[0..count], ); return; } @@ -2005,7 +2009,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { self.screen.clearUnprotectedCells( &self.screen.cursor.page_pin.node.data, self.screen.cursor.page_row, - cells[0..end], + cells[0..count], ); } @@ -2623,82 +2627,38 @@ pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { /// Full reset. /// -/// This will attempt to free the existing screen memory and allocate -/// new screens but if that fails this will reuse the existing memory -/// from the prior screens. In the latter case, memory may be wasted -/// (since its unused) but it isn't leaked. +/// This will attempt to free the existing screen memory but if that fails +/// this will reuse the existing memory. In the latter case, memory may +/// be wasted (since its unused) but it isn't leaked. pub fn fullReset(self: *Terminal) void { - // Attempt to initialize new screens. - var new_primary = Screen.init( - self.screen.alloc, - self.cols, - self.rows, - self.screen.pages.explicit_max_size, - ) catch |err| { - log.warn("failed to allocate new primary screen, reusing old memory err={}", .{err}); - self.fallbackReset(); - return; - }; - const new_secondary = Screen.init( - self.secondary_screen.alloc, - self.cols, - self.rows, - 0, - ) catch |err| { - log.warn("failed to allocate new secondary screen, reusing old memory err={}", .{err}); - new_primary.deinit(); - self.fallbackReset(); - return; - }; + // Reset our screens + self.screen.reset(); + self.secondary_screen.reset(); - // If we got here, both new screens were successfully allocated - // and we can deinitialize the old screens. - self.screen.deinit(); - self.secondary_screen.deinit(); + // Ensure we're back on primary screen + if (self.active_screen != .primary) { + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .primary; + } - // Replace with the newly allocated screens. - self.screen = new_primary; - self.secondary_screen = new_secondary; - - self.resetCommonState(); -} - -fn fallbackReset(self: *Terminal) void { - // Clear existing screens without reallocation - self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = false }); - self.screen.clearSelection(); - self.eraseDisplay(.scrollback, false); - self.eraseDisplay(.complete, false); - self.screen.cursorAbsolute(0, 0); - self.resetCommonState(); -} - -fn resetCommonState(self: *Terminal) void { - // We set the saved cursor to null and then restore. This will force - // our cursor to go back to the default which will also move the cursor - // to the top-left. - self.screen.saved_cursor = null; - self.restoreCursor() catch |err| { - log.warn("restore cursor on primary screen failed err={}", .{err}); - }; - - self.screen.endHyperlink(); - self.screen.charset = .{}; - self.modes = .{}; + // Rest our basic state + self.modes.reset(); self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); - self.screen.kitty_keyboard = .{}; - self.secondary_screen.kitty_keyboard = .{}; - self.screen.protected_mode = .off; + self.previous_char = null; + self.pwd.clearRetainingCapacity(); + self.status_display = .main; self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1, .left = 0, .right = self.cols - 1, }; - self.previous_char = null; - self.pwd.clearRetainingCapacity(); - self.status_display = .main; + + // Always mark dirty so we redraw everything + self.flags.dirty.clear = true; } /// Returns true if the point is dirty, used for testing. @@ -6104,6 +6064,36 @@ test "Terminal: eraseChars wide char boundary conditions" { } } +test "Terminal: eraseChars wide char splits proper cell boundaries" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 1, .cols = 30 }); + defer t.deinit(alloc); + + // This is a test for a bug: https://github.com/ghostty-org/ghostty/issues/2817 + // To explain the setup: + // (1) We need our wide characters starting on an even (1-based) column. + // (2) We need our cursor to be in the middle somewhere. + // (3) We need our count to be less than our cursor X and on a split cell. + // The bug was that we split the wrong cell boundaries. + + try t.printString("x食べて下さい"); + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("x食べて下さい", str); + } + + t.setCursorPos(1, 6); // At: て + t.eraseChars(4); // Delete: て下 + t.screen.cursor.page_pin.node.data.assertIntegrity(); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("x食べ さい", str); + } +} + test "Terminal: eraseChars wide char wrap boundary conditions" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 3, .cols = 8 }); @@ -10529,6 +10519,28 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); } +test "Terminal: fullReset default modes" { + var t = try init(testing.allocator, .{ + .cols = 10, + .rows = 10, + .default_modes = .{ .grapheme_cluster = true }, + }); + defer t.deinit(testing.allocator); + try testing.expect(t.modes.get(.grapheme_cluster)); + t.fullReset(); + try testing.expect(t.modes.get(.grapheme_cluster)); +} + +test "Terminal: fullReset tracked pins" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Create a tracked pin + const p = try t.screen.pages.trackPin(t.screen.cursor.page_pin.*); + t.fullReset(); + try testing.expect(t.screen.pages.pinIsValid(p.*)); +} + // https://github.com/mitchellh/ghostty/issues/272 // This is also tested in depth in screen resize tests but I want to keep // this test around to ensure we don't regress at multiple layers. diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 42f12ea07..057f28065 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -164,9 +164,9 @@ fn transmit( // If there are more chunks expected we do not respond. if (load.more) return .{}; - // If our image has no ID or number, we don't respond at all. Conversely, - // if we have either an ID or number, we always respond. - if (load.image.id == 0 and load.image.number == 0) return .{}; + // If the loaded image was assigned its ID automatically, not based + // on a number or explicitly specified ID, then we don't respond. + if (load.image.implicit_id) return .{}; // After the image is added, set the ID in case it changed. // The resulting image number and placement ID never change. @@ -335,6 +335,10 @@ fn loadAndAddImage( if (loading.image.id == 0) { loading.image.id = storage.next_image_id; storage.next_image_id +%= 1; + + // If the image also has no number then its auto-ID is "implicit". + // See the doc comment on the Image.implicit_id field for more detail. + if (loading.image.number == 0) loading.image.implicit_id = true; } // If this is chunked, this is the beginning of a new chunked transmission. @@ -529,3 +533,21 @@ test "kittygfx test valid i32 (expect invalid image ID)" { try testing.expect(!resp.ok()); try testing.expectEqual(resp.message, "ENOENT: image not found"); } + +test "kittygfx no response with no image ID or number" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,t=d,s=1,v=2,c=10,r=1,i=0,I=0;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } +} diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 931d068f9..ff498cbb8 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -455,6 +455,12 @@ pub const Image = struct { data: []const u8 = "", transmit_time: std.time.Instant = undefined, + /// Set this to true if this image was loaded by a command that + /// doesn't specify an ID or number, since such commands should + /// not be responded to, even though we do currently give them + /// IDs in the public range (which is bad!). + implicit_id: bool = false, + pub const Error = error{ InternalError, InvalidData, diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index bf8633c88..ffd3aa580 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -31,6 +31,9 @@ pub const ImageStorage = struct { /// This is the next automatically assigned image ID. We start mid-way /// through the u32 range to avoid collisions with buggy programs. + /// TODO: This isn't good enough, it's perfectly legal for programs + /// to use IDs in the latter half of the range and collisions + /// are not gracefully handled. next_image_id: u32 = 2147483647, /// This is the next automatically assigned placement ID. This is never @@ -690,7 +693,7 @@ pub const ImageStorage = struct { br.x = @min( // We need to sub one here because the x value is // one width already. So if the image is width "1" - // then we add zero to X because X itelf is width 1. + // then we add zero to X because X itself is width 1. pin.x + (grid_size.cols - 1), t.cols - 1, ); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index d295ea1ba..df3788d30 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -18,6 +18,7 @@ pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); +pub const search = @import("search.zig"); pub const size = @import("size.zig"); pub const tmux = @import("tmux.zig"); pub const x11_color = @import("x11_color.zig"); @@ -47,6 +48,7 @@ pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; pub const DeviceAttributeReq = ansi.DeviceAttributeReq; pub const Mode = modes.Mode; +pub const ModePacked = modes.ModePacked; pub const ModifyKeyFormat = ansi.ModifyKeyFormat; pub const ProtectedMode = ansi.ProtectedMode; pub const StatusLineType = ansi.StatusLineType; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index c4dbb1cd6..89d352e4a 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -21,6 +21,17 @@ pub const ModeState = struct { /// a real-world issue but we need to be aware of a DoS vector. saved: ModePacked = .{}, + /// The default values for the modes. This is used to reset + /// the modes to their default values during reset. + default: ModePacked = .{}, + + /// Reset the modes to their default values. This also clears the + /// saved state. + pub fn reset(self: *ModeState) void { + self.values = self.default; + self.saved = .{}; + } + /// Set a mode to a value. pub fn set(self: *ModeState, mode: Mode, value: bool) void { switch (mode) { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 8c470d726..83164e163 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1481,6 +1481,179 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).capacity(); } + /// Options for encoding the page as UTF-8. + pub const EncodeUtf8Options = struct { + /// The range of rows to encode. If end_y is null, then it will + /// encode to the end of the page. + start_y: size.CellCountInt = 0, + end_y: ?size.CellCountInt = null, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + unwrap: bool = true, + + /// Preceding state from encoding the prior page. Used to preserve + /// blanks properly across multiple pages. + preceding: TrailingUtf8State = .{}, + + /// If non-null, this will be cleared and filled with the x/y + /// coordinates of each byte in the UTF-8 encoded output. + /// The index in the array is the byte offset in the output + /// where 0 is the cursor of the writer when the function is + /// called. + cell_map: ?*CellMap = null, + + /// Trailing state for UTF-8 encoding. + pub const TrailingUtf8State = struct { + rows: usize = 0, + cells: usize = 0, + }; + }; + + /// See cell_map + pub const CellMap = std.ArrayList(CellMapEntry); + + /// The x/y coordinate of a single cell in the cell map. + pub const CellMapEntry = struct { + y: size.CellCountInt, + x: size.CellCountInt, + }; + + /// Encode the page contents as UTF-8. + /// + /// If preceding is non-null, then it will be used to initialize our + /// blank rows/cells count so that we can accumulate blanks across + /// multiple pages. + /// + /// Note: Many tests for this function are done via Screen.dumpString + /// tests since that function is a thin wrapper around this one and + /// it makes it easier to test input contents. + pub fn encodeUtf8( + self: *const Page, + writer: anytype, + opts: EncodeUtf8Options, + ) anyerror!EncodeUtf8Options.TrailingUtf8State { + var blank_rows: usize = opts.preceding.rows; + var blank_cells: usize = opts.preceding.cells; + + const start_y: size.CellCountInt = opts.start_y; + const end_y: size.CellCountInt = opts.end_y orelse self.size.rows; + + // We can probably avoid this by doing the logic below in a different + // way. The reason this exists is so that when we end a non-blank + // line with a newline, we can correctly map the cell map over to + // the correct x value. + // + // For example "A\nB". The cell map for "\n" should be (1, 0). + // This is tested in Screen.zig so feel free to refactor this. + var last_x: size.CellCountInt = 0; + + for (start_y..end_y) |y_usize| { + const y: size.CellCountInt = @intCast(y_usize); + const row: *Row = self.getRow(y); + const cells: []const Cell = self.getCells(row); + + // If this row is blank, accumulate to avoid a bunch of extra + // work later. If it isn't blank, make sure we dump all our + // blanks. + if (!Cell.hasTextAny(cells)) { + blank_rows += 1; + continue; + } + for (1..blank_rows + 1) |i| { + try writer.writeByte('\n'); + + // This is tested in Screen.zig, i.e. one test is + // "cell map with newlines" + if (opts.cell_map) |cell_map| { + try cell_map.append(.{ + .x = last_x, + .y = @intCast(y - blank_rows + i - 1), + }); + last_x = 0; + } + } + blank_rows = 0; + + // If we're not wrapped, we always add a newline so after + // the row is printed we can add a newline. + if (!row.wrap or !opts.unwrap) blank_rows += 1; + + // If the row doesn't continue a wrap then we need to reset + // our blank cell count. + if (!row.wrap_continuation or !opts.unwrap) blank_cells = 0; + + // Go through each cell and print it + for (cells, 0..) |*cell, x_usize| { + const x: size.CellCountInt = @intCast(x_usize); + + // Skip spacers + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // If we have a zero value, then we accumulate a counter. We + // only want to turn zero values into spaces if we have a non-zero + // char sometime later. + if (!cell.hasText()) { + blank_cells += 1; + continue; + } + if (blank_cells > 0) { + try writer.writeByteNTimes(' ', blank_cells); + if (opts.cell_map) |cell_map| { + for (0..blank_cells) |i| try cell_map.append(.{ + .x = @intCast(x - blank_cells + i), + .y = y, + }); + } + + blank_cells = 0; + } + + switch (cell.content_tag) { + .codepoint => { + try writer.print("{u}", .{cell.content.codepoint}); + if (opts.cell_map) |cell_map| { + last_x = x + 1; + try cell_map.append(.{ + .x = x, + .y = y, + }); + } + }, + + .codepoint_grapheme => { + try writer.print("{u}", .{cell.content.codepoint}); + if (opts.cell_map) |cell_map| { + last_x = x + 1; + try cell_map.append(.{ + .x = x, + .y = y, + }); + } + + for (self.lookupGrapheme(cell).?) |cp| { + try writer.print("{u}", .{cp}); + if (opts.cell_map) |cell_map| try cell_map.append(.{ + .x = x, + .y = y, + }); + } + }, + + // Unreachable since we do hasText() above + .bg_color_palette, + .bg_color_rgb, + => unreachable, + } + } + } + + return .{ .rows = blank_rows, .cells = blank_cells }; + } + /// Returns the bitset for the dirty bits on this page. /// /// The returned value is a DynamicBitSetUnmanaged but it is NOT diff --git a/src/terminal/search.zig b/src/terminal/search.zig new file mode 100644 index 000000000..56b181c48 --- /dev/null +++ b/src/terminal/search.zig @@ -0,0 +1,864 @@ +//! Search functionality for the terminal. +//! +//! At the time of writing this comment, this is a **work in progress**. +//! +//! Search at the time of writing is implemented using a simple +//! boyer-moore-horspool algorithm. The suboptimal part of the implementation +//! is that we need to encode each terminal page into a text buffer in order +//! to apply BMH to it. This is because the terminal page is not laid out +//! in a flat text form. +//! +//! To minimize memory usage, we use a sliding window to search for the +//! needle. The sliding window only keeps the minimum amount of page data +//! in memory to search for a needle (i.e. `needle.len - 1` bytes of overlap +//! between terminal pages). +//! +//! Future work: +//! +//! - PageListSearch on a PageList concurrently with another thread +//! - Handle pruned pages in a PageList to ensure we don't keep references +//! - Repeat search a changing active area of the screen +//! - Reverse search so that more recent matches are found first +//! + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const CircBuf = @import("../datastruct/main.zig").CircBuf; +const terminal = @import("main.zig"); +const point = terminal.point; +const Page = terminal.Page; +const PageList = terminal.PageList; +const Pin = PageList.Pin; +const Selection = terminal.Selection; +const Screen = terminal.Screen; + +/// Searches for a term in a PageList structure. +/// +/// At the time of writing, this does not support searching a pagelist +/// simultaneously as its being used by another thread. This will be resolved +/// in the future. +pub const PageListSearch = struct { + /// The list we're searching. + list: *PageList, + + /// The sliding window of page contents and nodes to search. + window: SlidingWindow, + + /// Initialize the page list search. + /// + /// The needle is not copied and must be kept alive for the duration + /// of the search operation. + pub fn init( + alloc: Allocator, + list: *PageList, + needle: []const u8, + ) Allocator.Error!PageListSearch { + var window = try SlidingWindow.init(alloc, needle); + errdefer window.deinit(alloc); + + return .{ + .list = list, + .window = window, + }; + } + + pub fn deinit(self: *PageListSearch, alloc: Allocator) void { + self.window.deinit(alloc); + } + + /// Find the next match for the needle in the pagelist. This returns + /// null when there are no more matches. + pub fn next( + self: *PageListSearch, + alloc: Allocator, + ) Allocator.Error!?Selection { + // Try to search for the needle in the window. If we find a match + // then we can return that and we're done. + if (self.window.next()) |sel| return sel; + + // Get our next node. If we have a value in our window then we + // can determine the next node. If we don't, we've never setup the + // window so we use our first node. + var node_: ?*PageList.List.Node = if (self.window.meta.last()) |meta| + meta.node.next + else + self.list.pages.first; + + // Add one pagelist node at a time, look for matches, and repeat + // until we find a match or we reach the end of the pagelist. + // This append then next pattern limits memory usage of the window. + while (node_) |node| : (node_ = node.next) { + try self.window.append(alloc, node); + if (self.window.next()) |sel| return sel; + } + + // We've reached the end of the pagelist, no matches. + return null; + } +}; + +/// Searches page nodes via a sliding window. The sliding window maintains +/// the invariant that data isn't pruned until (1) we've searched it and +/// (2) we've accounted for overlaps across pages to fit the needle. +/// +/// The sliding window is first initialized empty. Pages are then appended +/// in the order to search them. If you're doing a reverse search then the +/// pages should be appended in reverse order and the needle should be +/// reversed. +/// +/// All appends grow the window. The window is only pruned when a searc +/// is done (positive or negative match) via `next()`. +/// +/// To avoid unnecessary memory growth, the recommended usage is to +/// call `next()` until it returns null and then `append` the next page +/// and repeat the process. This will always maintain the minimum +/// required memory to search for the needle. +const SlidingWindow = struct { + /// The data buffer is a circular buffer of u8 that contains the + /// encoded page text that we can use to search for the needle. + data: DataBuf, + + /// The meta buffer is a circular buffer that contains the metadata + /// about the pages we're searching. This usually isn't that large + /// so callers must iterate through it to find the offset to map + /// data to meta. + meta: MetaBuf, + + /// Offset into data for our current state. This handles the + /// situation where our search moved through meta[0] but didn't + /// do enough to prune it. + data_offset: usize = 0, + + /// The needle we're searching for. Does not own the memory. + needle: []const u8, + + /// A buffer to store the overlap search data. This is used to search + /// overlaps between pages where the match starts on one page and + /// ends on another. The length is always `needle.len * 2`. + overlap_buf: []u8, + + const DataBuf = CircBuf(u8, 0); + const MetaBuf = CircBuf(Meta, undefined); + const Meta = struct { + node: *PageList.List.Node, + cell_map: Page.CellMap, + + pub fn deinit(self: *Meta) void { + self.cell_map.deinit(); + } + }; + + pub fn init( + alloc: Allocator, + needle: []const u8, + ) Allocator.Error!SlidingWindow { + var data = try DataBuf.init(alloc, 0); + errdefer data.deinit(alloc); + + var meta = try MetaBuf.init(alloc, 0); + errdefer meta.deinit(alloc); + + const overlap_buf = try alloc.alloc(u8, needle.len * 2); + errdefer alloc.free(overlap_buf); + + return .{ + .data = data, + .meta = meta, + .needle = needle, + .overlap_buf = overlap_buf, + }; + } + + pub fn deinit(self: *SlidingWindow, alloc: Allocator) void { + alloc.free(self.overlap_buf); + self.data.deinit(alloc); + + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(); + self.meta.deinit(alloc); + } + + /// Clear all data but retain allocated capacity. + pub fn clearAndRetainCapacity(self: *SlidingWindow) void { + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(); + self.meta.clear(); + self.data.clear(); + self.data_offset = 0; + } + + /// Search the window for the next occurrence of the needle. As + /// the window moves, the window will prune itself while maintaining + /// the invariant that the window is always big enough to contain + /// the needle. + pub fn next(self: *SlidingWindow) ?Selection { + const slices = slices: { + // If we have less data then the needle then we can't possibly match + const data_len = self.data.len(); + if (data_len < self.needle.len) return null; + + break :slices self.data.getPtrSlice( + self.data_offset, + data_len - self.data_offset, + ); + }; + + // Search the first slice for the needle. + if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { + return self.selection(idx, self.needle.len); + } + + // Search the overlap buffer for the needle. + if (slices[0].len > 0 and slices[1].len > 0) overlap: { + // Get up to needle.len - 1 bytes from each side (as much as + // we can) and store it in the overlap buffer. + const prefix: []const u8 = prefix: { + const len = @min(slices[0].len, self.needle.len - 1); + const idx = slices[0].len - len; + break :prefix slices[0][idx..]; + }; + const suffix: []const u8 = suffix: { + const len = @min(slices[1].len, self.needle.len - 1); + break :suffix slices[1][0..len]; + }; + const overlap_len = prefix.len + suffix.len; + assert(overlap_len <= self.overlap_buf.len); + @memcpy(self.overlap_buf[0..prefix.len], prefix); + @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); + + // Search the overlap + const idx = std.mem.indexOf( + u8, + self.overlap_buf[0..overlap_len], + self.needle, + ) orelse break :overlap; + + // We found a match in the overlap buffer. We need to map the + // index back to the data buffer in order to get our selection. + return self.selection( + slices[0].len - prefix.len + idx, + self.needle.len, + ); + } + + // Search the last slice for the needle. + if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { + return self.selection(slices[0].len + idx, self.needle.len); + } + + // No match. We keep `needle.len - 1` bytes available to + // handle the future overlap case. + var meta_it = self.meta.iterator(.reverse); + prune: { + var saved: usize = 0; + while (meta_it.next()) |meta| { + const needed = self.needle.len - 1 - saved; + if (meta.cell_map.items.len >= needed) { + // We save up to this meta. We set our data offset + // to exactly where it needs to be to continue + // searching. + self.data_offset = meta.cell_map.items.len - needed; + break; + } + + saved += meta.cell_map.items.len; + } else { + // If we exited the while loop naturally then we + // never got the amount we needed and so there is + // nothing to prune. + assert(saved < self.needle.len - 1); + break :prune; + } + + const prune_count = self.meta.len() - meta_it.idx; + if (prune_count == 0) { + // This can happen if we need to save up to the first + // meta value to retain our window. + break :prune; + } + + // We can now delete all the metas up to but NOT including + // the meta we found through meta_it. + meta_it = self.meta.iterator(.forward); + var prune_data_len: usize = 0; + for (0..prune_count) |_| { + const meta = meta_it.next().?; + prune_data_len += meta.cell_map.items.len; + meta.deinit(); + } + self.meta.deleteOldest(prune_count); + self.data.deleteOldest(prune_data_len); + } + + // Our data offset now moves to needle.len - 1 from the end so + // that we can handle the overlap case. + self.data_offset = self.data.len() - self.needle.len + 1; + + self.assertIntegrity(); + return null; + } + + /// Return a selection for the given start and length into the data + /// buffer and also prune the data/meta buffers if possible up to + /// this start index. + /// + /// The start index is assumed to be relative to the offset. i.e. + /// index zero is actually at `self.data[self.data_offset]`. The + /// selection will account for the offset. + fn selection( + self: *SlidingWindow, + start_offset: usize, + len: usize, + ) Selection { + const start = start_offset + self.data_offset; + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + + // meta_consumed is the number of bytes we've consumed in the + // data buffer up to and NOT including the meta where we've + // found our pin. This is important because it tells us the + // amount of data we can safely deleted from self.data since + // we can't partially delete a meta block's data. (The partial + // amount is represented by self.data_offset). + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + const tl: Pin = pin(&meta_it, &meta_consumed, start); + + // Store the information required to prune later. We store this + // now because we only want to prune up to our START so we can + // find overlapping matches. + const tl_meta_idx = meta_it.idx - 1; + const tl_meta_consumed = meta_consumed; + + // We have to seek back so that we reinspect our current + // iterator value again in case the start and end are in the + // same segment. + meta_it.seekBy(-1); + const br: Pin = pin(&meta_it, &meta_consumed, start + len - 1); + assert(meta_it.idx >= 1); + + // Our offset into the current meta block is the start index + // minus the amount of data fully consumed. We then add one + // to move one past the match so we don't repeat it. + self.data_offset = start - tl_meta_consumed + 1; + + // meta_it.idx is br's meta index plus one (because the iterator + // moves one past the end; we call next() one last time). So + // we compare against one to check that the meta that we matched + // in has prior meta blocks we can prune. + if (tl_meta_idx > 0) { + // Deinit all our memory in the meta blocks prior to our + // match. + const meta_count = tl_meta_idx; + meta_it.reset(); + for (0..meta_count) |_| meta_it.next().?.deinit(); + if (comptime std.debug.runtime_safety) { + assert(meta_it.idx == meta_count); + assert(meta_it.next().?.node == tl.node); + } + self.meta.deleteOldest(meta_count); + + // Delete all the data up to our current index. + assert(tl_meta_consumed > 0); + self.data.deleteOldest(tl_meta_consumed); + } + + self.assertIntegrity(); + return Selection.init(tl, br, false); + } + + /// Convert a data index into a pin. + /// + /// The iterator and offset are both expected to be passed by + /// pointer so that the pin can be efficiently called for multiple + /// indexes (in order). See selection() for an example. + /// + /// Precondition: the index must be within the data buffer. + fn pin( + it: *MetaBuf.Iterator, + offset: *usize, + idx: usize, + ) Pin { + while (it.next()) |meta| { + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = idx - offset.*; + if (meta_i >= meta.cell_map.items.len) { + // This meta doesn't contain the match. This means we + // can also prune this set of data because we only look + // forward. + offset.* += meta.cell_map.items.len; + continue; + } + + // We found the meta that contains the start of the match. + const map = meta.cell_map.items[meta_i]; + return .{ + .node = meta.node, + .y = map.y, + .x = map.x, + }; + } + + // Unreachable because it is a precondition that the index is + // within the data buffer. + unreachable; + } + + /// Add a new node to the sliding window. This will always grow + /// the sliding window; data isn't pruned until it is consumed + /// via a search (via next()). + pub fn append( + self: *SlidingWindow, + alloc: Allocator, + node: *PageList.List.Node, + ) Allocator.Error!void { + // Initialize our metadata for the node. + var meta: Meta = .{ + .node = node, + .cell_map = Page.CellMap.init(alloc), + }; + errdefer meta.deinit(); + + // This is suboptimal but we need to encode the page once to + // temporary memory, and then copy it into our circular buffer. + // In the future, we should benchmark and see if we can encode + // directly into the circular buffer. + var encoded: std.ArrayListUnmanaged(u8) = .{}; + defer encoded.deinit(alloc); + + // Encode the page into the buffer. + const page: *const Page = &meta.node.data; + _ = page.encodeUtf8( + encoded.writer(alloc), + .{ .cell_map = &meta.cell_map }, + ) catch { + // writer uses anyerror but the only realistic error on + // an ArrayList is out of memory. + return error.OutOfMemory; + }; + assert(meta.cell_map.items.len == encoded.items.len); + + // Ensure our buffers are big enough to store what we need. + try self.data.ensureUnusedCapacity(alloc, encoded.items.len); + try self.meta.ensureUnusedCapacity(alloc, 1); + + // Append our new node to the circular buffer. + try self.data.appendSlice(encoded.items); + try self.meta.append(meta); + + self.assertIntegrity(); + } + + fn assertIntegrity(self: *const SlidingWindow) void { + if (comptime !std.debug.runtime_safety) return; + + // Integrity check: verify our data matches our metadata exactly. + var meta_it = self.meta.iterator(.forward); + var data_len: usize = 0; + while (meta_it.next()) |m| data_len += m.cell_map.items.len; + assert(data_len == self.data.len()); + + // Integrity check: verify our data offset is within bounds. + assert(self.data_offset < self.data.len()); + } +}; + +test "PageListSearch single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + + var search = try PageListSearch.init(alloc, &s.pages, "boo!"); + defer search.deinit(alloc); + + // We should be able to find two matches. + { + const sel = (try search.next(alloc)).?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + { + const sel = (try search.next(alloc)).?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect((try search.next(alloc)) == null); + try testing.expect((try search.next(alloc)) == null); +} + +test "SlidingWindow empty on init" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(alloc); + try testing.expectEqual(0, w.data.len()); + try testing.expectEqual(0, w.meta.len()); +} + +test "SlidingWindow single append" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + + // We should be able to find two matches. + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append no match" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "nope!"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + try w.append(alloc, node.next.?); + + // Search should find two matches + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 79, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages match across boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "hello, world"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + try w.append(alloc, node.next.?); + + // Search should find a match + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match prunes first page" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "nope!"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + try w.append(alloc, node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Imaginary needle for search. Doesn't match! + var needle_list = std.ArrayList(u8).init(alloc); + defer needle_list.deinit(); + try needle_list.appendNTimes('x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w = try SlidingWindow.init(alloc, needle); + defer w.deinit(alloc); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + try w.append(alloc, node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "abc"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + try w.append(alloc, node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.needle = "boo"; + + // Add new page, now wraps + try w.append(alloc, node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "abcd"); + defer w.deinit(alloc); + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + try w.append(alloc, node); + try w.append(alloc, node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.needle = "boo!"; + + // Add new page, now wraps + try w.append(alloc, node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const sel = w.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(w.next() == null); +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 07aa43c42..41f86958e 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -843,6 +843,7 @@ const Subprocess = struct { // Don't leak these environment variables to child processes. if (comptime build_config.app_runtime == .gtk) { env.remove("GDK_DEBUG"); + env.remove("GDK_DISABLE"); env.remove("GSK_RENDERER"); } diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index e7b391419..bbcee7906 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -127,6 +127,23 @@ pub const DerivedConfig = struct { /// This will also start the child process if the termio is configured /// to run a child process. pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { + // The default terminal modes based on our config. + const default_modes: terminal.ModePacked = modes: { + var modes: terminal.ModePacked = .{}; + + // Setup our initial grapheme cluster support if enabled. We use a + // switch to ensure we get a compiler error if more cases are added. + switch (opts.full_config.@"grapheme-width-method") { + .unicode => modes.grapheme_cluster = true, + .legacy => {}, + } + + // Set default cursor blink settings + modes.cursor_blinking = opts.config.cursor_blink orelse true; + + break :modes modes; + }; + // Create our terminal var term = try terminal.Terminal.init(alloc, opts: { const grid_size = opts.size.grid(); @@ -134,19 +151,13 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .cols = grid_size.columns, .rows = grid_size.rows, .max_scrollback = opts.full_config.@"scrollback-limit", + .default_modes = default_modes, }; }); errdefer term.deinit(alloc); term.default_palette = opts.config.palette; term.color_palette.colors = opts.config.palette; - // Setup our initial grapheme cluster support if enabled. We use a - // switch to ensure we get a compiler error if more cases are added. - switch (opts.full_config.@"grapheme-width-method") { - .unicode => term.modes.set(.grapheme_cluster, true), - .legacy => {}, - } - // Set the image size limits try term.screen.kitty_images.setLimit( alloc, @@ -159,12 +170,6 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { opts.config.image_storage_limit, ); - // Set default cursor blink settings - term.modes.set( - .cursor_blinking, - opts.config.cursor_blink orelse true, - ); - // Set our default cursor style term.screen.cursor.cursor_style = opts.config.cursor_style; @@ -473,6 +478,18 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { ); } + // Clear all Kitty graphics state for this screen. This copies + // Kitty's behavior when Cmd+K deletes all Kitty graphics. I + // didn't spend time researching whether it only deletes Kitty + // graphics that are placed baove the cursor or if it deletes + // all of them. We delete all of them for now but if this behavior + // isn't fully correct we should fix this later. + self.terminal.screen.kitty_images.delete( + self.terminal.screen.alloc, + &self.terminal, + .{ .all = true }, + ); + return; } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 2fc9e92af..64915f704 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1418,7 +1418,7 @@ pub const StreamHandler = struct { var buf = std.ArrayList(u8).init(self.alloc); defer buf.deinit(); const writer = buf.writer(); - try writer.writeAll("\x1b[21"); + try writer.writeAll("\x1b]21"); for (request.list.items) |item| { switch (item) { @@ -1435,7 +1435,7 @@ pub const StreamHandler = struct { }, }, } orelse { - log.warn("no color configured for {}", .{key}); + try writer.print(";{}=", .{key}); continue; };