diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 2a1c98d48..2dd6a13e7 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ba1095896..c0a051753 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -279,7 +279,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: macos @@ -302,7 +302,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Download macOS Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: macos @@ -350,17 +350,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: source-tarball diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 03f6076a3..616ee84fe 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2a6ea8f9..75db53a4d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -101,7 +101,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -166,7 +166,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -199,7 +199,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -243,7 +243,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -406,7 +406,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: source-tarball - name: Extract tarball @@ -414,7 +414,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -509,7 +509,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -554,7 +554,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -603,7 +603,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -651,7 +651,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -706,7 +706,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -734,7 +734,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -761,7 +761,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -788,7 +788,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -815,7 +815,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -842,7 +842,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -876,7 +876,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -903,7 +903,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -938,7 +938,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix @@ -969,7 +969,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@01628ae51ea5d6b0c90109c7dccbf511953aff29 # v0.0.18 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: source-tarball @@ -996,7 +996,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 80d99b23f..d614814ad 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@9ff6d4004df1c3fd97cecafe010c874d77c48599 # v1.2.13 + uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: path: | /nix diff --git a/.gitignore b/.gitignore index 95eb1d5c3..e451b171a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ glad.zip /Box_test.ppm /Box_test_diff.ppm /ghostty.qcow2 + +vgcore.* diff --git a/build.zig b/build.zig index 1c98b2fa5..4acca53cc 100644 --- a/build.zig +++ b/build.zig @@ -27,7 +27,7 @@ pub fn build(b: *std.Build) !void { // Ghostty resources like terminfo, shell integration, themes, etc. const resources = try buildpkg.GhosttyResources.init(b, &config); - const i18n = try buildpkg.GhosttyI18n.init(b, &config); + const i18n = if (config.i18n) try buildpkg.GhosttyI18n.init(b, &config) else null; // Ghostty dependencies used by many artifacts. const deps = try buildpkg.SharedDeps.init(b, &config); @@ -79,7 +79,7 @@ pub fn build(b: *std.Build) !void { if (config.app_runtime != .none) { exe.install(); resources.install(); - i18n.install(); + if (i18n) |v| v.install(); } else { // Libghostty // @@ -112,7 +112,7 @@ pub fn build(b: *std.Build) !void { // The xcframework build always installs resources because our // macOS xcode project contains references to them. resources.install(); - i18n.install(); + if (i18n) |v| v.install(); } // Ghostty macOS app @@ -122,7 +122,7 @@ pub fn build(b: *std.Build) !void { .{ .xcframework = &xcframework, .docs = &docs, - .i18n = &i18n, + .i18n = if (i18n) |v| &v else null, .resources = &resources, }, ); @@ -166,7 +166,7 @@ pub fn build(b: *std.Build) !void { .{ .xcframework = &xcframework_native, .docs = &docs, - .i18n = &i18n, + .i18n = if (i18n) |v| &v else null, .resources = &resources, }, ); @@ -204,5 +204,9 @@ pub fn build(b: *std.Build) !void { // update-translations does what it sounds like and updates the "pot" // files. These should be committed to the repo. - translations_step.dependOn(i18n.update_step); + if (i18n) |v| { + translations_step.dependOn(v.update_step); + } else { + try translations_step.addError("cannot update translations when i18n is disabled", .{}); + } } diff --git a/build.zig.zon b/build.zig.zon index 4027e0252..8095fbe1f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -63,8 +63,8 @@ .gobject = .{ // https://github.com/jcollie/ghostty-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst", - .hash = "gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE", + .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", + .hash = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", .lazy = true, }, @@ -120,8 +120,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz", - .hash = "N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz", + .hash = "N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ccda58fd..24f1053ba 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE": { + "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM": { "name": "gobject", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst", - "hash": "sha256-ybeHo+NwcVZuyU037XB+/OofDoIoLsPnyNCG2jyiXC0=" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", + "hash": "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv": { + "N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz", - "hash": "sha256-w/biUQZ+AJv0atXypwQxJlKkHRUaFS0AlE/VlBJXlVU=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz", + "hash": "sha256-gl42NOZ59ok+umHCHbdBQhWCgFVpj5PAZDVGhJRpbiA=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c3fe7b2fa..380bafaeb 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -122,11 +122,11 @@ in }; } { - name = "gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE"; + name = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst"; - hash = "sha256-ybeHo+NwcVZuyU037XB+/OofDoIoLsPnyNCG2jyiXC0="; + url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst"; + hash = "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI="; }; } { @@ -162,11 +162,11 @@ in }; } { - name = "N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv"; + name = "N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz"; - hash = "sha256-w/biUQZ+AJv0atXypwQxJlKkHRUaFS0AlE/VlBJXlVU="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz"; + hash = "sha256-gl42NOZ59ok+umHCHbdBQhWCgFVpj5PAZDVGhJRpbiA="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 466a753ff..14bb0e8df 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,8 +27,8 @@ https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d6 https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz +https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 7da4009fd..d50371f5f 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-07-23-28-1/ghostty-gobject-0.14.1-2025-07-23-28-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7EXXnAB96BrWabxhzOw7HY-NzVexaPOIYw5t-dIE", - "sha256": "c9b787a3e37071566ec94d37ed707efcea1f0e82282ec3e7c8d086da3ca25c2d" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", + "sha256": "074ce22f32ae77e91d2aee53d414c4f46321f043ccfca861868349972b3940f2" }, { "type": "archive", @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b2742b8baf86f556d6be4d9c6515bfd9d9c7a140.tar.gz", - "dest": "vendor/p/N-V-__8AAN83XASXgcKp4RDTj_WcQ19E5X24C3FjQoffeMFv", - "sha256": "c3f6e251067e009bf46ad5f2a704312652a41d151a152d00944fd59412579555" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0efe86331736f09f9ed1.tar.gz", + "dest": "vendor/p/N-V-__8AABemXQQj_VhMpwuOSOiSzywW_yGD6aEL9YGI9uBu", + "sha256": "825e3634e679f6893eba61c21db7414215828055698f93c06435468494696e20" }, { "type": "archive", diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index cc3b9f63a..974f1b07f 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -14,7 +14,7 @@ struct TerminalEntity: AppEntity { @Property(title: "Kind") var kind: Kind - var screenshot: Image? + var screenshot: NSImage? static var typeDisplayRepresentation: TypeDisplayRepresentation { TypeDisplayRepresentation(name: "Terminal") @@ -24,8 +24,7 @@ struct TerminalEntity: AppEntity { var displayRepresentation: DisplayRepresentation { var rep = DisplayRepresentation(title: "\(title)") if let screenshot, - let nsImage = ImageRenderer(content: screenshot).nsImage, - let data = nsImage.tiffRepresentation { + let data = screenshot.tiffRepresentation { rep.image = .init(data: data) } @@ -45,11 +44,14 @@ struct TerminalEntity: AppEntity { static var defaultQuery = TerminalQuery() + @MainActor init(_ view: Ghostty.SurfaceView) { self.id = view.uuid self.title = view.title self.workingDirectory = view.pwd - self.screenshot = view.screenshot() + if let nsImage = ImageRenderer(content: view.screenshot()).nsImage { + self.screenshot = nsImage + } // Determine the kind based on the window controller type if view.window?.windowController is QuickTerminalController { diff --git a/po/pl_PL.UTF-8.po b/po/pl_PL.UTF-8.po index 018146fb7..da1280a1a 100644 --- a/po/pl_PL.UTF-8.po +++ b/po/pl_PL.UTF-8.po @@ -3,14 +3,15 @@ # Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # Bartosz Sokorski , 2025. +# trag1c , 2025. # msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-22 17:18+0000\n" -"PO-Revision-Date: 2025-03-17 12:15+0100\n" -"Last-Translator: Bartosz Sokorski \n" +"PO-Revision-Date: 2025-08-05 16:27+0200\n" +"Last-Translator: trag1c \n" "Language-Team: Polish \n" "Language: pl\n" "MIME-Version: 1.0\n" @@ -89,7 +90,7 @@ msgstr "Podziel w prawo" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "" +msgstr "Wykonaj komendę…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -162,7 +163,7 @@ msgstr "Otwórz konfigurację" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "" +msgstr "Paleta komend" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" @@ -210,12 +211,12 @@ msgstr "Zezwól" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 msgid "Remember choice for this split" -msgstr "" +msgstr "Zapamiętaj wybór dla tego podziału" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "" +msgstr "Przeładuj konfigurację, by ponownie wyświetlić ten komunikat" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 @@ -280,15 +281,15 @@ msgstr "Skopiowano do schowka" #: src/apprt/gtk/Surface.zig:1268 msgid "Cleared clipboard" -msgstr "" +msgstr "Wyczyszczono schowek" #: src/apprt/gtk/Surface.zig:2525 msgid "Command succeeded" -msgstr "" +msgstr "Komenda wykonana pomyślnie" #: src/apprt/gtk/Surface.zig:2527 msgid "Command failed" -msgstr "" +msgstr "Komenda nie powiodła się" #: src/apprt/gtk/Window.zig:216 msgid "Main Menu" @@ -300,7 +301,7 @@ msgstr "Zobacz otwarte karty" #: src/apprt/gtk/Window.zig:266 msgid "New Split" -msgstr "" +msgstr "Nowy podział" #: src/apprt/gtk/Window.zig:329 msgid "" diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 2c835a172..0818a98f6 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -40,10 +40,13 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "debug-warning" }, .{ .major = 1, .minor = 3, .name = "debug-warning" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, + .{ .major = 1, .minor = 5, .name = "split-tree" }, + .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, + .{ .major = 1, .minor = 5, .name = "command-palette" }, }; /// CSS files in css_path diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index dc024c5cf..82762b542 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -5,6 +5,7 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const ext = @import("ext.zig"); pub const Application = @import("class/application.zig").Application; pub const Window = @import("class/window.zig").Window; pub const Config = @import("class/config.zig").Config; @@ -29,6 +30,12 @@ pub fn Common( return @ptrCast(@alignCast(gobject.Object.ref(self.as(gobject.Object)))); } + /// If the reference count is 1 and the object is floating, clear the + /// floating attribute. Otherwise, increase the reference count by 1. + pub fn refSink(self: *Self) *Self { + return @ptrCast(@alignCast(gobject.Object.refSink(self.as(gobject.Object)))); + } + /// Decrease the reference count of the object. pub fn unref(self: *Self) void { gobject.Object.unref(self.as(gobject.Object)); @@ -73,7 +80,10 @@ pub fn Common( fn set(self: *Self, value: *const gobject.Value) void { const priv = private(self); if (@field(priv, name)) |v| { - glib.ext.destroy(v); + ext.boxedFree( + @typeInfo(@TypeOf(v)).pointer.child, + v, + ); } const T = @TypeOf(@field(priv, name)); @@ -212,6 +222,11 @@ pub fn Common( if (func_ti != .@"fn") { @compileError("bound function must be a function pointer"); } + if (func_ti.@"fn".return_type == bool) { + // glib booleans are ints and returning a Zig bool type + // I think uses a byte and causes ABI issues. + @compileError("bound function must return c_int instead of bool"); + } } gtk.Widget.Class.bindTemplateCallbackFull( diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index a8a9015b3..4a14434fa 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -15,14 +15,17 @@ const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); const CoreApp = @import("../../../App.zig"); const configpkg = @import("../../../config.zig"); +const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); const systemd = @import("../../../os/systemd.zig"); const terminal = @import("../../../terminal/main.zig"); const xev = @import("../../../global.zig").xev; +const Binding = @import("../../../input.zig").Binding; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); const ext = @import("../ext.zig"); +const key = @import("../key.zig"); const adw_version = @import("../adw_version.zig"); const gtk_version = @import("../gtk_version.zig"); const winprotopkg = @import("../winproto.zig"); @@ -31,9 +34,11 @@ const Common = @import("../class.zig").Common; const WeakRef = @import("../weak_ref.zig").WeakRef; const Config = @import("config.zig").Config; const Surface = @import("surface.zig").Surface; +const SplitTree = @import("split_tree.zig").SplitTree; const Window = @import("window.zig").Window; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; +const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; const log = std.log.scoped(.gtk_ghostty_application); @@ -75,8 +80,6 @@ pub const Application = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The current active configuration for the application.", .accessor = gobject.ext.typedAccessor( Self, ?*Config, @@ -105,6 +108,9 @@ pub const Application = extern struct { /// State and logic for the underlying windowing protocol. winproto: winprotopkg.App, + /// The global shortcut logic. + global_shortcuts: *GlobalShortcuts, + /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled /// and initialization was successful. @@ -127,7 +133,7 @@ pub const Application = extern struct { /// If non-null, we're currently showing a config errors dialog. /// This is a WeakRef because the dialog can close on its own /// outside of our own lifecycle and that's okay. - config_errors_dialog: WeakRef(ConfigErrorsDialog) = .{}, + config_errors_dialog: WeakRef(ConfigErrorsDialog) = .empty, /// glib source for our signal handler. signal_source: ?c_uint = null, @@ -305,6 +311,7 @@ pub const Application = extern struct { .winproto = wp, .css_provider = css_provider, .custom_css_providers = .empty, + .global_shortcuts = gobject.ext.newInstance(GlobalShortcuts, .{}), }; // Signals @@ -332,6 +339,7 @@ pub const Application = extern struct { const priv = self.private(); priv.config.unref(); priv.winproto.deinit(alloc); + priv.global_shortcuts.unref(); if (priv.transient_cgroup_base) |base| alloc.free(base); if (gdk.Display.getDefault()) |display| { gtk.StyleContext.removeProviderForDisplay( @@ -545,6 +553,10 @@ pub const Application = extern struct { .desktop_notification => Action.desktopNotification(self, target, value), + .equalize_splits => return Action.equalizeSplits(target), + + .goto_split => return Action.gotoSplit(target, value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -555,6 +567,8 @@ pub const Application = extern struct { .move_tab => return Action.moveTab(target, value), + .new_split => return Action.newSplit(target, value), + .new_tab => return Action.newTab(target), .new_window => try Action.newWindow( @@ -598,16 +612,13 @@ pub const Application = extern struct { .toggle_quick_terminal => return Action.toggleQuickTerminal(self), .toggle_tab_overview => return Action.toggleTabOverview(target), .toggle_window_decorations => return Action.toggleWindowDecorations(target), + .toggle_command_palette => return Action.toggleCommandPalette(target), // Unimplemented but todo on gtk-ng branch .prompt_title, - .toggle_command_palette, .inspector, // TODO: splits - .new_split, .resize_split, - .equalize_splits, - .goto_split, .toggle_split_zoom, => { log.warn("unimplemented action={}", .{action}); @@ -735,7 +746,7 @@ pub const Application = extern struct { if (config.@"split-divider-color") |color| { try writer.print( - \\.terminal-window .notebook separator {{ + \\.window .split paned > separator {{ \\ color: rgb({[r]d},{[g]d},{[b]d}); \\ background: rgb({[r]d},{[g]d},{[b]d}); \\}} @@ -854,6 +865,65 @@ pub const Application = extern struct { } } + fn syncActionAccelerators(self: *Self) void { + self.syncActionAccelerator("app.quit", .{ .quit = {} }); + self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); + self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); + self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); + self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); + self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); + self.syncActionAccelerator("win.close", .{ .close_window = {} }); + self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); + self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); + self.syncActionAccelerator("win.close-tab", .{ .close_tab = {} }); + self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); + self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); + self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); + self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); + self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); + self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); + self.syncActionAccelerator("win.reset", .{ .reset = {} }); + self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); + self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); + self.syncActionAccelerator("split-tree.new-left", .{ .new_split = .left }); + self.syncActionAccelerator("split-tree.new-right", .{ .new_split = .right }); + self.syncActionAccelerator("split-tree.new-up", .{ .new_split = .up }); + self.syncActionAccelerator("split-tree.new-down", .{ .new_split = .down }); + } + + fn syncActionAccelerator( + self: *Self, + gtk_action: [:0]const u8, + action: input.Binding.Action, + ) void { + const gtk_app = self.as(gtk.Application); + + // Reset it initially + const zero = [_:null]?[*:0]const u8{}; + gtk_app.setAccelsForAction(gtk_action, &zero); + + const config = self.private().config.get(); + const trigger = config.keybind.set.getTrigger(action) orelse return; + var buf: [1024]u8 = undefined; + const accel = if (key.accelFromTrigger( + &buf, + trigger, + )) |accel_| + accel_ orelse return + else |err| switch (err) { + // This should really never, never happen. Its not critical enough + // to actually crash, but this is a bug somewhere. An accelerator + // for a trigger can't possibly be more than 1024 bytes. + error.NoSpaceLeft => { + log.warn("accelerator somehow longer than 1024 bytes: {}", .{trigger}); + return; + }, + }; + const accels = [_:null]?[*:0]const u8{accel}; + + gtk_app.setAccelsForAction(gtk_action, &accels); + } + //--------------------------------------------------------------- // Properties @@ -884,6 +954,9 @@ pub const Application = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + // Sync our accelerators for menu items. + self.syncActionAccelerators(); + // Load our runtime and custom CSS. If this fails then our window is // just stuck with the old CSS but we don't want to fail the entire // config change operation. @@ -912,7 +985,7 @@ pub const Application = extern struct { //--------------------------------------------------------------- // Virtual Methods - fn startup(self: *Self) callconv(.C) void { + fn startup(self: *Self) callconv(.c) void { log.debug("startup", .{}); gio.Application.virtual_methods.startup.call( @@ -935,6 +1008,9 @@ pub const Application = extern struct { // Setup our action map self.startupActionMap(); + // Setup our global shortcuts + self.startupGlobalShortcuts(); + // Setup our cgroup for the application. self.startupCgroup() catch |err| { log.warn("cgroup initialization failed err={}", .{err}); @@ -1073,6 +1149,34 @@ pub const Application = extern struct { } } + /// Setup our global shortcuts. + fn startupGlobalShortcuts(self: *Self) void { + const priv = self.private(); + + // On startup, our dbus connection should be available. + priv.global_shortcuts.setDbusConnection( + self.as(gio.Application).getDbusConnection(), + ); + + // Setup a binding so that the shortcut config always matches the app. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + priv.global_shortcuts.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Setup the signal handler for global shortcut triggers + _ = GlobalShortcuts.signals.trigger.connect( + priv.global_shortcuts, + *Application, + globalShortcutTrigger, + self, + .{}, + ); + } + const CgroupError = error{ DbusConnectionFailed, CgroupInitFailed, @@ -1139,7 +1243,7 @@ pub const Application = extern struct { priv.transient_cgroup_base = path; } - fn activate(self: *Self) callconv(.C) void { + fn activate(self: *Self) callconv(.c) void { log.debug("activate", .{}); // Queue a new window @@ -1155,12 +1259,13 @@ pub const Application = extern struct { ); } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config_errors_dialog.get()) |diag| { diag.close(); diag.unref(); // strong ref from get() } + priv.config_errors_dialog.set(null); if (priv.signal_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove signal source", .{}); @@ -1174,7 +1279,7 @@ pub const Application = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { self.deinit(); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -1303,6 +1408,16 @@ pub const Application = extern struct { dialog.present(null); } + fn globalShortcutTrigger( + _: *GlobalShortcuts, + action: *const Binding.Action, + self: *Self, + ) callconv(.c) void { + self.core().performAllAction(self.rt(), action.*) catch |err| { + log.warn("failed to perform action={}", .{err}); + }; + } + fn actionReloadConfig( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -1437,7 +1552,7 @@ pub const Application = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { // Register our compiled resources exactly once. { const c = @cImport({ @@ -1538,6 +1653,52 @@ const Action = struct { gio_app.sendNotification(n.body, notification); } + pub fn equalizeSplits(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("equalize splits to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("split-tree.equalize", null) != 0; + }, + } + } + + pub fn gotoSplit( + target: apprt.Target, + to: apprt.action.GotoSplit, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + // Design note: we can't use widget actions here because + // we need to know whether there is a goto target for returning + // the proper perform result (boolean). + + const surface = core.rt_surface.surface; + const tree = ext.getAncestor( + SplitTree, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a split tree, ignoring goto_split", .{}); + return false; + }; + + return tree.goto(switch (to) { + .previous => .previous_wrapped, + .next => .next_wrapped, + .up => .{ .spatial = .up }, + .down => .{ .spatial = .down }, + .left => .{ .spatial = .left }, + .right => .{ .spatial = .right }, + }); + }, + } + } + pub fn gotoTab( target: apprt.Target, tab: apprt.action.GotoTab, @@ -1587,16 +1748,9 @@ const Action = struct { ) void { switch (target) { .app => log.warn("mouse over link to app is unexpected", .{}), - .surface => |surface| { - var v = gobject.ext.Value.new([:0]const u8); - if (value.url.len > 0) gobject.ext.Value.set(&v, value.url); - defer v.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "mouse-hover-url", - &v, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setMouseHoverUrl( + if (value.url.len > 0) value.url else null, + ), } } @@ -1606,15 +1760,7 @@ const Action = struct { ) void { switch (target) { .app => log.warn("mouse shape to app is unexpected", .{}), - .surface => |surface| { - var value = gobject.ext.Value.newFrom(shape); - defer value.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "mouse-shape", - &value, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setMouseShape(shape), } } @@ -1624,18 +1770,10 @@ const Action = struct { ) void { switch (target) { .app => log.warn("mouse visibility to app is unexpected", .{}), - .surface => |surface| { - var value = gobject.ext.Value.newFrom(switch (visibility) { - .visible => false, - .hidden => true, - }); - defer value.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "mouse-hidden", - &value, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setMouseHidden(switch (visibility) { + .visible => false, + .hidden => true, + }), } } @@ -1663,6 +1801,28 @@ const Action = struct { } } + pub fn newSplit( + target: apprt.Target, + direction: apprt.action.SplitDirection, + ) bool { + switch (target) { + .app => { + log.warn("new split to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction(switch (direction) { + .right => "split-tree.new-right", + .left => "split-tree.new-left", + .down => "split-tree.new-down", + .up => "split-tree.new-up", + }, null) != 0; + }, + } + } + pub fn newTab(target: apprt.Target) bool { switch (target) { .app => { @@ -1756,15 +1916,7 @@ const Action = struct { ) void { switch (target) { .app => log.warn("pwd to app is unexpected", .{}), - .surface => |surface| { - var v = gobject.ext.Value.newFrom(value.pwd); - defer v.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "pwd", - &v, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setPwd(value.pwd), } } @@ -1864,15 +2016,7 @@ const Action = struct { ) void { switch (target) { .app => log.warn("set_title to app is unexpected", .{}), - .surface => |surface| { - var v = gobject.ext.Value.newFrom(value.title); - defer v.unset(); - gobject.Object.setProperty( - surface.rt_surface.gobj().as(gobject.Object), - "title", - &v, - ); - }, + .surface => |surface| surface.rt_surface.gobj().setTitle(value.title), } } @@ -2003,6 +2147,15 @@ const Action = struct { }, } } + + pub fn toggleCommandPalette(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().toggleCommandPalette(); + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig index cb9a27444..d3d1b30b1 100644 --- a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig @@ -37,8 +37,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, bool, .{ - .nick = "Can Remember", - .blurb = "Allow remembering the choice.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -57,8 +55,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, ?*apprt.ClipboardRequest, .{ - .nick = "Request", - .blurb = "The clipboard request.", .accessor = C.privateBoxedFieldAccessor("request"), }, ); @@ -71,8 +67,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, ?*gtk.TextBuffer, .{ - .nick = "Clipboard Contents", - .blurb = "The clipboard contents being read/written.", .accessor = C.privateObjFieldAccessor("clipboard_contents"), }, ); @@ -85,8 +79,6 @@ pub const ClipboardConfirmationDialog = extern struct { Self, bool, .{ - .nick = "Blur", - .blurb = "Blur the contents, allowing the user to reveal.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -150,7 +142,7 @@ pub const ClipboardConfirmationDialog = extern struct { return gobject.ext.newInstance(Self, .{}); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Trigger initial values @@ -239,7 +231,7 @@ pub const ClipboardConfirmationDialog = extern struct { fn response( self: *Self, response_id: [*:0]const u8, - ) callconv(.C) void { + ) callconv(.c) void { const remember: bool = if (comptime can_remember) remember: { const priv = self.private(); break :remember priv.remember_choice.getActive() != 0; @@ -262,7 +254,7 @@ pub const ClipboardConfirmationDialog = extern struct { } } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.clipboard_contents) |v| { v.unref(); @@ -280,7 +272,7 @@ pub const ClipboardConfirmationDialog = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.request) |v| { glib.ext.destroy(v); @@ -304,7 +296,7 @@ pub const ClipboardConfirmationDialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), if (comptime adw_version.atLeast(1, 4, 0)) diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig index 579f792ce..3debafbb5 100644 --- a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig +++ b/src/apprt/gtk-ng/class/close_confirmation_dialog.zig @@ -32,8 +32,6 @@ pub const CloseConfirmationDialog = extern struct { Self, Target, .{ - .nick = "Target", - .blurb = "The target for this close confirmation.", .default = .app, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -81,7 +79,7 @@ pub const CloseConfirmationDialog = extern struct { }); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); } @@ -102,7 +100,7 @@ pub const CloseConfirmationDialog = extern struct { fn response( self: *Self, response_id: [*:0]const u8, - ) callconv(.C) void { + ) callconv(.c) void { if (std.mem.orderZ(u8, response_id, "close") == .eq) { signals.@"close-request".impl.emit( self, @@ -120,7 +118,7 @@ pub const CloseConfirmationDialog = extern struct { } } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -135,6 +133,7 @@ pub const CloseConfirmationDialog = extern struct { const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; + pub const refSink = C.refSink; pub const unref = C.unref; const private = C.private; @@ -143,7 +142,7 @@ pub const CloseConfirmationDialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(Dialog); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -181,12 +180,14 @@ pub const Target = enum(c_int) { app, tab, window, + surface, pub fn title(self: Target) [*:0]const u8 { return switch (self) { .app => i18n._("Quit Ghostty?"), .tab => i18n._("Close Tab?"), .window => i18n._("Close Window?"), + .surface => i18n._("Close Split?"), }; } @@ -195,6 +196,7 @@ pub const Target = enum(c_int) { .app => i18n._("All terminal sessions will be terminated."), .tab => i18n._("All terminal sessions in this tab will be terminated."), .window => i18n._("All terminal sessions in this window will be terminated."), + .surface => i18n._("The currently running process in this split will be terminated."), }; } diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig new file mode 100644 index 000000000..8b7bb328c --- /dev/null +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -0,0 +1,568 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +const adw = @import("adw"); +const gio = @import("gio"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const input = @import("../../../input.zig"); +const gresource = @import("../build/gresource.zig"); +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Window = @import("window.zig").Window; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_command_palette); + +pub const CommandPalette = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCommandPalette", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted when a command from the command palette is activated. The + /// action contains pointers to allocated data so if a receiver of this + /// signal needs to keep the action around it will need to clone the + /// action or there may be use-after-free errors. + pub const trigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const input.Binding.Action}, + void, + ); + }; + }; + + const Private = struct { + /// The configuration that this command palette is using. + config: ?*Config = null, + + /// The dialog object containing the palette UI. + dialog: *adw.Dialog, + + /// The search input text field. + search: *gtk.SearchEntry, + + /// The view containing each result row. + view: *gtk.ListView, + + /// The model that provides filtered data for the view to display. + model: *gtk.SingleSelection, + + /// The list that serves as the data source of the model. + /// This is where all command data is ultimately stored. + source: *gio.ListStore, + + pub var offset: c_int = 0; + }; + + /// Create a new instance of the command palette. The caller will own a + /// reference to the object. + pub fn new() *Self { + const self = gobject.ext.newInstance(Self, .{}); + + // Sink ourselves so that we aren't floating anymore. We'll unref + // ourselves when the palette is closed or an action is activated. + _ = self.refSink(); + + // Bump the ref so that the caller has a reference. + return self.ref(); + } + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Listen for any changes to our config. + _ = gobject.Object.signals.notify.connect( + self, + ?*anyopaque, + propConfig, + null, + .{ + .detail = "config", + }, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.source.removeAll(); + + if (priv.config) |config| { + config.unref(); + priv.config = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn propConfig(self: *CommandPalette, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { + const priv = self.private(); + + const config = priv.config orelse { + log.warn("command palette does not have a config!", .{}); + return; + }; + + const cfg = config.get(); + + // Clear existing binds + priv.source.removeAll(); + + for (cfg.@"command-palette-entry".value.items) |command| { + // Filter out actions that are not implemented or don't make sense + // for GTK. + switch (command.action) { + .close_all_windows, + .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, + => continue, + + else => {}, + } + + const cmd = Command.new(config, command); + const cmd_ref = cmd.as(gobject.Object); + priv.source.append(cmd_ref); + cmd_ref.unref(); + } + } + + fn close(self: *CommandPalette) void { + const priv = self.private(); + _ = priv.dialog.close(); + } + + fn dialogClosed(_: *adw.Dialog, self: *CommandPalette) callconv(.c) void { + self.unref(); + } + + fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // ESC was pressed - close the palette + self.close(); + } + + fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { + // If Enter is pressed, activate the selected entry + const priv = self.private(); + self.activated(priv.model.getSelected()); + } + + fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { + self.activated(pos); + } + + //--------------------------------------------------------------- + + /// Show or hide the command palette dialog. If the dialog is shown it will + /// be modal over the given window. + pub fn toggle(self: *CommandPalette, window: *Window) void { + const priv = self.private(); + + // If the dialog has been shown, close it. + if (priv.dialog.as(gtk.Widget).getRealized() != 0) { + self.close(); + return; + } + + // Show the dialog + priv.dialog.present(window.as(gtk.Widget)); + + // Focus on the search bar when opening the dialog + _ = priv.search.as(gtk.Widget).grabFocus(); + } + + /// Helper function to send a signal containing the action that should be + /// performed. + fn activated(self: *CommandPalette, pos: c_uint) void { + const priv = self.private(); + + // Use priv.model and not priv.source here to use the list of *visible* results + const object_ = priv.model.as(gio.ListModel).getObject(pos); + defer if (object_) |object| object.unref(); + + // Close before running the action in order to avoid being replaced by + // another dialog (such as the change title dialog). If that occurs then + // the command palette dialog won't be counted as having closed properly + // and cannot receive focus when reopened. + self.close(); + + const cmd = gobject.ext.cast(Command, object_ orelse return) orelse return; + const action = cmd.getAction() orelse return; + + // Signal that an an action has been selected. Signals are synchronous + // so we shouldn't need to worry about cloning the action. + signals.trigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const refSink = C.refSink; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(Command); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "command-palette", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("dialog", .{}); + class.bindTemplateChildPrivate("search", .{}); + class.bindTemplateChildPrivate("view", .{}); + class.bindTemplateChildPrivate("model", .{}); + class.bindTemplateChildPrivate("source", .{}); + + // Template Callbacks + class.bindTemplateCallback("closed", &dialogClosed); + class.bindTemplateCallback("notify_config", &propConfig); + class.bindTemplateCallback("search_stopped", &searchStopped); + class.bindTemplateCallback("search_activated", &searchActivated); + class.bindTemplateCallback("row_activated", &rowActivated); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + }); + + // Signals + signals.trigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; + +/// Object that wraps around a command. +/// +/// As GTK list models only accept objects that are within the GObject hierarchy, +/// we have to construct a wrapper to be easily consumed by the list model. +const Command = extern struct { + pub const Self = @This(); + pub const Parent = gobject.Object; + parent: Parent, + + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyCommand", + .instanceInit = &init, + .classInit = Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const action_key = struct { + pub const name = "action-key"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetActionKey, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const action = struct { + pub const name = "action"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetAction, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const title = struct { + pub const name = "title"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetTitle, + .getter_transfer = .none, + }, + ), + }, + ); + }; + + pub const description = struct { + pub const name = "description"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = gobject.ext.typedAccessor( + Self, + ?[:0]const u8, + .{ + .getter = propGetDescription, + .getter_transfer = .none, + }, + ), + }, + ); + }; + }; + + pub const Private = struct { + /// The configuration we should use to get keybindings. + config: ?*Config = null, + + /// Arena used to manage our allocations. + arena: ArenaAllocator, + + /// The command. + command: ?input.Command = null, + + /// Cache the formatted action. + action: ?[:0]const u8 = null, + + /// Cache the formatted action_key. + action_key: ?[:0]const u8 = null, + + pub var offset: c_int = 0; + }; + + pub fn new(config: *Config, command: input.Command) *Self { + const self = gobject.ext.newInstance(Self, .{ + .config = config, + }); + + const priv = self.private(); + priv.command = command.clone(priv.arena.allocator()) catch null; + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + // NOTE: we do not watch for changes to the config here as the command + // palette will destroy and recreate this object if/when the config + // changes. + + const priv = self.private(); + priv.arena = .init(Application.default().allocator()); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config) |config| { + config.unref(); + priv.config = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.arena.deinit(); + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + + fn propGetActionKey(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + if (priv.action_key) |action_key| return action_key; + + const command = priv.command orelse return null; + + priv.action_key = std.fmt.allocPrintZ( + priv.arena.allocator(), + "{}", + .{command.action}, + ) catch null; + + return priv.action_key; + } + + fn propGetAction(self: *Self) ?[:0]const u8 { + const priv = self.private(); + + if (priv.action) |action| return action; + + const command = priv.command orelse return null; + + const cfg = if (priv.config) |config| config.get() else return null; + const keybinds = cfg.keybind.set; + + const alloc = priv.arena.allocator(); + + priv.action = action: { + var buf: [64]u8 = undefined; + const trigger = keybinds.getTrigger(command.action) orelse break :action null; + const accel = (key.accelFromTrigger(&buf, trigger) catch break :action null) orelse break :action null; + break :action alloc.dupeZ(u8, accel) catch return null; + }; + + return priv.action; + } + + fn propGetTitle(self: *Self) ?[:0]const u8 { + const priv = self.private(); + const command = priv.command orelse return null; + return command.title; + } + + fn propGetDescription(self: *Self) ?[:0]const u8 { + const priv = self.private(); + const command = priv.command orelse return null; + return command.description; + } + + //--------------------------------------------------------------- + + /// Return a copy of the action. Callers must ensure that they do not use + /// the action beyond the lifetime of this object because it has internally + /// allocated data that will be freed when this object is. + pub fn getAction(self: *Self) ?input.Binding.Action { + const priv = self.private(); + const command = priv.command orelse return null; + return command.action; + } + + //--------------------------------------------------------------- + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.action_key.impl, + properties.action.impl, + properties.title.impl, + properties.description.impl, + }); + + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + }; +}; diff --git a/src/apprt/gtk-ng/class/config.zig b/src/apprt/gtk-ng/class/config.zig index e40602f47..2b98c68b5 100644 --- a/src/apprt/gtk-ng/class/config.zig +++ b/src/apprt/gtk-ng/class/config.zig @@ -39,8 +39,6 @@ pub const Config = extern struct { Self, ?*gtk.TextBuffer, .{ - .nick = "Diagnostics Buffer", - .blurb = "A TextBuffer that contains the diagnostics.", .accessor = gobject.ext.typedAccessor( Self, ?*gtk.TextBuffer, @@ -57,8 +55,6 @@ pub const Config = extern struct { Self, bool, .{ - .nick = "has-diagnostics", - .blurb = "Whether the configuration has diagnostics.", .default = false, .accessor = gobject.ext.typedAccessor( Self, @@ -139,7 +135,7 @@ pub const Config = extern struct { return text_buf; } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { self.private().config.deinit(); gobject.Object.virtual_methods.finalize.call( @@ -159,7 +155,7 @@ pub const Config = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.Object.virtual_methods.finalize.implement(class, &finalize); gobject.ext.registerProperties(class, &.{ properties.@"diagnostics-buffer", diff --git a/src/apprt/gtk-ng/class/config_errors_dialog.zig b/src/apprt/gtk-ng/class/config_errors_dialog.zig index 52591e622..fc76bc268 100644 --- a/src/apprt/gtk-ng/class/config_errors_dialog.zig +++ b/src/apprt/gtk-ng/class/config_errors_dialog.zig @@ -29,8 +29,6 @@ pub const ConfigErrorsDialog = extern struct { Self, ?*Config, .{ - .nick = "config", - .blurb = "The configuration that this dialog is showing errors for.", .accessor = gobject.ext.typedAccessor( Self, ?*Config, @@ -67,7 +65,7 @@ pub const ConfigErrorsDialog = extern struct { }); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); } @@ -82,7 +80,7 @@ pub const ConfigErrorsDialog = extern struct { fn response( self: *Self, response_id: [*:0]const u8, - ) callconv(.C) void { + ) callconv(.c) void { if (std.mem.orderZ(u8, response_id, "reload") != .eq) return; signals.@"reload-config".impl.emit( self, @@ -92,7 +90,7 @@ pub const ConfigErrorsDialog = extern struct { ); } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -134,7 +132,7 @@ pub const ConfigErrorsDialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(Dialog); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), diff --git a/src/apprt/gtk-ng/class/dialog.zig b/src/apprt/gtk-ng/class/dialog.zig index fd7fed21b..41a1988ba 100644 --- a/src/apprt/gtk-ng/class/dialog.zig +++ b/src/apprt/gtk-ng/class/dialog.zig @@ -82,7 +82,7 @@ pub const Dialog = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { _ = class; } diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk-ng/class/global_shortcuts.zig new file mode 100644 index 000000000..18280cfe9 --- /dev/null +++ b/src/apprt/gtk-ng/class/global_shortcuts.zig @@ -0,0 +1,632 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const Binding = @import("../../../input.zig").Binding; +const gresource = @import("../build/gresource.zig"); +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_global_shortcuts); + +pub const GlobalShortcuts = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gobject.Object; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyGlobalShortcuts", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const @"dbus-connection" = struct { + pub const name = "dbus-connection"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gio.DBusConnection, + .{ + .accessor = C.privateObjFieldAccessor("dbus_connection"), + }, + ); + }; + }; + + const Private = struct { + /// The configuration that this is using. + config: ?*Config = null, + + /// The dbus connection. + dbus_connection: ?*gio.DBusConnection = null, + + /// An arena allocator that is present for each refresh. + arena: ?std.heap.ArenaAllocator = null, + + /// A mapping from a unique ID to an action. + /// Currently the unique ID is simply the serialized representation of the + /// trigger that was used for the action as triggers are unique in the keymap, + /// but this may change in the future. + map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, + + /// The handle of the current global shortcuts portal session, + /// as a D-Bus object path. + handle: ?[:0]const u8 = null, + + /// The D-Bus signal subscription for the response signal on requests. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + response_subscription: c_uint = 0, + + /// The D-Bus signal subscription for the keybind activate signal. + /// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. + activate_subscription: c_uint = 0, + + pub var offset: c_int = 0; + }; + + pub const signals = struct { + /// Emitted whenever a global shortcut is triggered. + pub const trigger = struct { + pub const name = "trigger"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const Binding.Action}, + void, + ); + }; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + _ = gobject.Object.signals.notify.connect( + self, + *Self, + propConfig, + self, + .{ .detail = "config" }, + ); + } + + fn close(self: *Self) void { + const priv = self.private(); + const dbus = priv.dbus_connection orelse return; + + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + if (priv.activate_subscription != 0) { + dbus.signalUnsubscribe(priv.activate_subscription); + priv.activate_subscription = 0; + } + + if (priv.handle) |handle| { + // Close existing session + dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + priv.handle = null; + } + + if (priv.arena) |*arena| { + arena.deinit(); + priv.arena = null; + priv.map = .{}; // Uses arena memory + } + } + + fn refresh(self: *Self) Allocator.Error!void { + // Always close our previous state first. + self.close(); + + const priv = self.private(); + + // We need configuration to proceed. + const config = if (priv.config) |v| v.get() else return; + + // Setup our new arena that we'll use for memory allocations. + assert(priv.arena == null); + var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Our map starts out empty again. We don't need to worry about + // memory because its part of the arena we clear. + priv.map = .{}; + errdefer priv.map = .{}; + + // Update map + var trigger_buf: [1024]u8 = undefined; + var it = config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + const leaf = switch (entry.value_ptr.*) { + // Global shortcuts can't have leaders + .leader => continue, + .leaf => |leaf| leaf, + }; + if (!leaf.flags.global) continue; + + const trigger = if (key.xdgShortcutFromTrigger( + &trigger_buf, + entry.key_ptr.*, + )) |shortcut_| + shortcut_ orelse continue + else |err| switch (err) { + // If there isn't space to translate the trigger, then our + // buffer might be too small (but 1024 is insane!). In any case + // we don't want to stop registering globals. + error.NoSpaceLeft => { + log.warn( + "buffer too small to translate trigger, ignoring={}", + .{entry.key_ptr.*}, + ); + continue; + }, + }; + + try priv.map.put( + alloc, + try alloc.dupeZ(u8, trigger), + leaf.action, + ); + } + + // Store our arena + priv.arena = arena; + + // Create our session if we have global shortcuts. + if (priv.map.count() > 0) try self.request(.create_session); + } + + const Method = enum { + create_session, + bind_shortcuts, + + fn name(self: Method) [:0]const u8 { + return switch (self) { + .create_session => "CreateSession", + .bind_shortcuts => "BindShortcuts", + }; + } + + /// Construct the payload expected by the XDG portal call. + fn makePayload( + self: Method, + shortcuts: *GlobalShortcuts, + request_token: [:0]const u8, + ) ?*glib.Variant { + switch (self) { + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession + .create_session => { + var session_token: Token = undefined; + return glib.Variant.newParsed( + "({'handle_token': <%s>, 'session_handle_token': <%s>},)", + request_token.ptr, + generateToken(&session_token).ptr, + ); + }, + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts + .bind_shortcuts => { + const priv = shortcuts.private(); + const handle = priv.handle orelse return null; + + const bind_type = glib.VariantType.new("a(sa{sv})"); + defer glib.free(bind_type); + + var binds: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&binds, bind_type); + + var action_buf: [256]u8 = undefined; + + var it = priv.map.iterator(); + while (it.next()) |entry| { + const trigger = entry.key_ptr.*.ptr; + const action = std.fmt.bufPrintZ( + &action_buf, + "{}", + .{entry.value_ptr.*}, + ) catch continue; + + binds.addParsed( + "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", + trigger, + action.ptr, + trigger, + ); + } + + return glib.Variant.newParsed( + "(%o, %*, '', {'handle_token': <%s>})", + handle.ptr, + binds.end(), + request_token.ptr, + ); + }, + } + } + + fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { + switch (self) { + .create_session => { + var handle: ?[*:0]u8 = null; + if (vardict.lookup("session_handle", "&s", &handle) == 0) { + log.warn( + "session handle not found in response={s}", + .{vardict.print(@intFromBool(true))}, + ); + return; + } + + const priv = shortcuts.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + priv.handle = alloc.dupeZ(u8, std.mem.span(handle.?)) catch { + log.warn("out of memory: failed to clone session handle", .{}); + return; + }; + log.debug("session_handle={?s}", .{handle}); + + // Subscribe to keybind activations + assert(priv.activate_subscription == 0); + priv.activate_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.GlobalShortcuts", + "Activated", + "/org/freedesktop/portal/desktop", + handle, + .{ .match_arg0_path = true }, + shortcutActivated, + shortcuts, + null, + ); + + shortcuts.request(.bind_shortcuts) catch |err| { + log.warn("failed to bind shortcuts={}", .{err}); + return; + }; + }, + .bind_shortcuts => {}, + } + } + }; + + /// Submit a request to the global shortcuts portal. + fn request( + self: *Self, + comptime method: Method, + ) Allocator.Error!void { + // NOTE(pluiedev): + // XDG Portals are really, really poorly-designed pieces of hot garbage. + // How the protocol is _initially_ designed to work is as follows: + // + // 1. The client calls a method which returns the path of a Request object; + // 2. The client waits for the Response signal under said object path; + // 3. When the signal arrives, the actual return value and status code + // become available for the client for further processing. + // + // THIS DOES NOT WORK. Once the first two steps are complete, the client + // needs to immediately start listening for the third step, but an overeager + // server implementation could easily send the Response signal before the + // client is even ready, causing communications to break down over a simple + // race condition/two generals' problem that even _TCP_ had figured out + // decades ago. Worse yet, you get exactly _one_ chance to listen for the + // signal, or else your communication attempt so far has all been in vain. + // + // And they know this. Instead of fixing their freaking protocol, they just + // ask clients to manually construct the expected object path and subscribe + // to the request signal beforehand, making the whole response value of + // the original call COMPLETELY MEANINGLESS. + // + // Furthermore, this is _entirely undocumented_ aside from one tiny + // paragraph under the documentation for the Request interface, and + // anyone would be forgiven for missing it without reading the libportal + // source code. + // + // When in Rome, do as the Romans do, I guess...? + + const callbacks = struct { + fn gotResponseHandle( + source: ?*gobject.Object, + res: *gio.AsyncResult, + _: ?*anyopaque, + ) callconv(.c) void { + const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; + + var err: ?*glib.Error = null; + defer if (err) |err_| err_.free(); + + const params_ = dbus_.callFinish(res, &err) orelse { + if (err) |err_| log.warn("request failed={s} ({})", .{ + err_.f_message orelse "(unknown)", + err_.f_code, + }); + return; + }; + defer params_.unref(); + + // TODO: XDG recommends updating the signal subscription if the actual + // returned request path is not the same as the expected request + // path, to retain compatibility with older versions of XDG portals. + // Although it suffers from the race condition outlined above, + // we should still implement this at some point. + } + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response + fn responded( + dbus: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params_: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self_cb: *GlobalShortcuts = @ptrCast(@alignCast(ud)); + const priv = self_cb.private(); + + // Unsubscribe from the response signal + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } + + var response: u32 = 0; + var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); + params_.get("(u@a{sv})", &response, &vardict); + + switch (response) { + 0 => { + log.debug("request successful", .{}); + method.onResponse(self_cb, vardict.?); + }, + 1 => log.debug("request was cancelled by user", .{}), + 2 => log.warn("request ended unexpectedly", .{}), + else => log.warn("unrecognized response code={}", .{response}), + } + } + }; + + var request_token_buf: Token = undefined; + const request_token = generateToken(&request_token_buf); + + const payload = method.makePayload(self, request_token) orelse return; + const request_path = try self.getRequestPath(request_token); + + const priv = self.private(); + const dbus = priv.dbus_connection.?; + + assert(priv.response_subscription == 0); + priv.response_subscription = dbus.signalSubscribe( + null, + "org.freedesktop.portal.Request", + "Response", + request_path, + null, + .{}, + callbacks.responded, + self, + null, + ); + + dbus.call( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.GlobalShortcuts", + method.name(), + payload, + null, + .{}, + -1, + null, + callbacks.gotResponseHandle, + null, + ); + } + + /// Get the XDG portal request path for the current Ghostty instance. + /// + /// If this sounds like nonsense, see `request` for an explanation as to + /// why we need to do this. + /// + /// Precondition: dbus connection exists, arena setup + fn getRequestPath(self: *Self, token: [:0]const u8) Allocator.Error![:0]const u8 { + const priv = self.private(); + const dbus = priv.dbus_connection.?; + const alloc = priv.arena.?.allocator(); + + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html + // for the syntax XDG portals expect. + + // `getUniqueName` should never return null here as we're using an ordinary + // message bus connection. If it doesn't, something is very wrong + const unique_name = std.mem.span(dbus.getUniqueName().?); + + const object_path = try std.mem.joinZ( + alloc, + "/", + &.{ + "/org/freedesktop/portal/desktop/request", + unique_name[1..], // Remove leading `:` + token, + }, + ); + + // Sanitize the unique name by replacing every `.` with `_`. + // In effect, this will turn a unique name like `:1.192` into `1_192`. + // Valid D-Bus object path components never contain `.`s anyway, so we're + // free to replace all instances of `.` here and avoid extra allocation. + std.mem.replaceScalar(u8, object_path, '.', '_'); + + return object_path; + } + + //--------------------------------------------------------------- + // Property Handlers + + pub fn setDbusConnection( + self: *Self, + dbus_connection: ?*gio.DBusConnection, + ) void { + const priv = self.private(); + + // If we have a prior dbus connection we need to close our prior + // registrations first. + if (priv.dbus_connection) |v| { + self.close(); + v.unref(); + priv.dbus_connection = null; + } + + priv.dbus_connection = null; + if (dbus_connection) |v| { + v.ref(); // Weird this doesn't return self + priv.dbus_connection = v; + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + self.as(gobject.Object).notifyByPspec(properties.@"dbus-connection".impl.param_spec); + } + + fn propConfig( + _: *Self, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh() catch |err| { + log.warn("error refreshing global shortcuts: {}", .{err}); + }; + } + + //--------------------------------------------------------------- + // Signal Handlers + + fn shortcutActivated( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + params: *glib.Variant, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + + // 2nd value in the tuple is the activated shortcut ID + // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated + var shortcut_id: [*:0]const u8 = undefined; + params.getChild(1, "&s", &shortcut_id); + log.debug("activated={s}", .{shortcut_id}); + + const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return; + signals.trigger.impl.emit( + self, + null, + .{&action}, + null, + ); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + // Since we drop references here we may lose access to things like + // dbus connections, so we need to close all our connections right + // away instead of in finalize. + self.close(); + + const priv = self.private(); + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + if (priv.dbus_connection) |v| { + v.unref(); + priv.dbus_connection = null; + } + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.@"dbus-connection".impl, + }); + + // Signals + signals.trigger.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + }; +}; + +const Token = [16]u8; + +/// Generate a random token suitable for use in requests. +fn generateToken(buf: *Token) [:0]const u8 { + // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL + // 7 + 8 + 1 = 16 + return std.fmt.bufPrintZ( + buf, + "ghostty_{x:0<7}", + .{std.crypto.random.int(u28)}, + ) catch unreachable; +} diff --git a/src/apprt/gtk-ng/class/resize_overlay.zig b/src/apprt/gtk-ng/class/resize_overlay.zig index 321d3d565..9bb9a0a7c 100644 --- a/src/apprt/gtk-ng/class/resize_overlay.zig +++ b/src/apprt/gtk-ng/class/resize_overlay.zig @@ -42,8 +42,6 @@ pub const ResizeOverlay = extern struct { Self, c_uint, .{ - .nick = "Duration", - .blurb = "The duration this overlay appears in milliseconds.", .default = 750, .minimum = 250, .maximum = std.math.maxInt(c_uint), @@ -64,8 +62,6 @@ pub const ResizeOverlay = extern struct { Self, c_uint, .{ - .nick = "First Delay", - .blurb = "The delay in milliseconds before any overlay is shown for the first time.", .default = 250, .minimum = 250, .maximum = std.math.maxInt(c_uint), @@ -79,6 +75,19 @@ pub const ResizeOverlay = extern struct { ); }; + pub const label = struct { + pub const name = "label"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("label_text"), + }, + ); + }; + pub const @"overlay-halign" = struct { pub const name = "overlay-halign"; const impl = gobject.ext.defineProperty( @@ -86,8 +95,6 @@ pub const ResizeOverlay = extern struct { Self, gtk.Align, .{ - .nick = "halign", - .blurb = "The alignment of the label.", .default = .center, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -106,8 +113,6 @@ pub const ResizeOverlay = extern struct { Self, gtk.Align, .{ - .nick = "valign", - .blurb = "The alignment of the label.", .default = .center, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -124,6 +129,9 @@ pub const ResizeOverlay = extern struct { /// The label with the text label: *gtk.Label, + /// The text to set on the label when scheduled. + label_text: ?[:0]const u8, + /// The time that the overlay appears. duration: c_uint, @@ -147,7 +155,7 @@ pub const ResizeOverlay = extern struct { pub var offset: c_int = 0; }; - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); const priv = self.private(); @@ -162,9 +170,12 @@ pub const ResizeOverlay = extern struct { /// Set the label for the overlay. This will not show the /// overlay if it is currently hidden; you must call schedule. - pub fn setLabel(self: *Self, label: [:0]const u8) void { + pub fn setLabel(self: *Self, label: ?[:0]const u8) void { const priv = self.private(); - priv.label.setText(label.ptr); + if (priv.label_text) |v| glib.free(@constCast(@ptrCast(v))); + priv.label_text = null; + if (label) |v| priv.label_text = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.label.impl.param_spec); } /// Schedule the overlay to be shown. To avoid flickering during @@ -192,15 +203,26 @@ pub const ResizeOverlay = extern struct { // No matter what our idler is complete with this callback priv.idler = null; - // Show ourselves - self.as(gtk.Widget).setVisible(1); - + // Cancel our previous show timer no matter what. if (priv.timer) |timer| { if (glib.Source.remove(timer) == 0) { log.warn("unable to remove size overlay timer", .{}); } + priv.timer = null; } + // If we have a label to show, show ourselves. If we don't have + // label text, then hide our label. + const text = priv.label_text orelse { + self.as(gtk.Widget).setVisible(0); + return 0; + }; + + // Set our label and show it. + priv.label.setLabel(text); + self.as(gtk.Widget).setVisible(1); + + // Setup the new timer to hide ourselves after the delay. priv.timer = glib.timeoutAdd( priv.duration, onTimer, @@ -228,7 +250,7 @@ pub const ResizeOverlay = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.idler) |v| { if (glib.Source.remove(v) == 0) { @@ -260,6 +282,19 @@ pub const ResizeOverlay = extern struct { ); } + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.label_text) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.label_text = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -271,7 +306,7 @@ pub const ResizeOverlay = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ @@ -287,6 +322,7 @@ pub const ResizeOverlay = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.duration.impl, + properties.label.impl, properties.@"first-delay".impl, properties.@"overlay-halign".impl, properties.@"overlay-valign".impl, @@ -294,6 +330,7 @@ pub const ResizeOverlay = extern struct { // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); } pub const as = C.Class.as; diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig new file mode 100644 index 000000000..5eb0a5472 --- /dev/null +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -0,0 +1,1119 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const i18n = @import("../../../os/main.zig").i18n; +const apprt = @import("../../../apprt.zig"); +const input = @import("../../../input.zig"); +const CoreSurface = @import("../../../Surface.zig"); +const gtk_version = @import("../gtk_version.zig"); +const adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Config = @import("config.zig").Config; +const Application = @import("application.zig").Application; +const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const Surface = @import("surface.zig").Surface; + +const log = std.log.scoped(.gtk_ghostty_split_tree); + +pub const SplitTree = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = gtk.Box; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTree", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + /// The active surface is the surface that should be receiving all + /// surface-targeted actions. This is usually the focused surface, + /// but may also not be focused if the user has selected a non-surface + /// widget. + pub const @"active-surface" = struct { + pub const name = "active-surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface, + .{ + .getter = getActiveSurface, + }, + ), + }, + ); + }; + + pub const @"has-surfaces" = struct { + pub const name = "has-surfaces"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getHasSurfaces, + }, + ), + }, + ); + }; + + pub const tree = struct { + pub const name = "tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .accessor = .{ + .getter = getTreeValue, + .setter = setTreeValue, + }, + }, + ); + }; + }; + + pub const signals = struct { + /// Emitted whenever the tree property has changed, with access + /// to the previous and new values. + pub const changed = struct { + pub const name = "changed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{ ?*const Surface.Tree, ?*const Surface.Tree }, + void, + ); + }; + }; + + const Private = struct { + /// The tree datastructure containing all of our surface views. + tree: ?*Surface.Tree, + + // Template bindings + tree_bin: *adw.Bin, + + /// Last focused surface in the tree. We need this to handle various + /// tree change states. + last_focused: WeakRef(Surface) = .empty, + + /// The source that we use to rebuild the tree. This is also + /// used to debounce updates. + rebuild_source: ?c_uint = null, + + /// Tracks whether we want a rebuild to happen at the next tick + /// that our surface tree has no surfaces with parents. See the + /// propTree function for a lot more details. + rebuild_pending: bool, + + /// Used to store state about a pending surface close for the + /// close dialog. + pending_close: ?Surface.Tree.Node.Handle, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Initialize our actions + self.initActions(); + + // Initialize some basic state + const priv = self.private(); + priv.pending_close = null; + } + + fn initActions(self: *Self) void { + // The set of actions. Each action has (in order): + // [0] The action name + // [1] The callback function + // [2] The glib.VariantType of the parameter + // + // For action names: + // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html + const actions = .{ + // All of these will eventually take a target surface parameter. + // For now all our targets originate from the focused surface. + .{ "new-left", actionNewLeft, null }, + .{ "new-right", actionNewRight, null }, + .{ "new-up", actionNewUp, null }, + .{ "new-down", actionNewDown, null }, + + .{ "equalize", actionEqualize, null }, + }; + + // We need to collect our actions into a group since we're just + // a plain widget that doesn't implement ActionGroup directly. + const group = gio.SimpleActionGroup.new(); + errdefer group.unref(); + const map = group.as(gio.ActionMap); + inline for (actions) |entry| { + const action = gio.SimpleAction.new( + entry[0], + entry[2], + ); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *Self, + entry[1], + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } + + self.as(gtk.Widget).insertActionGroup( + "split-tree", + group.as(gio.ActionGroup), + ); + } + + /// Create a new split in the given direction from the currently + /// active surface. + /// + /// If the tree is empty this will create a new tree with a new surface + /// and ignore the direction. + /// + /// The parent will be used as the parent of the surface regardless of + /// if that parent is in this split tree or not. This allows inheriting + /// surface properties from anywhere. + pub fn newSplit( + self: *Self, + direction: Surface.Tree.Split.Direction, + parent_: ?*Surface, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + + // Create our new surface. + const surface: *Surface = .new(); + defer surface.unref(); + _ = surface.refSink(); + + // Inherit properly if we were asked to. + if (parent_) |p| { + if (p.core()) |core| { + surface.setParent(core); + } + } + + // Create our tree + var single_tree = try Surface.Tree.init(alloc, surface); + defer single_tree.deinit(); + + // We want to move our focus to the new surface no matter what. + // But we need to be careful to restore state if we fail. + const old_last_focused = self.private().last_focused.get(); + defer if (old_last_focused) |v| v.unref(); // unref strong ref from get + self.private().last_focused.set(surface); + errdefer self.private().last_focused.set(old_last_focused); + + // If we have no tree yet, then this becomes our tree and we're done. + const old_tree = self.getTree() orelse { + self.setTree(&single_tree); + return; + }; + + // The handle we create the split relative to. Today this is the active + // surface but this might be the handle of the given parent if we want. + const handle = self.getActiveSurfaceHandle() orelse 0; + + // Create our split! + var new_tree = try old_tree.split( + alloc, + handle, + direction, + 0.5, // Always split equally for new splits + &single_tree, + ); + defer new_tree.deinit(); + log.debug( + "new split at={} direction={} old_tree={} new_tree={}", + .{ handle, direction, old_tree, &new_tree }, + ); + + // Replace our tree + self.setTree(&new_tree); + } + + /// Move focus from the currently focused surface to the given + /// direction. Returns true if focus switched to a new surface. + pub fn goto(self: *Self, to: Surface.Tree.Goto) bool { + const tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + const target = if (tree.goto( + Application.default().allocator(), + active, + to, + )) |handle_| + handle_ orelse return false + else |err| switch (err) { + // Nothing we can do in this scenario. This is highly unlikely + // since split trees don't use that much memory. The application + // is probably about to crash in other ways. + error.OutOfMemory => return false, + }; + + // If we aren't changing targets then we did nothing. + if (active == target) return false; + + // Get the surface at the target location and grab focus. + const surface = tree.nodes[target].leaf; + surface.grabFocus(); + + return true; + } + + fn disconnectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + + fn connectSurfaceHandlers(self: *Self) void { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = gobject.Object.signals.notify.connect( + surface, + *Self, + propSurfaceFocused, + self, + .{ .detail = "focused" }, + ); + _ = gobject.Object.signals.notify.connect( + surface.as(gtk.Widget), + *Self, + propSurfaceParent, + self, + .{ .detail = "parent" }, + ); + } + } + + //--------------------------------------------------------------- + // Properties + + /// Get the currently active surface. See the "active-surface" property. + /// This does not ref the value. + pub fn getActiveSurface(self: *Self) ?*Surface { + const tree = self.getTree() orelse return null; + const handle = self.getActiveSurfaceHandle() orelse return null; + return tree.nodes[handle].leaf; + } + + fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle { + const tree = self.getTree() orelse return null; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.getFocused()) return entry.handle; + } + + return null; + } + + /// Returns the last focused surface in the tree. + pub fn getLastFocusedSurface(self: *Self) ?*Surface { + const surface = self.private().last_focused.get() orelse return null; + // We unref because get() refs the surface. We don't use the weakref + // in a multi-threaded context so this is safe. + surface.unref(); + return surface; + } + + /// Returns whether any of the surfaces in the tree have a parent. + /// This is important because we can only rebuild the widget tree + /// when every surface has no parent. + fn getTreeHasParents(self: *Self) bool { + const tree: *const Surface.Tree = self.getTree() orelse &.empty; + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + if (surface.as(gtk.Widget).getParent() != null) return true; + } + + return false; + } + + pub fn getHasSurfaces(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return !tree.isEmpty(); + } + + /// Get the tree data model that we're showing in this widget. This + /// does not clone the tree. + pub fn getTree(self: *Self) ?*Surface.Tree { + return self.private().tree; + } + + /// Set the tree data model that we're showing in this widget. This + /// will clone the given tree. + pub fn setTree(self: *Self, tree_: ?*const Surface.Tree) void { + const priv = self.private(); + + // We always normalize our tree parameter so that empty trees + // become null so that we don't have to deal with callers being + // confused about that. + const tree: ?*const Surface.Tree = tree: { + const tree = tree_ orelse break :tree null; + if (tree.isEmpty()) break :tree null; + break :tree tree; + }; + + // Emit the signal so that handlers can witness both the before and + // after values of the tree. + signals.changed.impl.emit( + self, + null, + .{ priv.tree, tree }, + null, + ); + + if (priv.tree) |old_tree| { + self.disconnectSurfaceHandlers(); + ext.boxedFree(Surface.Tree, old_tree); + priv.tree = null; + } + + if (tree) |new_tree| { + assert(priv.tree == null); + assert(!new_tree.isEmpty()); + priv.tree = ext.boxedCopy(Surface.Tree, new_tree); + self.connectSurfaceHandlers(); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + + fn getTreeValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().tree, + ); + } + + fn setTreeValue(self: *Self, value: *const gobject.Value) void { + self.setTree(gobject.ext.Value.get( + value, + ?*Surface.Tree, + )); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + priv.last_focused.set(null); + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.tree) |tree| { + ext.boxedFree(Surface.Tree, tree); + priv.tree = null; + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Signal handlers + + pub fn actionNewLeft( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .left, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionNewRight( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .right, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionNewUp( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .up, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionNewDown( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + self.newSplit( + .down, + self.getActiveSurface(), + ) catch |err| { + log.warn("new split failed error={}", .{err}); + }; + } + + pub fn actionEqualize( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + _ = parameter_; + + const old_tree = self.getTree() orelse return; + var new_tree = old_tree.equalize(Application.default().allocator()) catch |err| { + log.warn("unable to equalize tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + } + + fn surfaceCloseRequest( + surface: *Surface, + scope: *const Surface.CloseScope, + self: *Self, + ) callconv(.c) void { + switch (scope.*) { + // Handled upstream... this will probably go away for widget + // actions eventually. + .window, .tab => return, + + // Remove the surface from the tree. + .surface => {}, + } + + const core = surface.core() orelse return; + + // Reset our pending close state + const priv = self.private(); + priv.pending_close = null; + + // Find the surface in the tree to verify this is valid and + // set our pending close handle. + priv.pending_close = handle: { + const tree = self.getTree() orelse return; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == surface) { + break :handle entry.handle; + } + } + + return; + }; + + // If we don't need to confirm then just close immediately. + if (!core.needsConfirmQuit()) { + closeConfirmationClose( + null, + self, + ); + return; + } + + // Show a confirmation dialog + const dialog: *CloseConfirmationDialog = .new(.surface); + _ = CloseConfirmationDialog.signals.@"close-request".connect( + dialog, + *Self, + closeConfirmationClose, + self, + .{}, + ); + dialog.present(self.as(gtk.Widget)); + } + + fn closeConfirmationClose( + _: ?*CloseConfirmationDialog, + self: *Self, + ) callconv(.c) void { + // Get the handle we're closing + const priv = self.private(); + const handle = priv.pending_close orelse return; + priv.pending_close = null; + + // Figure out our next focus target. The next focus target is + // always the "previous" surface unless we're the leftmost then + // its the next. + const old_tree = self.getTree() orelse return; + const next_focus: ?*Surface = next_focus: { + const alloc = Application.default().allocator(); + const next_handle: Surface.Tree.Node.Handle = + (old_tree.goto(alloc, handle, .previous) catch null) orelse + (old_tree.goto(alloc, handle, .next) catch null) orelse + break :next_focus null; + if (next_handle == handle) break :next_focus null; + + // Note: we don't need to ref this or anything because its + // guaranteed to remain in the new tree since its not part + // of the handle we're removing. + break :next_focus old_tree.nodes[next_handle].leaf; + }; + + // Remove it from the tree. + var new_tree = old_tree.remove( + Application.default().allocator(), + handle, + ) catch |err| { + log.warn("unable to remove surface from tree: {}", .{err}); + return; + }; + defer new_tree.deinit(); + self.setTree(&new_tree); + + // Grab focus. We have to set this on the "last focused" because our + // focus will be set when the tree is redrawn. + if (next_focus) |v| priv.last_focused.set(v); + } + + fn propSurfaceFocused( + surface: *Surface, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // We never CLEAR our last_focused because the property is specifically + // the last focused surface. We let the weakref clear itself when + // the surface is destroyed. + if (!surface.getFocused()) return; + self.private().last_focused.set(surface); + + // Our active surface probably changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + + fn propSurfaceParent( + _: *gtk.Widget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + + // If we're not waiting to rebuild then ignore this. + if (!priv.rebuild_pending) return; + + // If any parents still exist in our tree then don't do anything. + if (self.getTreeHasParents()) return; + + // Schedule the rebuild. Note, I tried to do this immediately (not + // on an idle tick) and it didn't work and had obvious rendering + // glitches. Something to look into in the future. + assert(priv.rebuild_source == null); + priv.rebuild_pending = false; + priv.rebuild_source = glib.idleAdd(onRebuild, self); + } + + fn propTree( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // If we were planning a rebuild, always remove that so we can + // start from a clean slate. + if (priv.rebuild_source) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove rebuild source", .{}); + } + priv.rebuild_source = null; + } + + // We need to wait for all our previous surfaces to lose their + // parent before adding them to a new one. I'm not sure if its a GTK + // bug, but manually forcing an unparent of all prior surfaces AND + // adding them to a new parent in the same tick causes the GLArea + // to break (it seems). I didn't investigate too deeply. + // + // Note, we also can't just defer to an idle tick (via idleAdd) because + // sometimes it takes more than one tick for all our surfaces to + // lose their parent. + // + // To work around this issue, if we have any surfaces that have + // a parent, we set the build pending flag and wait for the tree + // to be fully parent-free before building. + priv.rebuild_pending = self.getTreeHasParents(); + + // Reset our prior bin. This will force all prior surfaces to + // unparent... eventually. + priv.tree_bin.setChild(null); + + // If none of the surfaces we plan on drawing require an unparent + // then we can setup our tree immediately. Otherwise, it'll happen + // via the `propSurfaceParent` callback. + if (!priv.rebuild_pending and priv.rebuild_source == null) { + priv.rebuild_source = glib.idleAdd( + onRebuild, + self, + ); + } + + // Dependent properties + self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + } + + fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + + // Always mark our rebuild source as null since we're done. + const priv = self.private(); + priv.rebuild_source = null; + + // Prior to rebuilding the tree, our surface tree must be + // comprised of fully orphaned surfaces. + assert(!self.getTreeHasParents()); + + // Rebuild our tree + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + if (!tree.isEmpty()) { + priv.tree_bin.setChild(self.buildTree(tree, 0)); + } + + // If we have a last focused surface, we need to refocus it, because + // during the frame between setting the bin to null and rebuilding, + // GTK will reset our focus state (as it should!) + if (priv.last_focused.get()) |v| { + defer v.unref(); + v.grabFocus(); + } + + // Our active surface may have changed + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + + return 0; + } + + /// Builds the widget tree associated with a surface split tree. + /// + /// The final returned widget is expected to be a floating reference, + /// ready to be attached to a parent widget. + fn buildTree( + self: *Self, + tree: *const Surface.Tree, + current: Surface.Tree.Node.Handle, + ) *gtk.Widget { + return switch (tree.nodes[current]) { + .leaf => |v| v.as(gtk.Widget), + .split => |s| SplitTreeSplit.new( + current, + &s, + self.buildTree(tree, s.left), + self.buildTree(tree, s.right), + ).as(gtk.Widget), + }; + } + + //--------------------------------------------------------------- + // Class + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(Surface); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree", + }), + ); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"active-surface".impl, + properties.@"has-surfaces".impl, + properties.tree.impl, + }); + + // Bindings + class.bindTemplateChildPrivate("tree_bin", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_tree", &propTree); + + // Signals + signals.changed.impl.register(.{}); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; + +/// This is an internal-only widget that represents a split in the +/// split tree. This is a wrapper around gtk.Paned that allows us to handle +/// ratio (0 to 1) based positioning of the split, and also allows us to +/// write back the updated ratio to the split tree when the user manually +/// adjusts the split position. +/// +/// Since this is internal, it expects to be nested within a SplitTree and +/// will use `getAncestor` to find the SplitTree it belongs to. +/// +/// This is an _immutable_ widget. It isn't meant to be updated after +/// creation. As such, there are no properties or APIs to change the split, +/// access the paned, etc. +const SplitTreeSplit = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySplitTreeSplit", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + const Private = struct { + /// The handle of the node in the tree that this split represents. + /// Assumed to be correct. + handle: Surface.Tree.Node.Handle, + + /// Source to handle repositioning the split when properties change. + idle: ?c_uint = null, + + // Template bindings + paned: *gtk.Paned, + + pub var offset: c_int = 0; + }; + + /// Create a new split. + /// + /// The reason we don't use GObject properties here is because this is + /// an immutable widget and we don't want to deal with the overhead of + /// all the boilerplate for properties, signals, bindings, etc. + pub fn new( + handle: Surface.Tree.Node.Handle, + split: *const Surface.Tree.Split, + start_child: *gtk.Widget, + end_child: *gtk.Widget, + ) *Self { + const self = gobject.ext.newInstance(Self, .{}); + const priv = self.private(); + priv.handle = handle; + + // Setup our paned fields + const paned = priv.paned; + paned.setStartChild(start_child); + paned.setEndChild(end_child); + paned.as(gtk.Orientable).setOrientation(switch (split.layout) { + .horizontal => .horizontal, + .vertical => .vertical, + }); + + // Signals and so on are setup in the template. + + return self; + } + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn refresh(self: *Self) void { + const priv = self.private(); + if (priv.idle == null) priv.idle = glib.idleAdd( + onIdle, + self, + ); + } + + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + const paned = priv.paned; + + // Our idle source is always over + priv.idle = null; + + // Get our split. This is the most dangerous part of this entire + // widget. We assume that this widget is always a child of a + // SplitTree, we assume that our handle is valid, and we assume + // the handle is always a split node. + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse return 0; + const tree = split_tree.getTree() orelse return 0; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle].split; + + // Current, min, and max positions as pixels. + const pos = paned.getPosition(); + const min = min: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "min-position", + &val, + ); + break :min gobject.ext.Value.get(&val, c_int); + }; + const max = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "max-position", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int); + }; + const pos_set: bool = max: { + var val = gobject.ext.Value.new(c_int); + defer val.unset(); + gobject.Object.getProperty( + paned.as(gobject.Object), + "position-set", + &val, + ); + break :max gobject.ext.Value.get(&val, c_int) != 0; + }; + + // We don't actually use min, but we don't expect this to ever + // be non-zero, so let's add an assert to ensure that. + assert(min == 0); + + // If our max is zero then we can't do any math. I don't know + // if this is possible but I suspect it can be if you make a nested + // split completely minimized. + if (max == 0) return 0; + + // Determine our current ratio. + const current_ratio: f64 = ratio: { + const pos_f64: f64 = @floatFromInt(pos); + const max_f64: f64 = @floatFromInt(max); + break :ratio pos_f64 / max_f64; + }; + const desired_ratio: f64 = @floatCast(split.ratio); + + // If our ratio is close enough to our desired ratio, then + // we ignore the update. This is to avoid constant split updates + // for lossy floating point math. + if (std.math.approxEqAbs( + f64, + current_ratio, + desired_ratio, + 0.001, + )) { + return 0; + } + + // If we're out of bounds, then we need to either set the position + // to what we expect OR update our expected ratio. + + // If we've never set the position, then we set it to the desired. + if (!pos_set) { + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + return 0; + } + + // If we've set the position, then this is a manual human update + // and we need to write our update back to the tree. + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + + return 0; + } + + //--------------------------------------------------------------- + // Signal handlers + + fn propPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + fn propMaxPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + fn propMinPosition( + _: *gtk.Paned, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + self.refresh(); + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.idle) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle = null; + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + const C = Common(Self, Private); + pub const as = C.as; + pub const ref = C.ref; + pub const unref = C.unref; + const private = C.private; + + pub const Class = extern struct { + parent_class: Parent.Class, + var parent: *Parent.Class = undefined; + pub const Instance = Self; + + fn init(class: *Class) callconv(.c) void { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "split-tree-split", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("paned", .{}); + + // Template Callbacks + class.bindTemplateCallback("notify_max_position", &propMaxPosition); + class.bindTemplateCallback("notify_min_position", &propMinPosition); + class.bindTemplateCallback("notify_position", &propPosition); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 3242337c2..8487b24b0 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -9,6 +9,7 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); const apprt = @import("../../../apprt.zig"); +const datastruct = @import("../../../datastruct/main.zig"); const font = @import("../../../font/main.zig"); const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); @@ -42,6 +43,9 @@ pub const Surface = extern struct { .private = .{ .Type = Private, .offset = &Private.offset }, }); + /// A SplitTree implementation that stores surfaces. + pub const Tree = datastruct.SplitTree(Self); + pub const properties = struct { pub const config = struct { pub const name = "config"; @@ -50,8 +54,6 @@ pub const Surface = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this surface is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -64,8 +66,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Child Exited", - .blurb = "True when the child process has exited.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -84,8 +84,6 @@ pub const Surface = extern struct { Self, ?*Size, .{ - .nick = "Default Size", - .blurb = "The default size of the window for this surface.", .accessor = C.privateBoxedFieldAccessor("default_size"), }, ); @@ -98,8 +96,6 @@ pub const Surface = extern struct { Self, ?*font.face.DesiredSize, .{ - .nick = "Desired Font Size", - .blurb = "The desired font size, only affects initialization.", .accessor = C.privateBoxedFieldAccessor("font_size_request"), }, ); @@ -112,8 +108,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Focused", - .blurb = "The focused state of the surface.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -132,8 +126,6 @@ pub const Surface = extern struct { Self, ?*Size, .{ - .nick = "Minimum Size", - .blurb = "The minimum size of the surface.", .accessor = C.privateBoxedFieldAccessor("min_size"), }, ); @@ -146,14 +138,14 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Mouse Hidden", - .blurb = "Whether the mouse cursor should be hidden.", .default = false, - .accessor = gobject.ext.privateFieldAccessor( + .accessor = gobject.ext.typedAccessor( Self, - Private, - &Private.offset, - "mouse_hidden", + bool, + .{ + .getter = getMouseHidden, + .setter = setMouseHidden, + }, ), }, ); @@ -166,14 +158,14 @@ pub const Surface = extern struct { Self, terminal.MouseShape, .{ - .nick = "Mouse Shape", - .blurb = "The current mouse shape to show for the surface.", .default = .text, - .accessor = gobject.ext.privateFieldAccessor( + .accessor = gobject.ext.typedAccessor( Self, - Private, - &Private.offset, - "mouse_shape", + terminal.MouseShape, + .{ + .getter = getMouseShape, + .setter = setMouseShape, + }, ), }, ); @@ -188,8 +180,6 @@ pub const Surface = extern struct { Self, ?[:0]const u8, .{ - .nick = "Mouse Hover URL", - .blurb = "The URL the mouse is currently hovering over (if any).", .default = null, .accessor = C.privateStringFieldAccessor("mouse_hover_url"), }, @@ -205,8 +195,6 @@ pub const Surface = extern struct { Self, ?[:0]const u8, .{ - .nick = "Working Directory", - .blurb = "The current working directory as reported by core.", .default = null, .accessor = C.privateStringFieldAccessor("pwd"), }, @@ -222,8 +210,6 @@ pub const Surface = extern struct { Self, ?[:0]const u8, .{ - .nick = "Title", - .blurb = "The title of the surface.", .default = null, .accessor = C.privateStringFieldAccessor("title"), }, @@ -237,8 +223,6 @@ pub const Surface = extern struct { Self, bool, .{ - .nick = "Zoom", - .blurb = "Whether the surface should be zoomed.", .default = false, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -327,6 +311,18 @@ pub const Surface = extern struct { ); }; + /// Emitted just prior to the context menu appearing. + pub const menu = struct { + pub const name = "menu"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + /// Emitted when the focus wants to be brought to the top and /// focused. pub const @"present-request" = struct { @@ -462,6 +458,7 @@ pub const Surface = extern struct { // Template binds child_exited_overlay: *ChildExited, + context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, progress_bar_overlay: *gtk.ProgressBar, @@ -553,6 +550,11 @@ pub const Surface = extern struct { ); } + pub fn toggleCommandPalette(self: *Self) bool { + // TODO: pass the surface with the action + return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; + } + /// Set the current progress report state. pub fn setProgressReport( self: *Self, @@ -1145,7 +1147,7 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Virtual Methods - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); const priv = self.private(); @@ -1194,7 +1196,7 @@ pub const Surface = extern struct { self.propConfig(undefined, null); } - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -1218,7 +1220,7 @@ pub const Surface = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.core_surface) |v| { // Remove ourselves from the list of known surfaces in the app. @@ -1273,11 +1275,34 @@ pub const Surface = extern struct { return self.private().title; } + /// Set the title for this surface, copies the value. + pub fn setTitle(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title) |v| glib.free(@constCast(@ptrCast(v))); + priv.title = null; + if (title) |v| priv.title = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec); + } + /// Returns the pwd property without a copy. pub fn getPwd(self: *Self) ?[:0]const u8 { return self.private().pwd; } + /// Set the pwd for this surface, copies the value. + pub fn setPwd(self: *Self, pwd: ?[:0]const u8) void { + const priv = self.private(); + if (priv.pwd) |v| glib.free(@constCast(@ptrCast(v))); + priv.pwd = null; + if (pwd) |v| priv.pwd = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + } + + /// Returns the focus state of this surface. + pub fn getFocused(self: *Self) bool { + return self.private().focused; + } + /// Change the configuration for this surface. pub fn setConfig(self: *Self, config: *Config) void { const priv = self.private(); @@ -1330,6 +1355,34 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"min-size".impl.param_spec); } + pub fn getMouseShape(self: *Self) terminal.MouseShape { + return self.private().mouse_shape; + } + + pub fn setMouseShape(self: *Self, shape: terminal.MouseShape) void { + const priv = self.private(); + priv.mouse_shape = shape; + self.as(gobject.Object).notifyByPspec(properties.@"mouse-shape".impl.param_spec); + } + + pub fn getMouseHidden(self: *Self) bool { + return self.private().mouse_hidden; + } + + pub fn setMouseHidden(self: *Self, hidden: bool) void { + const priv = self.private(); + priv.mouse_hidden = hidden; + self.as(gobject.Object).notifyByPspec(properties.@"mouse-hidden".impl.param_spec); + } + + pub fn setMouseHoverUrl(self: *Self, url: ?[:0]const u8) void { + const priv = self.private(); + if (priv.mouse_hover_url) |v| glib.free(@constCast(@ptrCast(v))); + priv.mouse_hover_url = null; + if (url) |v| priv.mouse_hover_url = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -1473,6 +1526,16 @@ pub const Surface = extern struct { self.close(.{ .surface = false }); } + fn contextMenuClosed( + _: *gtk.PopoverMenu, + self: *Self, + ) callconv(.c) void { + // When the context menu closes, it moves focus back to the tab + // bar if there are tabs. That's not correct. We need to grab it + // on the surface. + self.grabFocus(); + } + fn dtDrop( _: *gtk.DropTarget, value: *gobject.Value, @@ -1604,6 +1667,7 @@ pub const Surface = extern struct { priv.focused = true; priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1611,6 +1675,7 @@ pub const Surface = extern struct { priv.focused = false; priv.im_context.as(gtk.IMContext).focusOut(); _ = glib.idleAddOnce(idleFocus, self.ref()); + self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); } /// The focus callback must be triggered on an idle loop source because @@ -1647,9 +1712,9 @@ pub const Surface = extern struct { } // Report the event + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); const consumed = if (priv.core_surface) |surface| consumed: { const gtk_mods = event.getModifierState(); - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); const mods = gtk_key.translateMods(gtk_mods); break :consumed surface.mouseButtonCallback( .press, @@ -1661,10 +1726,28 @@ pub const Surface = extern struct { }; } else false; - // TODO: context menu - _ = consumed; - _ = x; - _ = y; + // If a right click isn't consumed, mouseButtonCallback selects the hovered + // word and returns false. We can use this to handle the context menu + // opening under normal scenarios. + if (!consumed and button == .right) { + signals.menu.impl.emit( + self, + null, + .{}, + null, + ); + + const rect: gdk.Rectangle = .{ + .f_x = @intFromFloat(x), + .f_y = @intFromFloat(y), + .f_width = 1, + .f_height = 1, + }; + + const popover = priv.context_menu.as(gtk.Popover); + popover.setPointingTo(&rect); + popover.popup(); + } } fn gcMouseUp( @@ -2234,6 +2317,7 @@ pub const Surface = extern struct { const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; + pub const refSink = C.refSink; pub const unref = C.unref; const private = C.private; @@ -2242,7 +2326,7 @@ pub const Surface = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( @@ -2259,6 +2343,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("url_left", .{}); class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("child_exited_overlay", .{}); + class.bindTemplateChildPrivate("context_menu", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); class.bindTemplateChildPrivate("drop_target", .{}); @@ -2288,6 +2373,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("url_mouse_enter", &ecUrlMouseEnter); class.bindTemplateCallback("url_mouse_leave", &ecUrlMouseLeave); class.bindTemplateCallback("child_exited_close", &childExitedClose); + class.bindTemplateCallback("context_menu_closed", &contextMenuClosed); class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); @@ -2315,6 +2401,7 @@ pub const Surface = extern struct { signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-write".impl.register(.{}); signals.init.impl.register(.{}); + signals.menu.impl.register(.{}); signals.@"present-request".impl.register(.{}); signals.@"toggle-fullscreen".impl.register(.{}); signals.@"toggle-maximize".impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/surface_child_exited.zig b/src/apprt/gtk-ng/class/surface_child_exited.zig index 3bf29285f..bdee81397 100644 --- a/src/apprt/gtk-ng/class/surface_child_exited.zig +++ b/src/apprt/gtk-ng/class/surface_child_exited.zig @@ -40,8 +40,6 @@ const SurfaceChildExitedBanner = extern struct { Self, ?*apprt.surface.Message.ChildExited, .{ - .nick = "Data", - .blurb = "The child exit data.", .accessor = C.privateBoxedFieldAccessor("data"), }, ); @@ -72,7 +70,7 @@ const SurfaceChildExitedBanner = extern struct { pub var offset: c_int = 0; }; - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); } @@ -134,7 +132,7 @@ const SurfaceChildExitedBanner = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -146,7 +144,7 @@ const SurfaceChildExitedBanner = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.data) |v| { glib.ext.destroy(v); @@ -170,7 +168,7 @@ const SurfaceChildExitedBanner = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ @@ -255,7 +253,7 @@ const SurfaceChildExitedNoop = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { _ = class; signals.@"close-request".impl.register(.{}); } diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 9787e991e..428ce72d6 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -18,6 +18,7 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const log = std.log.scoped(.gtk_ghostty_window); @@ -35,7 +36,7 @@ pub const Tab = extern struct { }); pub const properties = struct { - /// The active surface is the focus that should be receiving all + /// The active surface is the surface that should be receiving all /// surface-targeted actions. This is usually the focused surface, /// but may also not be focused if the user has selected a non-surface /// widget. @@ -46,8 +47,6 @@ pub const Tab = extern struct { Self, ?*Surface, .{ - .nick = "Active Surface", - .blurb = "The currently active surface.", .accessor = gobject.ext.typedAccessor( Self, ?*Surface, @@ -66,13 +65,29 @@ pub const Tab = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this surface is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); }; + pub const @"surface-tree" = struct { + pub const name = "surface-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface.Tree, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*Surface.Tree, + .{ + .getter = getSurfaceTree, + }, + ), + }, + ); + }; + pub const title = struct { pub const name = "title"; pub const get = impl.get; @@ -82,8 +97,6 @@ pub const Tab = extern struct { Self, ?[:0]const u8, .{ - .nick = "Title", - .blurb = "The title of the active surface.", .default = null, .accessor = C.privateStringFieldAccessor("title"), }, @@ -117,7 +130,7 @@ pub const Tab = extern struct { surface_bindings: *gobject.BindingGroup, // Template bindings - surface: *Surface, + split_tree: *SplitTree, pub var offset: c_int = 0; }; @@ -125,15 +138,13 @@ pub const Tab = extern struct { /// Set the parent of this tab page. This only affects the first surface /// ever created for a tab. If a surface was already created this does /// nothing. - pub fn setParent( - self: *Self, - parent: *CoreSurface, - ) void { - const priv = self.private(); - priv.surface.setParent(parent); + pub fn setParent(self: *Self, parent: *CoreSurface) void { + if (self.getActiveSurface()) |surface| { + surface.setParent(parent); + } } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // If our configuration is null then we get the configuration @@ -153,13 +164,15 @@ pub const Tab = extern struct { .{}, ); - // TODO: Eventually this should be set dynamically based on the - // current active surface. - priv.surface_bindings.setSource(priv.surface.as(gobject.Object)); - - // We need to do this so that the title initializes properly, - // I think because its a dynamic getter. - self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + // Create our initial surface in the split tree. + priv.split_tree.newSplit(.right, null) catch |err| switch (err) { + error.OutOfMemory => { + // TODO: We should make our "no surfaces" state more aesthetically + // pleasing and show something like an "Oops, something went wrong" + // message. For now, this is incredibly unlikely. + @panic("oom"); + }, + }; } //--------------------------------------------------------------- @@ -167,15 +180,26 @@ pub const Tab = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - pub fn getActiveSurface(self: *Self) *Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { + return self.getSplitTree().getActiveSurface(); + } + + /// Get the surface tree of this tab. + pub fn getSurfaceTree(self: *Self) ?*Surface.Tree { const priv = self.private(); - return priv.surface; + return priv.split_tree.getTree(); + } + + /// Get the split tree widget that is in this tab. + pub fn getSplitTree(self: *Self) *SplitTree { + const priv = self.private(); + return priv.split_tree; } /// Returns true if this tab needs confirmation before quitting based /// on the various Ghostty configurations. pub fn getNeedsConfirmQuit(self: *Self) bool { - const surface = self.getActiveSurface(); + const surface = self.getActiveSurface() orelse return false; const core_surface = surface.core() orelse return false; return core_surface.needsConfirmQuit(); } @@ -183,7 +207,7 @@ pub const Tab = extern struct { //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { v.unref(); @@ -202,7 +226,7 @@ pub const Tab = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.title) |v| { glib.free(@constCast(@ptrCast(v))); @@ -218,27 +242,40 @@ pub const Tab = extern struct { //--------------------------------------------------------------- // Signal handlers - fn surfaceCloseRequest( - _: *Surface, - scope: *const Surface.CloseScope, + fn propSplitTree( + _: *SplitTree, + _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - switch (scope.*) { - // Handled upstream... we don't control our window close. - .window => return, + self.as(gobject.Object).notifyByPspec(properties.@"surface-tree".impl.param_spec); - // Presently both the same, results in the tab closing. - .surface, .tab => { - signals.@"close-request".impl.emit( - self, - null, - .{}, - null, - ); - }, + // If our tree is empty we close the tab. + const tree: *const Surface.Tree = self.getSurfaceTree() orelse &.empty; + if (tree.isEmpty()) { + signals.@"close-request".impl.emit( + self, + null, + .{}, + null, + ); + return; } } + fn propActiveSurface( + _: *SplitTree, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + priv.surface_bindings.setSource(null); + if (self.getActiveSurface()) |surface| { + priv.surface_bindings.setSource(surface.as(gobject.Object)); + } + + self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -250,7 +287,8 @@ pub const Tab = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { + gobject.ext.ensureType(SplitTree); gobject.ext.ensureType(Surface); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -265,14 +303,16 @@ pub const Tab = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"surface-tree".impl, properties.title.impl, }); // Bindings - class.bindTemplateChildPrivate("surface", .{}); + class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks - class.bindTemplateCallback("surface_close_request", &surfaceCloseRequest); + class.bindTemplateCallback("notify_active_surface", &propActiveSurface); + class.bindTemplateCallback("notify_tree", &propSplitTree); // Signals signals.@"close-request".impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 127aff1e1..eb41b61d0 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -11,6 +11,7 @@ const gtk = @import("gtk"); const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const configpkg = @import("../../../config.zig"); +const TitlebarStyle = configpkg.Config.GtkTitlebarStyle; const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); const ext = @import("../ext.zig"); @@ -22,9 +23,12 @@ const Common = @import("../class.zig").Common; const Config = @import("config.zig").Config; const Application = @import("application.zig").Application; const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog; +const SplitTree = @import("split_tree.zig").SplitTree; const Surface = @import("surface.zig").Surface; const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; +const CommandPalette = @import("command_palette.zig").CommandPalette; +const WeakRef = @import("../weak_ref.zig").WeakRef; const log = std.log.scoped(.gtk_ghostty_window); @@ -52,8 +56,6 @@ pub const Window = extern struct { Self, ?*Surface, .{ - .nick = "Active Surface", - .blurb = "The currently active surface.", .accessor = gobject.ext.typedAccessor( Self, ?*Surface, @@ -72,8 +74,6 @@ pub const Window = extern struct { Self, ?*Config, .{ - .nick = "Config", - .blurb = "The configuration that this surface is using.", .accessor = C.privateObjFieldAccessor("config"), }, ); @@ -86,8 +86,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Debug", - .blurb = "True if runtime safety checks are enabled.", .default = build_config.is_debug, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = struct { @@ -100,6 +98,25 @@ pub const Window = extern struct { ); }; + pub const @"titlebar-style" = struct { + pub const name = "titlebar-style"; + const impl = gobject.ext.defineProperty( + name, + Self, + TitlebarStyle, + .{ + .default = .native, + .accessor = gobject.ext.typedAccessor( + Self, + TitlebarStyle, + .{ + .getter = Self.getTitlebarStyle, + }, + ), + }, + ); + }; + pub const @"headerbar-visible" = struct { pub const name = "headerbar-visible"; const impl = gobject.ext.defineProperty( @@ -107,8 +124,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Headerbar Visible", - .blurb = "True if the headerbar is visible.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getHeaderbarVisible, @@ -117,23 +132,6 @@ pub const Window = extern struct { ); }; - pub const @"background-opaque" = struct { - pub const name = "background-opaque"; - const impl = gobject.ext.defineProperty( - name, - Self, - bool, - .{ - .nick = "Background Opaque", - .blurb = "True if the background should be opaque.", - .default = true, - .accessor = gobject.ext.typedAccessor(Self, bool, .{ - .getter = Self.getBackgroundOpaque, - }), - }, - ); - }; - pub const @"quick-terminal" = struct { pub const name = "quick-terminal"; const impl = gobject.ext.defineProperty( @@ -141,8 +139,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Quick Terminal", - .blurb = "Whether this window behaves like a quick terminal.", .default = true, .accessor = gobject.ext.privateFieldAccessor( Self, @@ -161,8 +157,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Autohide Tab Bar", - .blurb = "If true, tab bar should autohide.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getTabsAutohide, @@ -178,8 +172,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Wide Tabs", - .blurb = "If true, tabs will be in the wide expanded style.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getTabsWide, @@ -195,8 +187,6 @@ pub const Window = extern struct { Self, bool, .{ - .nick = "Tab Bar Visibility", - .blurb = "If true, tab bar should be visible.", .default = true, .accessor = gobject.ext.typedAccessor(Self, bool, .{ .getter = Self.getTabsVisible, @@ -212,8 +202,6 @@ pub const Window = extern struct { Self, adw.ToolbarStyle, .{ - .nick = "Toolbar Style", - .blurb = "The style for the toolbar top/bottom bars.", .default = .raised, .accessor = gobject.ext.typedAccessor( Self, @@ -261,6 +249,9 @@ pub const Window = extern struct { /// See tabOverviewOpen for why we have this. tab_overview_focus_timer: ?c_uint = null, + /// A weak reference to a command palette. + command_palette: WeakRef(CommandPalette) = .empty, + // Template bindings tab_overview: *adw.TabOverview, tab_bar: *adw.TabBar, @@ -277,7 +268,7 @@ pub const Window = extern struct { }); } - fn init(self: *Self, _: *Class) callconv(.C) void { + fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); // If our configuration is null then we get the configuration @@ -345,10 +336,16 @@ pub const Window = extern struct { .{ "close-tab", actionCloseTab, null }, .{ "new-tab", actionNewTab, null }, .{ "new-window", actionNewWindow, null }, + .{ "split-right", actionSplitRight, null }, + .{ "split-left", actionSplitLeft, null }, + .{ "split-up", actionSplitUp, null }, + .{ "split-down", actionSplitDown, null }, .{ "copy", actionCopy, null }, .{ "paste", actionPaste, null }, .{ "reset", actionReset, null }, .{ "clear", actionClear, null }, + // TODO: accept the surface that toggled the command palette + .{ "toggle-command-palette", actionToggleCommandPalette, null }, }; const action_map = self.as(gio.ActionMap); @@ -420,6 +417,25 @@ pub const Window = extern struct { .{ .sync_create = true }, ); + // Bind signals + const split_tree = tab.getSplitTree(); + _ = SplitTree.signals.changed.connect( + split_tree, + *Self, + tabSplitTreeChanged, + self, + .{}, + ); + + // Run an initial notification for the surface tree so we can setup + // initial state. + tabSplitTreeChanged( + split_tree, + null, + split_tree.getTree(), + self, + ); + return page; } @@ -553,12 +569,12 @@ pub const Window = extern struct { // Trigger all our dynamic properties that depend on the config. inline for (&.{ - "background-opaque", "headerbar-visible", "tabs-autohide", "tabs-visible", "tabs-wide", "toolbar-style", + "titlebar-style", }) |key| { self.as(gobject.Object).notifyByPspec( @field(properties, key).impl.param_spec, @@ -568,6 +584,12 @@ pub const Window = extern struct { // Remainder uses the config const config = if (priv.config) |v| v.get() else return; + // Only add a solid background if we're opaque. + self.toggleCssClass( + "background", + config.@"background-opacity" >= 1, + ); + // 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 // we use GTK CSS color variables. @@ -590,6 +612,27 @@ pub const Window = extern struct { }; } + /// Sync the state of any actions on this window. + fn syncActions(self: *Self) void { + const has_selection = selection: { + const surface = self.getActiveSurface() orelse + break :selection false; + const core_surface = surface.core() orelse + break :selection false; + break :selection core_surface.hasSelection(); + }; + + const action_map: *gio.ActionMap = gobject.ext.cast( + gio.ActionMap, + self, + ) orelse return; + const action: *gio.SimpleAction = gobject.ext.cast( + gio.SimpleAction, + action_map.lookupAction("copy") orelse return, + ) orelse return; + action.setEnabled(@intFromBool(has_selection)); + } + fn toggleCssClass(self: *Self, class: [:0]const u8, value: bool) void { const widget = self.as(gtk.Widget); if (value) @@ -623,6 +666,95 @@ pub const Window = extern struct { self.private().toast_overlay.addToast(toast); } + fn connectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + const priv = self.private(); + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = Surface.signals.@"close-request".connect( + surface, + *Self, + surfaceCloseRequest, + self, + .{}, + ); + _ = Surface.signals.@"present-request".connect( + surface, + *Self, + surfacePresentRequest, + self, + .{}, + ); + _ = Surface.signals.@"clipboard-write".connect( + surface, + *Self, + surfaceClipboardWrite, + self, + .{}, + ); + _ = Surface.signals.menu.connect( + surface, + *Self, + surfaceMenu, + self, + .{}, + ); + _ = Surface.signals.@"toggle-fullscreen".connect( + surface, + *Self, + surfaceToggleFullscreen, + self, + .{}, + ); + _ = Surface.signals.@"toggle-maximize".connect( + surface, + *Self, + surfaceToggleMaximize, + self, + .{}, + ); + + // If we've never had a surface initialize yet, then we register + // this signal. Its theoretically possible to launch multiple surfaces + // before init so we could register this on multiple and that is not + // a problem because we'll check the flag again in each handler. + if (!priv.surface_init) { + _ = Surface.signals.init.connect( + surface, + *Self, + surfaceInit, + self, + .{}, + ); + } + } + } + + /// Disconnect all the surface handlers for the given tree. This should + /// be called whenever a tree is no longer present in the window, e.g. + /// when a tab is detached or the tree changes. + fn disconnectSurfaceHandlers( + self: *Self, + tree: *const Surface.Tree, + ) void { + var it = tree.iterator(); + while (it.next()) |entry| { + const surface = entry.view; + _ = gobject.signalHandlersDisconnectMatched( + surface.as(gobject.Object), + .{ .data = true }, + 0, + 0, + null, + null, + self, + ); + } + } + //--------------------------------------------------------------- // Properties @@ -702,6 +834,14 @@ pub const Window = extern struct { return false; } + fn isFullscreen(self: *Window) bool { + return self.as(gtk.Window).isFullscreen() != 0; + } + + fn isMaximized(self: *Window) bool { + return self.as(gtk.Window).isMaximized() != 0; + } + fn getHeaderbarVisible(self: *Self) bool { const priv = self.private(); @@ -713,52 +853,70 @@ pub const Window = extern struct { if (priv.quick_terminal) return false; // If we're fullscreen we never show the header bar. - if (self.as(gtk.Window).isFullscreen() != 0) return false; + if (self.isFullscreen()) return false; // The remainder needs a config const config_obj = self.private().config orelse return true; const config = config_obj.get(); - // *Conditionally* disable the header bar when maximized, - // and gtk-titlebar-hide-when-maximized is set - if (self.as(gtk.Window).isMaximized() != 0 and - config.@"gtk-titlebar-hide-when-maximized") - { + // *Conditionally* disable the header bar when maximized, and + // gtk-titlebar-hide-when-maximized is set + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") { return false; } - return config.@"gtk-titlebar"; - } + return switch (config.@"gtk-titlebar-style") { + // If the titlebar style is tabs never show the titlebar. + .tabs => false, - fn getBackgroundOpaque(self: *Self) bool { - const priv = self.private(); - const config = (priv.config orelse return true).get(); - return config.@"background-opacity" >= 1.0; + // If the titlebar style is native show the titlebar if configured + // to do so. + .native => config.@"gtk-titlebar", + }; } fn getTabsAutohide(self: *Self) bool { const priv = self.private(); const config = if (priv.config) |v| v.get() else return true; - return switch (config.@"window-show-tab-bar") { - // Auto we always autohide... obviously. - .auto => true, - // Always we never autohide because we always show the tab bar. - .always => false, + return switch (config.@"gtk-titlebar-style") { + // If the titlebar style is tabs we cannot autohide. + .tabs => false, - // Never we autohide because it doesn't actually matter, - // since getTabsVisible will return false. - .never => true, + .native => switch (config.@"window-show-tab-bar") { + // Auto we always autohide... obviously. + .auto => true, + + // Always we never autohide because we always show the tab bar. + .always => false, + + // Never we autohide because it doesn't actually matter, + // since getTabsVisible will return false. + .never => true, + }, }; } fn getTabsVisible(self: *Self) bool { const priv = self.private(); const config = if (priv.config) |v| v.get() else return true; - return switch (config.@"window-show-tab-bar") { - .always, .auto => true, - .never => false, - }; + + switch (config.@"gtk-titlebar-style") { + .tabs => { + // *Conditionally* disable the tab bar when maximized, the titlebar + // style is tabs, and gtk-titlebar-hide-when-maximized is set. + if (self.isMaximized() and config.@"gtk-titlebar-hide-when-maximized") return false; + + // If the titlebar style is tabs the tab bar must always be visible. + return true; + }, + .native => { + return switch (config.@"window-show-tab-bar") { + .always, .auto => true, + .never => false, + }; + }, + } } fn getTabsWide(self: *Self) bool { @@ -777,6 +935,12 @@ pub const Window = extern struct { }; } + fn getTitlebarStyle(self: *Self) TitlebarStyle { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return .native; + return config.@"gtk-titlebar-style"; + } + fn propConfig( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -845,23 +1009,7 @@ pub const Window = extern struct { const active = button.getActive() != 0; if (!active) return; - const has_selection = selection: { - const surface = self.getActiveSurface() orelse - break :selection false; - const core_surface = surface.core() orelse - break :selection false; - break :selection core_surface.hasSelection(); - }; - - const action_map: *gio.ActionMap = gobject.ext.cast( - gio.ActionMap, - self, - ) orelse return; - const action: *gio.SimpleAction = gobject.ext.cast( - gio.SimpleAction, - action_map.lookupAction("copy") orelse return, - ) orelse return; - action.setEnabled(@intFromBool(has_selection)); + self.syncActions(); } fn propQuickTerminal( @@ -884,16 +1032,6 @@ pub const Window = extern struct { } } - /// Add or remove "background" CSS class depending on if the background - /// should be opaque. - fn propBackgroundOpaque( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Self, - ) callconv(.c) void { - self.toggleCssClass("background", self.getBackgroundOpaque()); - } - fn propScaleFactor( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -912,15 +1050,29 @@ pub const Window = extern struct { }; } + fn closureTitlebarStyleIsTab( + _: *Self, + value: TitlebarStyle, + ) callconv(.c) c_int { + return @intFromBool(switch (value) { + .native => false, + .tabs => true, + }); + } + //--------------------------------------------------------------- // Virtual methods - fn dispose(self: *Self) callconv(.C) void { + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); + + priv.command_palette.set(null); + if (priv.config) |v| { v.unref(); priv.config = null; } + priv.tab_bindings.setSource(null); gtk.Widget.disposeTemplate( @@ -934,7 +1086,7 @@ pub const Window = extern struct { ); } - fn finalize(self: *Self) callconv(.C) void { + fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); priv.tab_bindings.unref(); priv.winproto.deinit(Application.default().allocator()); @@ -1152,8 +1304,6 @@ pub const Window = extern struct { _: c_int, self: *Self, ) callconv(.c) void { - const priv = self.private(); - // Get the attached page which must be a Tab object. const child = page.getChild(); const tab = gobject.ext.cast(Tab, child) orelse return; @@ -1186,57 +1336,8 @@ pub const Window = extern struct { // behavior is consistent with macOS and the previous GTK apprt, // but that behavior was all implicit and not documented, so here // I am. - // - // TODO: When we have a split tree we'll want to attach to that. - const surface = tab.getActiveSurface(); - _ = Surface.signals.@"close-request".connect( - surface, - *Self, - surfaceCloseRequest, - self, - .{}, - ); - _ = Surface.signals.@"present-request".connect( - surface, - *Self, - surfacePresentRequest, - self, - .{}, - ); - _ = Surface.signals.@"clipboard-write".connect( - surface, - *Self, - surfaceClipboardWrite, - self, - .{}, - ); - _ = Surface.signals.@"toggle-fullscreen".connect( - surface, - *Self, - surfaceToggleFullscreen, - self, - .{}, - ); - _ = Surface.signals.@"toggle-maximize".connect( - surface, - *Self, - surfaceToggleMaximize, - self, - .{}, - ); - - // If we've never had a surface initialize yet, then we register - // this signal. Its theoretically possible to launch multiple surfaces - // before init so we could register this on multiple and that is not - // a problem because we'll check the flag again in each handler. - if (!priv.surface_init) { - _ = Surface.signals.init.connect( - surface, - *Self, - surfaceInit, - self, - .{}, - ); + if (tab.getSurfaceTree()) |tree| { + self.connectSurfaceHandlers(tree); } } @@ -1259,17 +1360,10 @@ pub const Window = extern struct { self, ); - // Remove all the signals that have this window as the userdata. - const surface = tab.getActiveSurface(); - _ = gobject.signalHandlersDisconnectMatched( - surface.as(gobject.Object), - .{ .data = true }, - 0, - 0, - null, - null, - self, - ); + // Remove the tree handlers + if (tab.getSurfaceTree()) |tree| { + self.disconnectSurfaceHandlers(tree); + } } fn tabViewCreateWindow( @@ -1355,6 +1449,13 @@ pub const Window = extern struct { } } + fn surfaceMenu( + _: *Surface, + self: *Self, + ) callconv(.c) void { + self.syncActions(); + } + fn surfacePresentRequest( surface: *Surface, self: *Self, @@ -1452,6 +1553,21 @@ pub const Window = extern struct { } } + fn tabSplitTreeChanged( + _: *SplitTree, + old_tree: ?*const Surface.Tree, + new_tree: ?*const Surface.Tree, + self: *Self, + ) callconv(.c) void { + if (old_tree) |tree| { + self.disconnectSurfaceHandlers(tree); + } + + if (new_tree) |tree| { + self.connectSurfaceHandlers(tree); + } + } + fn actionAbout( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -1528,6 +1644,38 @@ pub const Window = extern struct { self.performBindingAction(.new_tab); } + fn actionSplitRight( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .right }); + } + + fn actionSplitLeft( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .left }); + } + + fn actionSplitUp( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .up }); + } + + fn actionSplitDown( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.{ .new_split = .down }); + } + fn actionCopy( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -1560,6 +1708,68 @@ pub const Window = extern struct { self.performBindingAction(.clear_screen); } + /// Toggle the command palette. + /// + /// TODO: accept the surface that toggled the command palette as a parameter + fn toggleCommandPalette(self: *Window) void { + const priv = self.private(); + + // Get a reference to a command palette. First check the weak reference + // that we save to see if we already have one stored. If we don't then + // create a new one. + const command_palette = priv.command_palette.get() orelse command_palette: { + // Create a fresh command palette. + const command_palette = CommandPalette.new(); + + // Synchronize our config to the command palette's config. + _ = gobject.Object.bindProperty( + self.as(gobject.Object), + "config", + command_palette.as(gobject.Object), + "config", + .{ .sync_create = true }, + ); + + // Listen to the activate signal to know if the user selected an option in + // the command palette. + _ = CommandPalette.signals.trigger.connect( + command_palette, + *Window, + signalCommandPaletteTrigger, + self, + .{}, + ); + + // Save a weak reference to the command palette. We use a weak reference to avoid + // reference counting cycles that might cause problems later. + priv.command_palette.set(command_palette); + + break :command_palette command_palette; + }; + defer command_palette.unref(); + + // Tell the command palette to toggle itself. If the dialog gets + // presented (instead of hidden) it will be modal over our window. + command_palette.toggle(self); + } + + // React to a signal from a command palette asking an action to be performed. + fn signalCommandPaletteTrigger(_: *CommandPalette, action: *const input.Binding.Action, self: *Self) callconv(.c) void { + // If the activation actually has an action, perform it. + self.performBindingAction(action.*); + } + + /// React to a GTK action requesting that the command palette be toggled. + fn actionToggleCommandPalette( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + // TODO: accept the surface that toggled the command palette as a + // parameter + self.toggleCommandPalette(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -1571,7 +1781,7 @@ pub const Window = extern struct { var parent: *Parent.Class = undefined; pub const Instance = Self; - fn init(class: *Class) callconv(.C) void { + fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(DebugWarning); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -1585,7 +1795,6 @@ pub const Window = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, - properties.@"background-opaque".impl, properties.config.impl, properties.debug.impl, properties.@"headerbar-visible".impl, @@ -1594,6 +1803,7 @@ pub const Window = extern struct { properties.@"tabs-visible".impl, properties.@"tabs-wide".impl, properties.@"toolbar-style".impl, + properties.@"titlebar-style".impl, }); // Bindings @@ -1615,13 +1825,13 @@ pub const Window = extern struct { class.bindTemplateCallback("tab_create_window", &tabViewCreateWindow); class.bindTemplateCallback("notify_n_pages", &tabViewNPages); class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage); - class.bindTemplateCallback("notify_background_opaque", &propBackgroundOpaque); class.bindTemplateCallback("notify_config", &propConfig); class.bindTemplateCallback("notify_fullscreened", &propFullscreened); class.bindTemplateCallback("notify_maximized", &propMaximized); class.bindTemplateCallback("notify_menu_active", &propMenuActive); class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); + class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/css/style-dark.css b/src/apprt/gtk-ng/css/style-dark.css index a9aa2dcc0..f13b4f4f0 100644 --- a/src/apprt/gtk-ng/css/style-dark.css +++ b/src/apprt/gtk-ng/css/style-dark.css @@ -1,3 +1,8 @@ .transparent { background-color: transparent; } + +.window .split paned > separator { + background-color: rgba(36, 36, 36, 1); + background-clip: content-box; +} diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index 1e3e09d9f..a1a425f66 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -101,3 +101,39 @@ label.resize-overlay { /* after GTK 4.16 is a requirement, switch to the following: */ /* background-color: color-mix(in srgb, var(--error-bg-color), transparent); */ } + +/* + * Command Palette + */ +.command-palette-search > image:first-child { + margin-left: 8px; + margin-right: 4px; +} + +.command-palette-search > image:last-child { + margin-left: 4px; + margin-right: 8px; +} + +/* + * Splits + */ + +.window .split paned > separator { + background-color: rgba(250, 250, 250, 1); + background-clip: content-box; + + /* This works around the oversized drag area for the right side of GtkPaned. + * + * Upstream Gtk issue: + * https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002 + * + * Ghostty issue: + * https://github.com/ghostty-org/ghostty/issues/3020 + * + * Without this, it's not possible to select the first character on the + * right-hand side of a split. + */ + margin: 0; + padding: 0; +} diff --git a/src/apprt/gtk-ng/key.zig b/src/apprt/gtk-ng/key.zig index fc3296366..a00b0312e 100644 --- a/src/apprt/gtk-ng/key.zig +++ b/src/apprt/gtk-ng/key.zig @@ -9,7 +9,10 @@ const input = @import("../../input.zig"); const winproto = @import("winproto.zig"); /// Returns a GTK accelerator string from a trigger. -pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { +pub fn accelFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{NoSpaceLeft}!?[:0]const u8 { var buf_stream = std.io.fixedBufferStream(buf); const writer = buf_stream.writer(); @@ -30,7 +33,10 @@ pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u /// Returns a XDG-compliant shortcuts string from a trigger. /// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ -pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { +pub fn xdgShortcutFromTrigger( + buf: []u8, + trigger: input.Binding.Trigger, +) error{NoSpaceLeft}!?[:0]const u8 { var buf_stream = std.io.fixedBufferStream(buf); const writer = buf_stream.writer(); @@ -54,7 +60,7 @@ pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]c return slice[0 .. slice.len - 1 :0]; } -fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { +fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) error{NoSpaceLeft}!bool { switch (trigger.key) { .physical => |k| { const keyval = keyvalFromKey(k) orelse return false; diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 4cbbef097..23499c7f3 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -15,19 +15,32 @@ template $GhosttySurface: Adw.Bin { focusable: false; focus-on-click: false; - GLArea gl_area { - realize => $gl_realize(); - unrealize => $gl_unrealize(); - render => $gl_render(); - resize => $gl_resize(); + child: Box { hexpand: true; vexpand: true; - focusable: true; - focus-on-click: true; - has-stencil-buffer: false; - has-depth-buffer: false; - use-es: false; - } + + GLArea gl_area { + realize => $gl_realize(); + unrealize => $gl_unrealize(); + render => $gl_render(); + resize => $gl_resize(); + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + has-stencil-buffer: false; + has-depth-buffer: false; + allowed-apis: gl; + } + + PopoverMenu context_menu { + closed => $context_menu_closed(); + menu-model: context_menu_model; + flags: nested; + halign: start; + has-arrow: false; + } + }; [overlay] ProgressBar progress_bar_overlay { @@ -122,3 +135,104 @@ IMMulticontext im_context { preedit-end => $im_preedit_end(); commit => $im_commit(); } + +menu context_menu_model { + section { + item { + label: _("Copy"); + action: "win.copy"; + } + + item { + label: _("Paste"); + action: "win.paste"; + } + } + + section { + item { + label: _("Clear"); + action: "win.clear"; + } + + item { + label: _("Reset"); + action: "win.reset"; + } + } + + section { + submenu { + label: _("Split"); + + item { + label: _("Change Title…"); + action: "win.prompt-title"; + } + + item { + label: _("Split Up"); + action: "split-tree.new-up"; + } + + item { + label: _("Split Down"); + action: "split-tree.new-down"; + } + + item { + label: _("Split Left"); + action: "split-tree.new-left"; + } + + item { + label: _("Split Right"); + action: "split-tree.new-right"; + } + } + + submenu { + label: _("Tab"); + + item { + label: _("New Tab"); + action: "win.new-tab"; + } + + item { + label: _("Close Tab"); + action: "win.close-tab"; + } + } + + submenu { + label: _("Window"); + + item { + label: _("New Window"); + action: "win.new-window"; + } + + item { + label: _("Close Window"); + action: "win.close"; + } + } + } + + section { + submenu { + label: _("Config"); + + item { + label: _("Open Configuration"); + action: "app.open-config"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } + } + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/command-palette.blp b/src/apprt/gtk-ng/ui/1.5/command-palette.blp new file mode 100644 index 000000000..473fb1f06 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/command-palette.blp @@ -0,0 +1,110 @@ +using Gtk 4.0; +using Gio 2.0; +using Adw 1; + +Adw.Dialog dialog { + content-width: 700; + closed => $closed(); + + Adw.ToolbarView { + top-bar-style: flat; + + [top] + Adw.HeaderBar { + [title] + Gtk.SearchEntry search { + hexpand: true; + placeholder-text: _("Execute a command…"); + stop-search => $search_stopped(); + activate => $search_activated(); + + styles [ + "command-palette-search", + ] + } + } + + Gtk.ScrolledWindow { + min-content-height: 300; + + Gtk.ListView view { + show-separators: true; + single-click-activate: true; + activate => $row_activated(); + + model: Gtk.SingleSelection model { + model: Gtk.FilterListModel { + incremental: true; + + filter: Gtk.AnyFilter { + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.title; + search: bind search.text; + } + + Gtk.StringFilter { + expression: expr item as <$GhosttyCommand>.action-key; + search: bind search.text; + } + }; + + model: Gio.ListStore source { + item-type: typeof<$GhosttyCommand>; + }; + }; + }; + + styles [ + "rich-list", + ] + + factory: Gtk.BuilderListItemFactory { + template Gtk.ListItem { + child: Gtk.Box { + orientation: horizontal; + spacing: 10; + tooltip-text: bind template.item as <$GhosttyCommand>.description; + + Gtk.Box { + orientation: vertical; + hexpand: true; + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "title", + ] + + label: bind template.item as <$GhosttyCommand>.title; + } + + Gtk.Label { + ellipsize: end; + halign: start; + wrap: false; + single-line-mode: true; + + styles [ + "subtitle", + "monospace", + ] + + label: bind template.item as <$GhosttyCommand>.action-key; + } + } + + Gtk.ShortcutLabel { + accelerator: bind template.item as <$GhosttyCommand>.action; + valign: center; + } + }; + } + }; + } + } + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp b/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp new file mode 100644 index 000000000..182919f4e --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp @@ -0,0 +1,20 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTreeSplit: Adw.Bin { + styles [ + "split", + ] + + // The double-nesting is required due to a GTK bug where you can't + // bind the first child of a builder layout. If you do, you get a double + // dispose. Easiest way to see that is simply remove this and see the + // GTK critical errors (and sometimes crashes). + Adw.Bin { + Paned paned { + notify::max-position => $notify_max_position(); + notify::min-position => $notify_min_position(); + notify::position => $notify_position(); + } + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk-ng/ui/1.5/split-tree.blp new file mode 100644 index 000000000..e8c53b607 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/split-tree.blp @@ -0,0 +1,25 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySplitTree: Box { + notify::tree => $notify_tree(); + orientation: vertical; + + Adw.Bin tree_bin { + visible: bind template.has-surfaces; + hexpand: true; + vexpand: true; + } + + // This could be a lot more visually pleasing but in practice this doesn't + // ever happen at the time of writing this comment. A surface-less split + // tree always closes its parent. + Label { + visible: bind template.has-surfaces inverted; + // Purposely not localized currently because this shouldn't really + // ever appear. When we have a situation it does appear, we may want + // to change the styling and text so I don't want to burden localizers + // to handle this yet. + label: "No surfaces."; + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 476244576..4cb47487d 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -5,11 +5,12 @@ template $GhosttyTab: Box { "tab", ] + orientation: vertical; hexpand: true; vexpand: true; - // A tab currently just contains a surface directly. When we introduce - // splits we probably want to replace this with the split widget type. - $GhosttySurface surface { - close-request => $surface_close_request(); + + $GhosttySplitTree split_tree { + notify::active-surface => $notify_active_surface(); + notify::tree => $notify_tree(); } } diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index e13fb44f7..4ca90dfb5 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -8,7 +8,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { close-request => $close_request(); realize => $realize(); - notify::background-opaque => $notify_background_opaque(); notify::config => $notify_config(); notify::fullscreened => $notify_fullscreened(); notify::maximized => $notify_maximized(); @@ -50,6 +49,8 @@ template $GhosttyWindow: Adw.ApplicationWindow { tooltip-text: _("New Tab"); dropdown-tooltip: _("New Split"); menu-model: split_menu; + can-focus: false; + focus-on-click: false; } [end] @@ -78,6 +79,64 @@ template $GhosttyWindow: Adw.ApplicationWindow { expand-tabs: bind template.tabs-wide; view: tab_view; visible: bind template.tabs-visible; + + [start] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; + + Gtk.WindowControls { + side: start; + } + + Adw.SplitButton { + styles [ + "flat", + ] + + clicked => $new_tab(); + icon-name: "tab-new-symbolic"; + tooltip-text: _("New Tab"); + dropdown-tooltip: _("New Split"); + menu-model: split_menu; + can-focus: false; + focus-on-click: false; + } + } + + [end] + Gtk.Box { + orientation: horizontal; + visible: bind $titlebar_style_is_tabs(template.titlebar-style) as ; + + Gtk.ToggleButton { + styles [ + "flat", + ] + + icon-name: "view-grid-symbolic"; + tooltip-text: _("View Open Tabs"); + active: bind tab_overview.open bidirectional; + can-focus: false; + focus-on-click: false; + } + + Gtk.MenuButton { + styles [ + "flat", + ] + + notify::active => $notify_menu_active(); + icon-name: "open-menu-symbolic"; + menu-model: main_menu; + tooltip-text: _("Main Menu"); + can-focus: false; + } + + Gtk.WindowControls { + side: end; + } + } } Box { diff --git a/src/apprt/gtk-ng/weak_ref.zig b/src/apprt/gtk-ng/weak_ref.zig index 7ee5cf730..f689e45fa 100644 --- a/src/apprt/gtk-ng/weak_ref.zig +++ b/src/apprt/gtk-ng/weak_ref.zig @@ -10,6 +10,8 @@ pub fn WeakRef(comptime T: type) type { ref: gobject.WeakRef = std.mem.zeroes(gobject.WeakRef), + pub const empty: Self = .{}; + /// Set the weak reference to the given object. This will not /// increase the reference count of the object. pub fn set(self: *Self, v_: ?*T) void { @@ -23,14 +25,9 @@ pub fn WeakRef(comptime T: type) type { /// Get a strong reference to the object, or null if the object /// has been finalized. This increases the reference count by one. pub fn get(self: *Self) ?*T { - // The GIR of g_weak_ref_get has a bug where the optional - // is not encoded. Or, it may be a bug in zig-gobject. - const obj_: ?*gobject.Object = @ptrCast(self.ref.get()); - const obj = obj_ orelse return null; - // We can't use `as` because `as` guarantees conversion and // that can't be statically guaranteed. - return gobject.ext.cast(T, obj); + return gobject.ext.cast(T, self.ref.get() orelse return null); } }; } @@ -38,7 +35,7 @@ pub fn WeakRef(comptime T: type) type { test WeakRef { const testing = std.testing; - var ref: WeakRef(gtk.TextBuffer) = .{}; + var ref: WeakRef(gtk.TextBuffer) = .empty; const obj: *gtk.TextBuffer = .new(null); ref.set(obj); ref.get().?.unref(); // The "?" asserts non-null diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index bf1549021..a1d622143 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -184,14 +184,14 @@ fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { self.destroy(); } -fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { +fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void { const dialog = gobject.ext.cast(DialogType, dialog_.?).?; const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); const response = dialog.chooseFinish(result); self.handleResponse(response); } -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.C) void { +fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { self.handleResponse(response); } diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig index ac9dbaa8a..2506bef97 100644 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ b/src/apprt/gtk/GlobalShortcuts.zig @@ -335,6 +335,7 @@ fn request( var response: u32 = 0; var vardict: ?*glib.Variant = null; + defer if (vardict) |v| v.unref(); params_.get("(u@a{sv})", &response, &vardict); switch (response) { diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index e6b502c80..2f026e33c 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -1121,7 +1121,7 @@ fn gtkActionToggleCommandPalette( _: *gio.SimpleAction, _: ?*glib.Variant, self: *Window, -) callconv(.C) void { +) callconv(.c) void { self.performBindingAction(.toggle_command_palette); } diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig index d9d0083d0..50d0d1227 100644 --- a/src/apprt/gtk/menu.zig +++ b/src/apprt/gtk/menu.zig @@ -82,10 +82,6 @@ pub fn Menu( return self.menu_widget.as(gtk.Widget).getVisible() != 0; } - pub fn setVisible(self: *const Self, visible: bool) void { - self.menu_widget.as(gtk.Widget).setVisible(@intFromBool(visible)); - } - /// Refresh the menu. Right now that means enabling/disabling the "Copy" /// menu item based on whether there is an active selection or not, but /// that may change in the future. diff --git a/src/build/Config.zig b/src/build/Config.zig index 69a9dd8a0..175745dc6 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -37,6 +37,7 @@ font_backend: font.Backend = .freetype, x11: bool = false, wayland: bool = false, sentry: bool = true, +i18n: bool = true, wasm_shared: bool = true, /// Ghostty exe properties @@ -175,6 +176,16 @@ pub fn init(b: *std.Build) !Config { "Enables linking against X11 libraries when using the GTK rendering backend.", ) orelse gtk_targets.x11; + config.i18n = b.option( + bool, + "i18n", + "Enables gettext-based internationalization. Enabled by default only for macOS, and other Unix-like systems like Linux and FreeBSD when using glibc.", + ) orelse switch (target.result.os.tag) { + .macos, .ios => true, + .linux, .freebsd => target.result.isGnuLibC(), + else => false, + }; + //--------------------------------------------------------------- // Ghostty Exe Properties @@ -420,6 +431,7 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { step.addOption(bool, "x11", self.x11); step.addOption(bool, "wayland", self.wayland); step.addOption(bool, "sentry", self.sentry); + step.addOption(bool, "i18n", self.i18n); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(font.Backend, "font_backend", self.font_backend); step.addOption(rendererpkg.Impl, "renderer", self.renderer); @@ -467,6 +479,7 @@ pub fn fromOptions() Config { .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, .wasm_shared = options.wasm_shared, + .i18n = options.i18n, }; } diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index d3bda032d..0afb64007 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -17,7 +17,7 @@ xctest: *std.Build.Step.Run, pub const Deps = struct { xcframework: *const XCFramework, docs: *const Docs, - i18n: *const I18n, + i18n: ?*const I18n, resources: *const Resources, }; @@ -81,7 +81,7 @@ pub fn init( // We also need all these resources because the xcode project // references them via symlinks. deps.resources.addStepDependencies(&step.step); - deps.i18n.addStepDependencies(&step.step); + if (deps.i18n) |v| v.addStepDependencies(&step.step); deps.docs.installDummy(&step.step); // Expect success @@ -113,7 +113,7 @@ pub fn init( // We also need all these resources because the xcode project // references them via symlinks. deps.resources.addStepDependencies(&step.step); - deps.i18n.addStepDependencies(&step.step); + if (deps.i18n) |v| v.addStepDependencies(&step.step); deps.docs.installDummy(&step.step); // Expect success diff --git a/src/build_config.zig b/src/build_config.zig index 3dac47463..903197717 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -41,6 +41,7 @@ pub const flatpak = options.flatpak; 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; +pub const i18n: bool = config.i18n; /// 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 diff --git a/src/config/Config.zig b/src/config/Config.zig index bca82ff79..2cf5a3e17 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2892,6 +2892,21 @@ else /// more subtle border. @"gtk-toolbar-style": GtkToolbarStyle = .raised, +/// The style of the GTK titlbar. Available values are `native` and `tabs`. +/// +/// The `native` titlebar style is a traditional titlebar with a title, a few +/// buttons and window controls. A separate tab bar will show up below the +/// titlebar if you have multiple tabs open in the window. +/// +/// The `tabs` titlebar merges the tab bar and the traditional titlebar. +/// This frees up vertical space on your screen if you use multiple tabs. One +/// limitation of the `tabs` titlebar is that you cannot drag the titlebar +/// by the titles any longer (as they are tab titles now). Other areas of the +/// `tabs` title bar can be used to drag the window around. +/// +/// The default style is `native`. +@"gtk-titlebar-style": GtkTitlebarStyle = .native, + /// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs /// are the new typical Gnome style where tabs fill their available space. /// If you set this to `false` then tabs will only take up space they need, @@ -6947,6 +6962,21 @@ pub const GtkToolbarStyle = enum { @"raised-border", }; +/// See gtk-titlebar-style +pub const GtkTitlebarStyle = enum(c_int) { + native, + tabs, + + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + GtkTitlebarStyle, + .{ .name = "GhosttyGtkTitlebarStyle" }, + ), + + .none => void, + }; +}; + /// See app-notifications pub const AppNotifications = packed struct { @"clipboard-copy": bool = true, diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 4f45f9483..14ee0e504 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -6,6 +6,7 @@ const cache_table = @import("cache_table.zig"); const circ_buf = @import("circ_buf.zig"); const intrusive_linked_list = @import("intrusive_linked_list.zig"); const segmented_pool = @import("segmented_pool.zig"); +const split_tree = @import("split_tree.zig"); pub const lru = @import("lru.zig"); pub const BlockingQueue = blocking_queue.BlockingQueue; @@ -13,6 +14,7 @@ pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; pub const SegmentedPool = segmented_pool.SegmentedPool; +pub const SplitTree = split_tree.SplitTree; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig new file mode 100644 index 000000000..6d224757b --- /dev/null +++ b/src/datastruct/split_tree.zig @@ -0,0 +1,1658 @@ +const std = @import("std"); +const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); +const ArenaAllocator = std.heap.ArenaAllocator; +const Allocator = std.mem.Allocator; + +/// SplitTree represents a tree of view types that can be divided. +/// +/// Concretely for Ghostty, it represents a tree of terminal views. In +/// its basic state, there are no splits and it is a single full-sized +/// terminal. However, it can be split arbitrarily many times among two +/// axes (horizontal and vertical) to create a tree of terminal views. +/// +/// This is an immutable tree structure, meaning all operations on it +/// will return a new tree with the operation applied. This allows us to +/// store versions of the tree in a history for easy undo/redo. To facilitate +/// this, the stored View type must implement reference counting; this is left +/// as an implementation detail of the View type. +/// +/// The View type will be stored as a pointer within the tree and must +/// implement a number of functions to work properly: +/// +/// - `fn ref(*View, Allocator) Allocator.Error!*View` - Increase a +/// reference count of the view. The Allocator will be the allocator provided +/// to the tree operation. This is allowed to copy the value if it wants to; +/// the returned value is expected to be a new reference (but that may +/// just be a copy). +/// +/// - `fn unref(*View, Allocator) void` - Decrease the reference count of a +/// view. The Allocator will be the allocator provided to the tree +/// operation. +/// +/// - `fn eql(*const View, *const View) bool` - Check if two views are equal. +/// +/// Optionally the following functions can also be implemented: +/// +/// - `fn splitTreeLabel(*const View) []const u8` - Return a label that is used +/// for the debug view. If this isn't specified then the node handle +/// will be used. +/// +/// Note: for both the ref and unref functions, the allocator is optional. +/// If the functions take less arguments, then the allocator will not be +/// passed. +pub fn SplitTree(comptime V: type) type { + return struct { + const Self = @This(); + + /// The view that this tree contains. + pub const View = V; + + /// The arena allocator used for all allocations in the tree. + /// Since the tree is an immutable structure, this lets us + /// cleanly free all memory when the tree is deinitialized. + arena: ArenaAllocator, + + /// All the nodes in the tree. Node at index 0 is always the root. + nodes: []const Node, + + /// An empty tree. + pub const empty: Self = .{ + // Arena can be undefined because we have zero allocated nodes. + // If our nodes are empty our deinit function doesn't touch the + // arena. + .arena = undefined, + .nodes = &.{}, + }; + + pub const Node = union(enum) { + leaf: *View, + split: Split, + + /// A handle into the nodes array. This lets us keep track of + /// nodes with 16-bit handles rather than full pointer-width + /// values. + pub const Handle = u16; + }; + + pub const Split = struct { + layout: Layout, + ratio: f16, + left: Node.Handle, + right: Node.Handle, + + pub const Layout = enum { horizontal, vertical }; + pub const Direction = enum { left, right, down, up }; + }; + + /// Initialize a new tree with a single view. + pub fn init(gpa: Allocator, view: *View) Allocator.Error!Self { + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const nodes = try alloc.alloc(Node, 1); + nodes[0] = .{ .leaf = try viewRef(view, gpa) }; + errdefer viewUnref(view, gpa); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + pub fn deinit(self: *Self) void { + // Important: only free memory if we have memory to free, + // because we use an undefined arena for empty trees. + if (self.nodes.len > 0) { + // Unref all our views + const gpa: Allocator = self.arena.child_allocator; + for (self.nodes) |node| switch (node) { + .leaf => |view| viewUnref(view, gpa), + .split => {}, + }; + self.arena.deinit(); + } + + self.* = undefined; + } + + /// Clone this tree, returning a new tree with the same nodes. + pub fn clone(self: *const Self, gpa: Allocator) Allocator.Error!Self { + // If we're empty then return an empty tree. + if (self.isEmpty()) return .empty; + + // Create a new arena allocator for the clone. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate a new nodes array and copy the existing nodes into it. + const nodes = try alloc.dupe(Node, self.nodes); + + // Increase the reference count of all the views in the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + /// Returns true if this is an empty tree. + pub fn isEmpty(self: *const Self) bool { + // An empty tree has no nodes. + return self.nodes.len == 0; + } + + /// An iterator over all the views in the tree. + pub fn iterator( + self: *const Self, + ) Iterator { + return .{ .nodes = self.nodes }; + } + + pub const ViewEntry = struct { + handle: Node.Handle, + view: *View, + }; + + pub const Iterator = struct { + i: Node.Handle = 0, + nodes: []const Node, + + pub fn next(self: *Iterator) ?ViewEntry { + // If we have no nodes, return null. + if (self.i >= self.nodes.len) return null; + + // Get the current node and increment the index. + const handle = self.i; + self.i += 1; + const node = self.nodes[handle]; + + return switch (node) { + .leaf => |v| .{ .handle = handle, .view = v }, + .split => self.next(), + }; + } + }; + + pub const Goto = union(enum) { + /// Previous view, null if we're the first view. + previous, + + /// Next view, null if we're the last view. + next, + + /// Previous view, but wrapped around to the last view. May + /// return the same view if this is the first view. + previous_wrapped, + + /// Next view, but wrapped around to the first view. May return + /// the same view if this is the last view. + next_wrapped, + + /// A spatial direction. "Spatial" means that the direction is + /// based on the nearest surface in the given direction visually + /// as the surfaces are laid out on a 2D grid. + spatial: Spatial.Direction, + }; + + /// Goto a view from a certain point in the split tree. Returns null + /// if the direction results in no visitable view. + /// + /// Allocator is only used for temporary state for spatial navigation. + pub fn goto( + self: *const Self, + alloc: Allocator, + from: Node.Handle, + to: Goto, + ) Allocator.Error!?Node.Handle { + return switch (to) { + .previous => self.previous(from), + .next => self.next(from), + .previous_wrapped => self.previous(from) orelse self.deepest(.right, 0), + .next_wrapped => self.next(from) orelse self.deepest(.left, 0), + .spatial => |d| spatial: { + // Get our spatial representation. + var sp = try self.spatial(alloc); + defer sp.deinit(alloc); + break :spatial self.nearest(sp, from, d); + }, + }; + } + + pub const Side = enum { left, right }; + + /// Returns the deepest view in the tree in the given direction. + /// This can be used to find the leftmost/rightmost surface within + /// a given split structure. + pub fn deepest( + self: *const Self, + side: Side, + from: Node.Handle, + ) Node.Handle { + var current: Node.Handle = from; + while (true) { + switch (self.nodes[current]) { + .leaf => return current, + .split => |s| current = switch (side) { + .left => s.left, + .right => s.right, + }, + } + } + } + + /// Returns the previous view from the given node handle (which itself + /// doesn't need to be a view). If there is no previous (this is the + /// most previous view) then this will return null. + /// + /// "Previous" is defined as the previous node in an in-order + /// traversal of the tree. This isn't a perfect definition and we + /// may want to change this to something that better matches a + /// spatial view of the tree later. + fn previous(self: *const Self, from: Node.Handle) ?Node.Handle { + return switch (self.previousBacktrack(from, 0)) { + .result => |v| v, + .backtrack, .deadend => null, + }; + } + + /// Same as `previous`, but returns the next view instead. + fn next(self: *const Self, from: Node.Handle) ?Node.Handle { + return switch (self.nextBacktrack(from, 0)) { + .result => |v| v, + .backtrack, .deadend => null, + }; + } + + // Design note: we use a recursive backtracking search because + // split trees are never that deep, so we can abuse the stack as + // a safe allocator (stack overflow unlikely unless the kernel is + // tuned in some really weird way). + const Backtrack = union(enum) { + deadend, + backtrack, + result: Node.Handle, + }; + + fn previousBacktrack( + self: *const Self, + from: Node.Handle, + current: Node.Handle, + ) Backtrack { + // If we reached the point that we're trying to find the previous + // value of, then we need to backtrack from here. + if (from == current) return .backtrack; + + return switch (self.nodes[current]) { + // If we hit a leaf that isn't our target, then deadend. + .leaf => .deadend, + + .split => |s| switch (self.previousBacktrack(from, s.left)) { + .result => |v| .{ .result = v }, + + // Backtrack from the left means we have to continue + // backtracking because we can't see what's before the left. + .backtrack => .backtrack, + + // If we hit a deadend on the left then let's move right. + .deadend => switch (self.previousBacktrack(from, s.right)) { + .result => |v| .{ .result = v }, + + // Deadend means its not in this split at all since + // we already tracked the left. + .deadend => .deadend, + + // Backtrack means that its in our left view because + // we can see the immediate previous and there MUST + // be leaves (we can't have split-only leaves). + .backtrack => .{ .result = self.deepest(.right, s.left) }, + }, + }, + }; + } + + // See previousBacktrack for detailed comments. This is a mirror + // of that. + fn nextBacktrack( + self: *const Self, + from: Node.Handle, + current: Node.Handle, + ) Backtrack { + if (from == current) return .backtrack; + return switch (self.nodes[current]) { + .leaf => .deadend, + .split => |s| switch (self.nextBacktrack(from, s.right)) { + .result => |v| .{ .result = v }, + .backtrack => .backtrack, + .deadend => switch (self.nextBacktrack(from, s.left)) { + .result => |v| .{ .result = v }, + .deadend => .deadend, + .backtrack => .{ .result = self.deepest(.left, s.right) }, + }, + }, + }; + } + + /// Returns the nearest leaf node (view) in the given direction. + fn nearest( + self: *const Self, + sp: Spatial, + from: Node.Handle, + direction: Spatial.Direction, + ) ?Node.Handle { + const target = sp.slots[from]; + + var result: ?struct { + handle: Node.Handle, + distance: f16, + } = null; + for (sp.slots, 0..) |slot, handle| { + // Never match ourself + if (handle == from) continue; + + // Only match leaves + switch (self.nodes[handle]) { + .leaf => {}, + .split => continue, + } + + // Ensure it is in the proper direction + if (!switch (direction) { + .left => slot.maxX() <= target.x, + .right => slot.x >= target.maxX(), + .up => slot.maxY() <= target.y, + .down => slot.y >= target.maxY(), + }) continue; + + // Track our distance + const dx = slot.x - target.x; + const dy = slot.y - target.y; + const distance = @sqrt(dx * dx + dy * dy); + + // If we have a nearest it must be closer. + if (result) |n| { + if (distance >= n.distance) continue; + } + result = .{ + .handle = @intCast(handle), + .distance = distance, + }; + } + + return if (result) |n| n.handle else null; + } + + /// Resize the given node in place. The node MUST be a split (asserted). + /// + /// In general, this is an immutable data structure so this is + /// heavily discouraged. However, this is provided for convenience + /// and performance reasons where its very important for GUIs to + /// update the ratio during a live resize than to redraw the entire + /// widget tree. + pub fn resizeInPlace( + self: *Self, + at: Node.Handle, + ratio: f16, + ) void { + // Let's talk about this constCast. Our member are const but + // we actually always own their memory. We don't want consumers + // who directly access the nodes to be able to modify them + // (without nasty stuff like this), but given this is internal + // usage its perfectly fine to modify the node in-place. + const s: *Split = @constCast(&self.nodes[at].split); + s.ratio = ratio; + } + + /// Insert another tree into this tree at the given node in the + /// specified direction. The other tree will be inserted in the + /// new direction. For example, if the direction is "right" then + /// `insert` is inserted right of the existing node. + /// + /// The allocator will be used for the newly created tree. + /// The previous trees will not be freed, but reference counts + /// for the views will be increased accordingly for the new tree. + pub fn split( + self: *const Self, + gpa: Allocator, + at: Node.Handle, + direction: Split.Direction, + ratio: f16, + insert: *const Self, + ) Allocator.Error!Self { + // The new arena for our new tree. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // We know we're going to need the sum total of the nodes + // between the two trees plus one for the new split node. + const nodes = try alloc.alloc(Node, self.nodes.len + insert.nodes.len + 1); + if (nodes.len > std.math.maxInt(Node.Handle)) return error.OutOfMemory; + + // We can copy our nodes exactly as they are, since they're + // mostly not changing (only `at` is changing). + @memcpy(nodes[0..self.nodes.len], self.nodes); + + // We can copy the destination nodes as well directly next to + // the source nodes. We just have to go through and offset + // all the handles in the destination tree to account for + // the shift. + const nodes_inserted = nodes[self.nodes.len..][0..insert.nodes.len]; + @memcpy(nodes_inserted, insert.nodes); + for (nodes_inserted) |*node| switch (node.*) { + .leaf => {}, + .split => |*s| { + // We need to offset the handles in the split + s.left += @intCast(self.nodes.len); + s.right += @intCast(self.nodes.len); + }, + }; + + // Determine our split layout and if we're on the left + const layout: Split.Layout, const left: bool = switch (direction) { + .left => .{ .horizontal, true }, + .right => .{ .horizontal, false }, + .up => .{ .vertical, true }, + .down => .{ .vertical, false }, + }; + + // Copy our previous value to the end of the nodes list and + // create our new split node. + nodes[nodes.len - 1] = nodes[at]; + nodes[at] = .{ .split = .{ + .layout = layout, + .ratio = ratio, + .left = @intCast(if (left) self.nodes.len else nodes.len - 1), + .right = @intCast(if (left) nodes.len - 1 else self.nodes.len), + } }; + + // We need to increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ .arena = arena, .nodes = nodes }; + } + + /// Remove a node from the tree. + pub fn remove( + self: *Self, + gpa: Allocator, + at: Node.Handle, + ) Allocator.Error!Self { + assert(at < self.nodes.len); + + // If we're removing node zero then we're clearing the tree. + if (at == 0) return .empty; + + // The new arena for our new tree. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate our new nodes list with the number of nodes we'll + // need after the removal. + const nodes = try alloc.alloc(Node, self.countAfterRemoval( + 0, + at, + 0, + )); + + // Traverse the tree and copy all our nodes into place. + assert(self.removeNode( + nodes, + 0, + 0, + at, + ) > 0); + + // Increase the reference count of all the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + fn removeNode( + self: *Self, + nodes: []Node, + new_offset: Node.Handle, + current: Node.Handle, + target: Node.Handle, + ) Node.Handle { + assert(current != target); + + switch (self.nodes[current]) { + // Leaf is simple, just copy it over. We don't ref anything + // yet because it'd make undo (errdefer) harder. We do that + // all at once later. + .leaf => |view| { + nodes[new_offset] = .{ .leaf = view }; + return 1; + }, + + .split => |s| { + // If we're removing one of the split node sides then + // we remove the split node itself as well and only add + // the other (non-removed) side. + if (s.left == target) return self.removeNode( + nodes, + new_offset, + s.right, + target, + ); + if (s.right == target) return self.removeNode( + nodes, + new_offset, + s.left, + target, + ); + + // Neither side is being directly removed, so we traverse. + const left = self.removeNode( + nodes, + new_offset + 1, + s.left, + target, + ); + assert(left > 0); + const right = self.removeNode( + nodes, + new_offset + 1 + left, + s.right, + target, + ); + assert(right > 0); + nodes[new_offset] = .{ .split = .{ + .layout = s.layout, + .ratio = s.ratio, + .left = new_offset + 1, + .right = new_offset + 1 + left, + } }; + + return left + right + 1; + }, + } + } + + /// Returns the number of nodes that would be needed to store + /// the tree if the target node is removed. + fn countAfterRemoval( + self: *Self, + current: Node.Handle, + target: Node.Handle, + acc: usize, + ) usize { + assert(current != target); + + return switch (self.nodes[current]) { + // Leaf is simple, always takes one node. + .leaf => acc + 1, + + // Split is slightly more complicated. If either side is the + // target to remove, then we remove the split node as well + // so our count is just the count of the other side. + // + // If neither side is the target, then we count both sides + // and add one to account for the split node itself. + .split => |s| if (s.left == target) self.countAfterRemoval( + s.right, + target, + acc, + ) else if (s.right == target) self.countAfterRemoval( + s.left, + target, + acc, + ) else self.countAfterRemoval( + s.left, + target, + acc, + ) + self.countAfterRemoval( + s.right, + target, + acc, + ) + 1, + }; + } + + /// Reference all the nodes in the given slice, handling unref if + /// any fail. This should be called LAST so you don't have to undo + /// the refs at any further point after this. + fn refNodes(gpa: Allocator, nodes: []Node) Allocator.Error!void { + // We need to increase the reference count of all the nodes. + // Careful accounting here so that we properly unref on error + // only the nodes we referenced. + var reffed: usize = 0; + errdefer for (0..reffed) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| viewUnref(view, gpa), + } + }; + for (0..nodes.len) |i| { + switch (nodes[i]) { + .split => {}, + .leaf => |view| nodes[i] = .{ .leaf = try viewRef(view, gpa) }, + } + reffed = i; + } + assert(reffed == nodes.len - 1); + } + + /// Equalize this node and all its children, returning a new node with splits + /// adjusted so that each split's ratio is based on the relative weight + /// (number of leaves) of its children. + pub fn equalize( + self: *const Self, + gpa: Allocator, + ) Allocator.Error!Self { + if (self.isEmpty()) return .empty; + + // Create a new arena allocator for the clone. + var arena = ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Allocate a new nodes array and copy the existing nodes into it. + const nodes = try alloc.dupe(Node, self.nodes); + + // Go through and equalize our ratios based on weights. + for (nodes) |*node| switch (node.*) { + .leaf => {}, + .split => |*s| { + const weight_left = self.weight(s.left, s.layout, 0); + const weight_right = self.weight(s.right, s.layout, 0); + assert(weight_left > 0); + assert(weight_right > 0); + const total_f16: f16 = @floatFromInt(weight_left + weight_right); + const weight_left_f16: f16 = @floatFromInt(weight_left); + s.ratio = weight_left_f16 / total_f16; + }, + }; + + // Increase the reference count of all the views in the nodes. + try refNodes(gpa, nodes); + + return .{ + .arena = arena, + .nodes = nodes, + }; + } + + fn weight( + self: *const Self, + from: Node.Handle, + layout: Split.Layout, + acc: usize, + ) usize { + return switch (self.nodes[from]) { + .leaf => acc + 1, + .split => |s| if (s.layout == layout) + self.weight(s.left, layout, acc) + + self.weight(s.right, layout, acc) + else + 1, + }; + } + + /// Spatial representation of the split tree. See spatial. + pub const Spatial = struct { + /// The slots of the spatial representation in the same order + /// as the tree it was created from. + slots: []const Slot, + + pub const empty: Spatial = .{ .slots = &.{} }; + + pub const Direction = enum { left, right, down, up }; + + const Slot = struct { + x: f16, + y: f16, + width: f16, + height: f16, + + fn maxX(self: *const Slot) f16 { + return self.x + self.width; + } + + fn maxY(self: *const Slot) f16 { + return self.y + self.height; + } + }; + + pub fn deinit(self: *Spatial, alloc: Allocator) void { + alloc.free(self.slots); + self.* = undefined; + } + }; + + /// Spatial representation of the split tree. This can be used to + /// better understand the layout of the tree in a 2D space. + /// + /// The bounds of the representation are always based on each split + /// being exactly 1 unit wide and high. The x and y coordinates + /// are offsets into that space. This means that the spatial + /// representation is a normalized representation of the actual + /// space. + /// + /// The top-left corner of the tree is always (0, 0). + /// + /// We use a normalized form because we can calculate it without + /// accessing to the actual rendered view sizes. These actual sizes + /// may not be available at various times because GUI toolkits often + /// only make them available once they're part of a widget tree and + /// a SplitTree can represent views that aren't currently visible. + pub fn spatial( + self: *const Self, + alloc: Allocator, + ) Allocator.Error!Spatial { + // No nodes, empty spatial representation. + if (self.nodes.len == 0) return .empty; + + // Get our total dimensions. + const dim = self.dimensions(0); + + // Create our slots which will match our nodes exactly. + const slots = try alloc.alloc(Spatial.Slot, self.nodes.len); + errdefer alloc.free(slots); + slots[0] = .{ + .x = 0, + .y = 0, + .width = @floatFromInt(dim.width), + .height = @floatFromInt(dim.height), + }; + self.fillSpatialSlots(slots, 0); + + return .{ .slots = slots }; + } + + fn fillSpatialSlots( + self: *const Self, + slots: []Spatial.Slot, + current: Node.Handle, + ) void { + assert(slots[current].width > 0 and slots[current].height > 0); + + switch (self.nodes[current]) { + // Leaf node, current slot is already filled by caller. + .leaf => {}, + + .split => |s| { + switch (s.layout) { + .horizontal => { + slots[s.left] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width * s.ratio, + .height = slots[current].height, + }; + slots[s.right] = .{ + .x = slots[current].x + slots[current].width * s.ratio, + .y = slots[current].y, + .width = slots[current].width * (1 - s.ratio), + .height = slots[current].height, + }; + }, + + .vertical => { + slots[s.left] = .{ + .x = slots[current].x, + .y = slots[current].y, + .width = slots[current].width, + .height = slots[current].height * s.ratio, + }; + slots[s.right] = .{ + .x = slots[current].x, + .y = slots[current].y + slots[current].height * s.ratio, + .width = slots[current].width, + .height = slots[current].height * (1 - s.ratio), + }; + }, + } + + self.fillSpatialSlots(slots, s.left); + self.fillSpatialSlots(slots, s.right); + }, + } + } + + /// Get the dimensions of the tree starting from the given node. + /// + /// This creates relative dimensions (see Spatial) by assuming each + /// leaf is exactly 1x1 unit in size. + fn dimensions(self: *const Self, current: Node.Handle) struct { + width: u16, + height: u16, + } { + return switch (self.nodes[current]) { + .leaf => .{ .width = 1, .height = 1 }, + .split => |s| split: { + const left = self.dimensions(s.left); + const right = self.dimensions(s.right); + break :split switch (s.layout) { + .horizontal => .{ + .width = left.width + right.width, + .height = @max(left.height, right.height), + }, + + .vertical => .{ + .width = @max(left.width, right.width), + .height = left.height + right.height, + }, + }; + }, + }; + } + + /// Format the tree in a human-readable format. By default this will + /// output a diagram followed by a textual representation. This can + /// be controlled via the formatting string: + /// + /// - `diagram` - Output a diagram of the split tree only. + /// - `text` - Output a textual representation of the split tree only. + /// - Empty - Output both a diagram and a textual representation. + /// + pub fn format( + self: *const Self, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = options; + + if (self.nodes.len == 0) { + try writer.writeAll("empty"); + return; + } + + if (std.mem.eql(u8, fmt, "diagram")) { + self.formatDiagram(writer) catch + try writer.writeAll("failed to draw split tree diagram"); + } else if (std.mem.eql(u8, fmt, "text")) { + try self.formatText(writer, 0, 0); + } else if (fmt.len == 0) { + self.formatDiagram(writer) catch {}; + try self.formatText(writer, 0, 0); + } else { + return error.InvalidFormat; + } + } + + fn formatText( + self: *const Self, + writer: anytype, + current: Node.Handle, + depth: usize, + ) !void { + for (0..depth) |_| try writer.writeAll(" "); + + switch (self.nodes[current]) { + .leaf => |v| if (@hasDecl(View, "splitTreeLabel")) + try writer.print("leaf: {s}\n", .{v.splitTreeLabel()}) + else + try writer.print("leaf: {d}\n", .{current}), + + .split => |s| { + try writer.print("split (layout: {s}, ratio: {d:.2})\n", .{ + @tagName(s.layout), + s.ratio, + }); + try self.formatText(writer, s.left, depth + 1); + try self.formatText(writer, s.right, depth + 1); + }, + } + } + + fn formatDiagram( + self: *const Self, + writer: anytype, + ) !void { + // Use our arena's GPA to allocate some intermediate memory. + // Requiring allocation for formatting is nasty but this is really + // only used for debugging and testing and shouldn't hit OOM + // scenarios. + var arena: ArenaAllocator = .init(self.arena.child_allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Get our spatial representation. + const sp = spatial: { + const sp = try self.spatial(alloc); + + // Scale our spatial representation to have minimum width/height 1. + var min_w: f16 = 1; + var min_h: f16 = 1; + for (sp.slots) |slot| { + min_w = @min(min_w, slot.width); + min_h = @min(min_h, slot.height); + } + + const ratio_w: f16 = 1 / min_w; + const ratio_h: f16 = 1 / min_h; + const slots = try alloc.dupe(Spatial.Slot, sp.slots); + for (slots) |*slot| { + slot.x *= ratio_w; + slot.y *= ratio_h; + slot.width *= ratio_w; + slot.height *= ratio_h; + } + + break :spatial .{ .slots = slots }; + }; + + // The width we need for the largest label. + const max_label_width: usize = max_label_width: { + if (!@hasDecl(View, "splitTreeLabel")) { + break :max_label_width std.math.log10(sp.slots.len) + 1; + } + + var max: usize = 0; + for (self.nodes) |node| switch (node) { + .split => {}, + .leaf => |view| { + const label = view.splitTreeLabel(); + max = @max(max, label.len); + }, + }; + + break :max_label_width max; + }; + + // We need space for whitespace and ASCII art so add that. + // We need to accommodate the leaf handle, whitespace, and + // then the border. + const cell_width = cell_width: { + // Border + whitespace + label + whitespace + border. + break :cell_width 2 + max_label_width + 2; + }; + const cell_height = cell_height: { + // Border + label + border. No whitespace needed on the + // vertical axis. + break :cell_height 1 + 1 + 1; + }; + + // Make a grid that can fit our entire ASCII diagram. We know + // the width/height based on node 0. + const grid = grid: { + // Get our initial width/height. Each leaf is 1x1 in this. + // We round up for this because partial widths/heights should + // take up an extra cell. + var width: usize = @intFromFloat(@ceil(sp.slots[0].width)); + var height: usize = @intFromFloat(@ceil(sp.slots[0].height)); + + // We need space for whitespace and ASCII art so add that. + // We need to accommodate the leaf handle, whitespace, and + // then the border. + width *= cell_width; + height *= cell_height; + + const rows = try alloc.alloc([]u8, height); + for (0..rows.len) |y| { + rows[y] = try alloc.alloc(u8, width + 1); + @memset(rows[y], ' '); + rows[y][width] = '\n'; + } + break :grid rows; + }; + + // Draw each node + for (sp.slots, 0..) |slot, handle| { + // We only draw leaf nodes. Splits are only used for layout. + const node = self.nodes[handle]; + switch (node) { + .leaf => {}, + .split => continue, + } + + var x: usize = @intFromFloat(@floor(slot.x)); + var y: usize = @intFromFloat(@floor(slot.y)); + var width: usize = @intFromFloat(@max(@floor(slot.width), 1)); + var height: usize = @intFromFloat(@max(@floor(slot.height), 1)); + x *= cell_width; + y *= cell_height; + width *= cell_width; + height *= cell_height; + + // Top border + { + const top = grid[y][x..][0..width]; + top[0] = '+'; + for (1..width - 1) |i| top[i] = '-'; + top[width - 1] = '+'; + } + + // Bottom border + { + const bottom = grid[y + height - 1][x..][0..width]; + bottom[0] = '+'; + for (1..width - 1) |i| bottom[i] = '-'; + bottom[width - 1] = '+'; + } + + // Left border + for (y + 1..y + height - 1) |y_cur| grid[y_cur][x] = '|'; + for (y + 1..y + height - 1) |y_cur| grid[y_cur][x + width - 1] = '|'; + + // Get our label text + var buf: [10]u8 = undefined; + const label: []const u8 = if (@hasDecl(View, "splitTreeLabel")) + node.leaf.splitTreeLabel() + else + try std.fmt.bufPrint(&buf, "{d}", .{handle}); + + // Draw the handle in the center + const x_mid = width / 2 + x; + const y_mid = height / 2 + y; + const label_width = label.len; + const label_start = x_mid - label_width / 2; + const row = grid[y_mid][label_start..]; + _ = try std.fmt.bufPrint(row, "{s}", .{label}); + } + + // Output every row + for (grid) |row| { + // We currently have a bug in our height calculation that + // results in trailing blank lines. Ignore those. We should + // really fix our height calculation instead. If someone wants + // to do that just remove this line and see the tests that fail + // and go from there. + if (row[0] == ' ') break; + try writer.writeAll(row); + } + } + + fn viewRef(view: *View, gpa: Allocator) Allocator.Error!*View { + const func = @typeInfo(@TypeOf(View.ref)).@"fn"; + return switch (func.params.len) { + 1 => view.ref(), + 2 => try view.ref(gpa), + else => @compileError("invalid view ref function"), + }; + } + + fn viewUnref(view: *View, gpa: Allocator) void { + const func = @typeInfo(@TypeOf(View.unref)).@"fn"; + switch (func.params.len) { + 1 => view.unref(), + 2 => view.unref(gpa), + else => @compileError("invalid view unref function"), + } + } + + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + Self, + .{ + // To get the type name we get the non-qualified type name + // of the view and append that to `GhosttySplitTree`. + .name = name: { + const type_name = @typeName(View); + const last = if (std.mem.lastIndexOfScalar( + u8, + type_name, + '.', + )) |idx| + type_name[idx + 1 ..] + else + type_name; + assert(last.len > 0); + break :name "GhosttySplitTree" ++ last; + }, + + .funcs = .{ + .copy = &struct { + fn copy(self: *Self) callconv(.c) *Self { + const ptr = @import("glib").ext.create(Self); + ptr.* = if (self.nodes.len == 0) + .empty + else + self.clone(self.arena.child_allocator) catch @panic("oom"); + return ptr; + } + }.copy, + .free = &struct { + fn free(self: *Self) callconv(.c) void { + self.deinit(); + @import("glib").ext.destroy(self); + } + }.free, + }, + }, + ), + + .none => void, + }; + }; +} + +const TestTree = SplitTree(TestView); + +const TestView = struct { + const Self = @This(); + + label: []const u8, + + pub fn ref(self: *Self, alloc: Allocator) Allocator.Error!*Self { + const ptr = try alloc.create(Self); + ptr.* = self.*; + return ptr; + } + + pub fn unref(self: *Self, alloc: Allocator) void { + alloc.destroy(self); + } + + pub fn splitTreeLabel(self: *const Self) []const u8 { + return self.label; + } +}; + +test "SplitTree: empty tree" { + const testing = std.testing; + const alloc = testing.allocator; + var t: TestTree = .empty; + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\empty + ); +} + +test "SplitTree: single node" { + const testing = std.testing; + const alloc = testing.allocator; + var v: TestTree.View = .{ .label = "A" }; + var t: TestTree = try .init(alloc, &v); + defer t.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\ + ); +} + +test "SplitTree: split horizontal" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer t3.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ leaf: B + \\ + ); + } + + // Split right at B + var vC: TestTree.View = .{ .label = "C" }; + var tC: TestTree = try .init(alloc, &vC); + defer tC.deinit(); + var it = t3.iterator(); + var t4 = try t3.split( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound, + .right, + 0.5, + &tC, + ); + defer t4.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t4}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+--------++---++---+ + \\| A || B || C | + \\+--------++---++---+ + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ split (layout: horizontal, ratio: 0.50) + \\ leaf: B + \\ leaf: C + \\ + ); + } + + // Split right at C + var vD: TestTree.View = .{ .label = "D" }; + var tD: TestTree = try .init(alloc, &vD); + defer tD.deinit(); + it = t4.iterator(); + var t5 = try t4.split( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "C")) { + break entry.handle; + } + } else return error.NotFound, + .right, + 0.5, + &tD, + ); + defer t5.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t5}); + defer alloc.free(str); + try testing.expectEqualStrings( + \\+------------------++--------++---++---+ + \\| A || B || C || D | + \\+------------------++--------++---++---+ + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ split (layout: horizontal, ratio: 0.50) + \\ leaf: B + \\ split (layout: horizontal, ratio: 0.50) + \\ leaf: C + \\ leaf: D + \\ + , str); + } + + // Find "previous" from D back. + { + var current: u8 = 'D'; + while (current != 'A') : (current -= 1) { + it = t5.iterator(); + const handle = t5.previous( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ).?; + + const entry = t5.nodes[handle].leaf; + try testing.expectEqualStrings( + entry.label, + &.{current - 1}, + ); + } + + it = t5.iterator(); + try testing.expect(t5.previous( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ) == null); + } + + // Find "next" from A forward. + { + var current: u8 = 'A'; + while (current != 'D') : (current += 1) { + it = t5.iterator(); + const handle = t5.next( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ).?; + + const entry = t5.nodes[handle].leaf; + try testing.expectEqualStrings( + entry.label, + &.{current + 1}, + ); + } + + it = t5.iterator(); + try testing.expect(t5.next( + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, &.{current})) { + break entry.handle; + } + } else return error.NotFound, + ) == null); + } +} + +test "SplitTree: split vertical" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + + var t3 = try t1.split( + alloc, + 0, // at root + .down, // split down + 0.5, + &t2, // insert t2 + ); + defer t3.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\+---+ + \\| B | + \\+---+ + \\ + ); +} + +test "SplitTree: remove leaf" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var t3 = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer t3.deinit(); + + // Remove "A" + var it = t3.iterator(); + var t4 = try t3.remove( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound, + ); + defer t4.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{t4}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); +} + +test "SplitTree: split twice, remove intermediary" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var v3: TestTree.View = .{ .label = "C" }; + var t3: TestTree = try .init(alloc, &v3); + defer t3.deinit(); + + // A | B horizontal. + var split1 = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split1.deinit(); + + // Insert C below that. + var split2 = try split1.split( + alloc, + 0, // at root + .down, // split down + 0.5, + &t3, // insert t3 + ); + defer split2.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split2}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\+--------+ + \\| C | + \\+--------+ + \\ + ); + } + + // Remove "B" + var it = split2.iterator(); + var split3 = try split2.remove( + alloc, + while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound, + ); + defer split3.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split3}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\+---+ + \\| C | + \\+---+ + \\ + ); + } + + // Remove every node from split2 (our most complex one), which should + // never crash. We don't test the result is correct, this just verifies + // we don't hit any assertion failures. + for (0..split2.nodes.len) |i| { + var t = try split2.remove(alloc, @intCast(i)); + t.deinit(); + } +} + +test "SplitTree: spatial goto" { + const testing = std.testing; + const alloc = testing.allocator; + + var v1: TestTree.View = .{ .label = "A" }; + var t1: TestTree = try .init(alloc, &v1); + defer t1.deinit(); + var v2: TestTree.View = .{ .label = "B" }; + var t2: TestTree = try .init(alloc, &v2); + defer t2.deinit(); + var v3: TestTree.View = .{ .label = "C" }; + var t3: TestTree = try .init(alloc, &v3); + defer t3.deinit(); + var v4: TestTree.View = .{ .label = "D" }; + var t4: TestTree = try .init(alloc, &v4); + defer t4.deinit(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer splitAB.deinit(); + + // A | C vertical + var splitAC = try splitAB.split( + alloc, + at: { + var it = splitAB.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }, + .down, // split down + 0.8, + &t3, // insert t3 + ); + defer splitAC.deinit(); + + // B | D vertical + var splitBD = try splitAC.split( + alloc, + at: { + var it = splitAB.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .down, // split down + 0.3, + &t4, // insert t4 + ); + defer splitBD.deinit(); + const split = splitBD; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| || B | + \\| |+---+ + \\| |+---+ + \\| A || | + \\| || | + \\| || | + \\| || D | + \\+---+| | + \\+---+| | + \\| C || | + \\+---++---+ + \\ + ); + } + + // Spatial C => right + { + const target = (try split.goto( + alloc, + from: { + var it = split.iterator(); + break :from while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "C")) { + break entry.handle; + } + } else return error.NotFound; + }, + .{ .spatial = .right }, + )).?; + const view = split.nodes[target].leaf; + try testing.expectEqualStrings(view.label, "D"); + } + + // Spatial D => left + { + const target = (try split.goto( + alloc, + from: { + var it = split.iterator(); + break :from while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "D")) { + break entry.handle; + } + } else return error.NotFound; + }, + .{ .spatial = .left }, + )).?; + const view = split.nodes[target].leaf; + try testing.expectEqualStrings("A", view.label); + } + + // Equalize + var equal = try split.equalize(alloc); + defer equal.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{equal}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\+---++---+ + \\| C || D | + \\+---++---+ + \\ + ); + } +} + +test "SplitTree: clone empty tree" { + const testing = std.testing; + const alloc = testing.allocator; + var t: TestTree = .empty; + defer t.deinit(); + + var t2 = try t.clone(alloc); + defer t2.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{}", .{t2}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\empty + ); + } +} diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 09c996290..59ea48e18 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -38,6 +38,10 @@ cursor_height: u32, /// The constraint height for nerd fonts icons. icon_height: u32, +/// Original cell width in pixels. This is used to keep +/// glyphs centered if the cell width is adjusted wider. +original_cell_width: ?u32 = null, + /// Minimum acceptable values for some fields to prevent modifiers /// from being able to, for example, cause 0-thickness underlines. const Minimums = struct { @@ -263,6 +267,11 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { const new = @max(entry.value_ptr.apply(original), 1); if (new == original) continue; + // Preserve the original cell width if not set. + if (self.original_cell_width == null) { + self.original_cell_width = self.cell_width; + } + // Set the new value @field(self, @tagName(tag)) = new; diff --git a/src/font/face.zig b/src/font/face.zig index a20df8c11..2902f97ae 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -222,6 +222,16 @@ pub const RenderOptions = struct { y: f64, }; + /// Returns true if the constraint does anything. If it doesn't, + /// because it neither sizes nor positions the glyph, then this + /// returns false. + pub inline fn doesAnything(self: Constraint) bool { + return self.size_horizontal != .none or + self.align_horizontal != .none or + self.size_vertical != .none or + self.align_vertical != .none; + } + /// Apply this constraint to the provided glyph /// size, given the available width and height. pub fn constrain( diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 2a3696d3f..1b1c559fb 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -346,89 +346,76 @@ pub const Face = struct { const metrics = opts.grid_metrics; const cell_width: f64 = @floatFromInt(metrics.cell_width); - // const cell_height: f64 = @floatFromInt(metrics.cell_height); + const cell_height: f64 = @floatFromInt(metrics.cell_height); + + // Next we apply any constraints to get the final size of the glyph. + var constraint = opts.constraint; // We eliminate any negative vertical padding since these overlap - // values aren't needed under CoreText with how precisely we apply - // constraints, and they can lead to extra height that looks bad - // for things like powerline glyphs. - var constraint = opts.constraint; + // values aren't needed with how precisely we apply constraints, + // and they can lead to extra height that looks bad for things like + // powerline glyphs. constraint.pad_top = @max(0.0, constraint.pad_top); constraint.pad_bottom = @max(0.0, constraint.pad_bottom); + + // We need to add the baseline position before passing to the constrain + // function since it operates on cell-relative positions, not baseline. + const cell_baseline: f64 = @floatFromInt(metrics.cell_baseline); + const glyph_size = constraint.constrain( .{ .width = rect.size.width, .height = rect.size.height, .x = rect.origin.x, - .y = rect.origin.y + @as(f64, @floatFromInt(metrics.cell_baseline)), + .y = rect.origin.y + cell_baseline, }, metrics, opts.constraint_width, ); - // These calculations are an attempt to mostly imitate the effect of - // `shouldSubpixelQuantizeFonts`[^1], which helps maximize legibility - // at small pixel sizes (low DPI). We do this math ourselves instead - // of letting CoreText do it because it's not entirely clear how the - // math in CoreText works and we've run in to edge cases where glyphs - // have their bottom or left row cut off due to bad rounding. - // - // This math seems to have a mostly comparable result to whatever it - // is that CoreText does, and is even (in my opinion) better in some - // cases. - // - // I'm not entirely certain but I suspect that when you enable the - // CoreText option it also does some sort of rudimentary hinting, - // but it doesn't seem to make that big of a difference in terms - // of legibility in the end. - // - // [^1]: https://developer.apple.com/documentation/coregraphics/cgcontext/setshouldsubpixelquantizefonts(_:)?language=objc + var x = glyph_size.x; + var y = glyph_size.y; + var width = glyph_size.width; + var height = glyph_size.height; - // We only want to apply quantization if we don't have any - // constraints and this isn't a bitmap glyph, since CoreText - // doesn't seem to apply its quantization to bitmap glyphs. - // - // TODO: Maybe gate this so it only applies at small font sizes, - // or else offer a user config option that can disable it. - const should_quantize = !sbix and std.meta.eql(opts.constraint, .none); + // If this is a bitmap glyph, it will always render as full pixels, + // not fractional pixels, so we need to quantize its position and + // size accordingly to align to full pixels so we get good results. + if (sbix) { + width = cell_width - @round(cell_width - width - x) - @round(x); + height = cell_height - @round(cell_height - height - y) - @round(y); + x = @round(x); + y = @round(y); + } - // We offset our glyph by its bearings when we draw it, using `@floor` - // here rounds it *up* since we negate it right outside. Moving it by - // whole pixels ensures that we don't disturb the pixel alignment of - // the glyph, fractional pixels will still be drawn on all sides as - // necessary. - const draw_x = -@floor(rect.origin.x); - const draw_y = -@floor(rect.origin.y); + // If the cell width was adjusted wider, we re-center all glyphs + // in the new width, so that they aren't weirdly off to the left. + if (metrics.original_cell_width) |original| recenter: { + // We don't do this if the constraint has a horizontal alignment, + // since in that case the position was already calculated with the + // new cell width in mind. + if (opts.constraint.align_horizontal != .none) break :recenter; - // We use `x` and `y` for our full pixel bearings post-raster. - // We need to subtract the fractional pixel of difference from - // the edge of the draw area to the edge of the actual glyph. - const frac_x = rect.origin.x + draw_x; - const frac_y = rect.origin.y + draw_y; - const x = glyph_size.x - frac_x; - const y = glyph_size.y - frac_y; + // If the original width was wider then we don't do anything. + if (original >= metrics.cell_width) break :recenter; - // We never modify the width. - // - // When using the CoreText option the widths do seem to be - // modified extremely subtly, but even at very small font - // sizes it's hardly a noticeable difference. - const width = glyph_size.width; + // We add half the difference to re-center. + x += (cell_width - @as(f64, @floatFromInt(original))) / 2; + } - // If the top of the glyph (taking in to account the y position) - // is within half a pixel of an exact pixel edge, we round up the - // height, otherwise leave it alone. - // - // This seems to match what CoreText does. - const frac_top = (glyph_size.height + frac_y) - @floor(glyph_size.height + frac_y); - const height = - if (should_quantize) - if (frac_top >= 0.5) - glyph_size.height + 1 - frac_top - else - glyph_size.height - else - glyph_size.height; + // Our whole-pixel bearings for the final glyph. + // The fractional portion will be included in the rasterized position. + const px_x: i32 = @intFromFloat(@floor(x)); + const px_y: i32 = @intFromFloat(@floor(y)); + + // We offset our glyph by its bearings when we draw it, so that it's + // rendered fully inside our canvas area, but we make sure to keep the + // fractional pixel offset so that we rasterize with the appropriate + // sub-pixel position. + const frac_x = x - @floor(x); + const frac_y = y - @floor(y); + const draw_x = -rect.origin.x + frac_x; + const draw_y = -rect.origin.y + frac_y; // Add the fractional pixel to the width and height and take // the ceiling to get a canvas size that will definitely fit @@ -511,7 +498,9 @@ pub const Face = struct { context.setAllowsFontSubpixelPositioning(ctx, true); context.setShouldSubpixelPositionFonts(ctx, true); - // See comments about quantization earlier in the function. + // We don't want subpixel quantization, since we very carefully + // manage the position of our glyphs ourselves, and dont want to + // mess that up. context.setAllowsFontSubpixelQuantization(ctx, false); context.setShouldSubpixelQuantizeFonts(ctx, false); @@ -553,46 +542,11 @@ pub const Face = struct { // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - const offset_y: i32 = @as(i32, @intFromFloat(@round(y))) + @as(i32, @intCast(px_height)); + const offset_y: i32 = px_y + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. - const offset_x: i32 = offset_x: { - // If the glyph's advance is narrower than the cell width then we - // center the advance of the glyph within the cell width. At first - // I implemented this to proportionally scale the center position - // of the glyph but that messes up glyphs that are meant to align - // vertically with others, so this is a compromise. - // - // This makes it so that when the `adjust-cell-width` config is - // used, or when a fallback font with a different advance width - // is used, we don't get weirdly aligned glyphs. - // - // We don't do this if the constraint has a horizontal alignment, - // since in that case the position was already calculated with the - // new cell width in mind. - if (opts.constraint.align_horizontal == .none) { - const advance = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, null); - const new_advance = - cell_width * @as(f64, @floatFromInt(opts.cell_width orelse 1)); - // If the original advance is greater than the cell width then - // it's possible that this is a ligature or other glyph that is - // intended to overflow the cell to one side or the other, and - // adjusting the bearings could mess that up, so we just leave - // it alone if that's the case. - // - // We also don't want to do anything if the advance is zero or - // less, since this is used for stuff like combining characters. - if (advance > new_advance or advance <= 0.0) { - break :offset_x @intFromFloat(@round(x)); - } - break :offset_x @intFromFloat( - @round(x + (new_advance - advance) / 2), - ); - } else { - break :offset_x @intFromFloat(@round(x)); - } - }; + const offset_x: i32 = px_x; return .{ .width = px_width, diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 4e7100396..6c888672b 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -156,23 +156,58 @@ pub const Face = struct { /// but sometimes allocation isn't required and a static string is /// returned. pub fn name(self: *const Face, buf: []u8) Allocator.Error![]const u8 { - // We don't use this today but its possible the table below - // returns UTF-16 in which case we'd want to use this for conversion. - _ = buf; - const count = self.face.getSfntNameCount(); // We look for the font family entry. for (0..count) |i| { const entry = self.face.getSfntName(i) catch continue; if (entry.name_id == freetype.c.TT_NAME_ID_FONT_FAMILY) { - return entry.string[0..entry.string_len]; + const string = entry.string[0..entry.string_len]; + // There are other encodings that are something other than UTF-8 + // but this is one we've seen "in the wild" so far. + if (entry.platform_id == freetype.c.TT_PLATFORM_MICROSOFT and entry.encoding_id == freetype.c.TT_MS_ID_UNICODE_CS) skip: { + if (string.len % 2 != 0) break :skip; + if (string.len > 1024) break :skip; + var tmp: [512]u16 = undefined; + const max = string.len / 2; + for (@as([]const u16, @alignCast(@ptrCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c); + const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string; + return buf[0..len]; + } + return string; } } return ""; } + test "face name" { + const embedded = @import("../embedded.zig"); + + var lib: Library = try .init(testing.allocator); + defer lib.deinit(); + + { + var face: Face = try .init(lib, embedded.variable, .{ .size = .{ .points = 14 } }); + defer face.deinit(); + + var buf: [1024]u8 = undefined; + const actual = try face.name(&buf); + + try testing.expectEqualStrings("JetBrains Mono", actual); + } + + { + var face: Face = try .init(lib, embedded.inconsolata, .{ .size = .{ .points = 14 } }); + defer face.deinit(); + + var buf: [1024]u8 = undefined; + const actual = try face.name(&buf); + + try testing.expectEqualStrings("Inconsolata", actual); + } + } + /// Return a new face that is the same as this but also has synthetic /// bold applied. pub fn syntheticBold(self: *const Face, opts: font.face.Options) !Face { @@ -328,19 +363,11 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); - // We enable hinting by default, and disable it if either of the - // constraint alignments are not center or none, since this means - // that the glyph needs to be aligned flush to the cell edge, and - // hinting can mess that up. - const do_hinting = self.load_flags.hinting and - switch (opts.constraint.align_horizontal) { - .start, .end => false, - .center, .none => true, - } and - switch (opts.constraint.align_vertical) { - .start, .end => false, - .center, .none => true, - }; + // Hinting should only be enabled if the configured load flags specify + // it and the provided constraint doesn't actually do anything, since + // if it does, then it'll mess up the hinting anyway when it moves or + // resizes the glyph. + const do_hinting = self.load_flags.hinting and !opts.constraint.doesAnything(); // Load the glyph. try self.face.loadGlyph(glyph_index, .{ @@ -356,6 +383,11 @@ pub const Face = struct { .force_autohint = self.load_flags.@"force-autohint", .no_autohint = !self.load_flags.autohint, + // If we're gonna be rendering this glyph in monochrome, + // then we should use the monochrome hinter as well, or + // else it won't look very good at all. + .target_mono = self.load_flags.monochrome, + // NO_SVG set to true because we don't currently support rendering // SVG glyphs under FreeType, since that requires bundling another // dependency to handle rendering the SVG. @@ -363,14 +395,45 @@ pub const Face = struct { }); const glyph = self.face.handle.*.glyph; - const glyph_width: f64 = f26dot6ToF64(glyph.*.metrics.width); - const glyph_height: f64 = f26dot6ToF64(glyph.*.metrics.height); + // We get a rect that represents the position + // and size of the glyph before any changes. + const rect: struct { + x: f64, + y: f64, + width: f64, + height: f64, + } = metrics: { + // If we're dealing with an outline glyph then we get the + // outline's bounding box instead of using the built-in + // metrics, since that's more precise and allows better + // cell-fitting. + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + // Get the glyph's bounding box before we transform it at all. + // We use this rather than the metrics, since it's more precise. + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); + + break :metrics .{ + .x = f26dot6ToF64(bbox.xMin), + .y = f26dot6ToF64(bbox.yMin), + .width = f26dot6ToF64(bbox.xMax - bbox.xMin), + .height = f26dot6ToF64(bbox.yMax - bbox.yMin), + }; + } + + break :metrics .{ + .x = f26dot6ToF64(glyph.*.metrics.horiBearingX), + .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height), + .width = f26dot6ToF64(glyph.*.metrics.width), + .height = f26dot6ToF64(glyph.*.metrics.height), + }; + }; // If our glyph is smaller than a quarter pixel in either axis // then it has no outlines or they're too small to render. // // In this case we just return 0-sized glyph struct. - if (glyph_width < 0.25 or glyph_height < 0.25) + if (rect.width < 0.25 or rect.height < 0.25) return font.Glyph{ .width = 0, .height = 0, @@ -391,31 +454,70 @@ pub const Face = struct { _ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount)); } - // Next we need to apply any constraints. const metrics = opts.grid_metrics; - const cell_width: f64 = @floatFromInt(metrics.cell_width); - // const cell_height: f64 = @floatFromInt(metrics.cell_height); + const cell_height: f64 = @floatFromInt(metrics.cell_height); - const glyph_x: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingX); - const glyph_y: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingY) - glyph_height; + // Next we apply any constraints to get the final size of the glyph. + var constraint = opts.constraint; - const glyph_size = opts.constraint.constrain( + // We eliminate any negative vertical padding since these overlap + // values aren't needed with how precisely we apply constraints, + // and they can lead to extra height that looks bad for things like + // powerline glyphs. + constraint.pad_top = @max(0.0, constraint.pad_top); + constraint.pad_bottom = @max(0.0, constraint.pad_bottom); + + // We need to add the baseline position before passing to the constrain + // function since it operates on cell-relative positions, not baseline. + const cell_baseline: f64 = @floatFromInt(metrics.cell_baseline); + + const glyph_size = constraint.constrain( .{ - .width = glyph_width, - .height = glyph_height, - .x = glyph_x, - .y = glyph_y + @as(f64, @floatFromInt(metrics.cell_baseline)), + .width = rect.width, + .height = rect.height, + .x = rect.x, + .y = rect.y + cell_baseline, }, metrics, opts.constraint_width, ); - const width = glyph_size.width; - const height = glyph_size.height; - // This may need to be adjusted later on. + var width = glyph_size.width; + var height = glyph_size.height; var x = glyph_size.x; - const y = glyph_size.y; + var y = glyph_size.y; + + // If this is a bitmap glyph, it will always render as full pixels, + // not fractional pixels, so we need to quantize its position and + // size accordingly to align to full pixels so we get good results. + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_BITMAP) { + width = cell_width - @round(cell_width - width - x) - @round(x); + height = cell_height - @round(cell_height - height - y) - @round(y); + x = @round(x); + y = @round(y); + } + + // If the cell width was adjusted wider, we re-center all glyphs + // in the new width, so that they aren't weirdly off to the left. + if (metrics.original_cell_width) |original| recenter: { + // We don't do this if the constraint has a horizontal alignment, + // since in that case the position was already calculated with the + // new cell width in mind. + if (opts.constraint.align_horizontal != .none) break :recenter; + + // If the original width was wider then we don't do anything. + if (original >= metrics.cell_width) break :recenter; + + // We add half the difference to re-center. + // + // NOTE: We round this to a whole-pixel amount because under + // FreeType, the outlines will be hinted, which isn't + // the case under CoreText. If we move the outlines by + // a non-whole-pixel amount, it completely ruins the + // hinting. + x += @round((cell_width - @as(f64, @floatFromInt(original))) / 2); + } // Now we can render the glyph. var bitmap: freetype.c.FT_Bitmap = undefined; @@ -429,8 +531,8 @@ pub const Face = struct { // matrix, since that has 16.16 coefficients, and also I was having // weird issues that I can only assume where due to freetype doing // some bad caching or something when I did this using the matrix. - const scale_x = width / glyph_width; - const scale_y = height / glyph_height; + const scale_x = width / rect.width; + const scale_y = height / rect.height; const skew: f64 = if (self.synthetic.italic) // We skew by 12 degrees to synthesize italics. @@ -438,19 +540,24 @@ pub const Face = struct { else 0.0; - var bbox_before: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_before); - const outline = &glyph.*.outline; for (outline.points[0..@intCast(outline.n_points)]) |*p| { // Convert to f64 for processing var px = f26dot6ToF64(p.x); var py = f26dot6ToF64(p.y); + // Subtract original bearings + px -= rect.x; + py -= rect.y; + // Scale px *= scale_x; py *= scale_y; + // Add new bearings + px += x; + py += y - cell_baseline; + // Skew px += py * skew; @@ -459,16 +566,6 @@ pub const Face = struct { p.y = @as(i32, @bitCast(F26Dot6.from(py))); } - var bbox_after: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_after); - - // If our bounding box changed, account for the lsb difference. - // - // This can happen when we skew glyphs that have a bit sticking - // out to the left higher up, like the top of the T or the serif - // on the lower case l in many monospace fonts. - x += f26dot6ToF64(bbox_after.xMin) - f26dot6ToF64(bbox_before.xMin); - try self.face.renderGlyph( if (self.load_flags.monochrome) .mono @@ -566,6 +663,10 @@ pub const Face = struct { ) != 0) { return error.BitmapHandlingError; } + + // Update the bearings to account for the new positioning. + glyph.*.bitmap_top = @intFromFloat(@floor(y - cell_baseline + height)); + glyph.*.bitmap_left = @intFromFloat(@floor(x)); }, else => |f| { @@ -600,6 +701,20 @@ pub const Face = struct { }, } + // Our whole-pixel bearings for the final glyph. + // The fractional portion will be included in the rasterized position. + // + // For the Y position, FreeType's `bitmap_top` is the distance from the + // baseline to the top of the glyph, but we need the distance from the + // bottom of the cell to the bottom of the glyph, so first we add the + // baseline to get the distance from the bottom of the cell to the top + // of the glyph, then we subtract the height of the glyph to get the + // bottom. + const px_x: i32 = glyph.*.bitmap_left; + const px_y: i32 = glyph.*.bitmap_top + + @as(i32, @intCast(metrics.cell_baseline)) - + @as(i32, @intCast(bitmap.rows)); + const px_width = bitmap.width; const px_height = bitmap.rows; const len: usize = @intCast( @@ -635,48 +750,11 @@ pub const Face = struct { // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - const offset_y: i32 = - @as(i32, @intFromFloat(@floor(y))) + - @as(i32, @intCast(px_height)); + const offset_y: i32 = px_y + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. - const offset_x: i32 = offset_x: { - // If the glyph's advance is narrower than the cell width then we - // center the advance of the glyph within the cell width. At first - // I implemented this to proportionally scale the center position - // of the glyph but that messes up glyphs that are meant to align - // vertically with others, so this is a compromise. - // - // This makes it so that when the `adjust-cell-width` config is - // used, or when a fallback font with a different advance width - // is used, we don't get weirdly aligned glyphs. - // - // We don't do this if the constraint has a horizontal alignment, - // since in that case the position was already calculated with the - // new cell width in mind. - if (opts.constraint.align_horizontal == .none) { - const advance = f26dot6ToFloat(glyph.*.advance.x); - const new_advance = - cell_width * @as(f64, @floatFromInt(opts.cell_width orelse 1)); - // If the original advance is greater than the cell width then - // it's possible that this is a ligature or other glyph that is - // intended to overflow the cell to one side or the other, and - // adjusting the bearings could mess that up, so we just leave - // it alone if that's the case. - // - // We also don't want to do anything if the advance is zero or - // less, since this is used for stuff like combining characters. - if (advance > new_advance or advance <= 0.0) { - break :offset_x @intFromFloat(@floor(x)); - } - break :offset_x @intFromFloat( - @floor(x + (new_advance - advance) / 2), - ); - } else { - break :offset_x @intFromFloat(@floor(x)); - } - }; + const offset_x: i32 = px_x; return Glyph{ .width = px_width, diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index afc7d9adb..285a5a6b9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1833,7 +1833,10 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); - var shaper = try Shaper.init(alloc, .{}); + var shaper = try Shaper.init(alloc, .{ + // Some of our tests rely on dlig being enabled by default + .features = &.{"dlig"}, + }); errdefer shaper.deinit(); return TestShaper{ diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig index 66d0cb1f7..5fce7d6eb 100644 --- a/src/font/shaper/feature.zig +++ b/src/font/shaper/feature.zig @@ -287,7 +287,6 @@ pub const FeatureList = struct { /// These features are hardcoded to always be on by default. Users /// can turn them off by setting the features to "-liga" for example. pub const default_features = [_]Feature{ - .{ .tag = "dlig".*, .value = 1 }, .{ .tag = "liga".*, .value = 1 }, }; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 8a0beab8b..b5c96797f 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1296,7 +1296,10 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { grid_ptr.* = try .init(alloc, .{ .collection = c }); errdefer grid_ptr.*.deinit(alloc); - var shaper = try Shaper.init(alloc, .{}); + var shaper = try Shaper.init(alloc, .{ + // Some of our tests rely on dlig being enabled by default + .features = &.{"dlig"}, + }); errdefer shaper.deinit(); return TestShaper{ diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 92e629e19..90917f657 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -247,7 +247,7 @@ pub const RunIterator = struct { if (j == self.i) current_font = font_info.idx; // If our fonts are not equal, then we're done with our run. - if (font_info.idx.int() != current_font.int()) break; + if (font_info.idx != current_font) break; // If we're a fallback character, add that and continue; we // don't want to add the entire grapheme. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f76da360a..b20319810 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -5,6 +5,7 @@ const Binding = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const build_config = @import("../build_config.zig"); const ziglyph = @import("ziglyph"); const key = @import("key.zig"); const KeyEvent = key.KeyEvent; @@ -729,6 +730,16 @@ pub const Action = union(enum) { pub const Key = @typeInfo(Action).@"union".tag_type.?; + /// Make this a valid gobject if we're in a GTK environment. + pub const getGObjectType = switch (build_config.app_runtime) { + .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + Action, + .{ .name = "GhosttyBindingAction" }, + ), + + .none => void, + }; + pub const CrashThread = enum { main, io, diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index edc204886..d3e7fcaaa 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -756,7 +756,7 @@ fn renderSizeWindow(self: *Inspector) void { { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText( - "%d px", + "%.2f px", self.surface.font_size.pixels(), ); } diff --git a/src/os/i18n.zig b/src/os/i18n.zig index 2ecae27ac..c6bce6fbf 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -73,6 +73,8 @@ pub const InitError = error{ /// want to set the domain for the entire application since this is also /// used by libghostty. pub fn init(resources_dir: []const u8) InitError!void { + if (comptime !build_config.i18n) return; + switch (builtin.os.tag) { // i18n is unsupported on Windows .windows => return, @@ -102,11 +104,13 @@ pub fn init(resources_dir: []const u8) InitError!void { /// This should only be called for apprts that are fully owning the /// Ghostty application. This should not be called for libghostty users. pub fn initGlobalDomain() error{OutOfMemory}!void { + if (comptime !build_config.i18n) return; _ = textdomain(build_config.bundle_id) orelse return error.OutOfMemory; } /// Translate a message for the Ghostty domain. pub fn _(msgid: [*:0]const u8) [*:0]const u8 { + if (comptime !build_config.i18n) return msgid; return dgettext(build_config.bundle_id, msgid); } @@ -132,8 +136,15 @@ pub fn canonicalizeLocale( buf: []u8, locale: []const u8, ) error{NoSpaceLeft}![:0]const u8 { + if (comptime !build_config.i18n) return locale; + // Fix zh locales for macOS - if (fixZhLocale(locale)) |fixed| return fixed; + if (fixZhLocale(locale)) |fixed| { + if (buf.len < fixed.len + 1) return error.NoSpaceLeft; + @memcpy(buf[0..fixed.len], fixed); + buf[fixed.len] = 0; + return buf[0..fixed.len :0]; + } // Buffer must be 16 or at least as long as the locale and null term if (buf.len < @max(16, locale.len + 1)) return error.NoSpaceLeft; diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 7de61e9b3..8c16d8cea 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -229,24 +229,39 @@ pub fn isCovering(cp: u21) bool { }; } +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area and anything +/// in the "dingbats" unicode block. +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn isSymbol(cp: u21) bool { + return uucode.get(.general_category, cp) == .other_private_use or + uucode.get(.block, cp) == .dingbats; +} + /// Returns the appropriate `constraint_width` for /// the provided cell when rendering its glyph(s). pub fn constraintWidth(cell_pin: terminal.Pin) u2 { const cell = cell_pin.rowAndCell().cell; const cp = cell.codepoint(); - // If not a Co (Private Use) and not a Dingbats, use grid width. - if (uucode.get("general_category", cp) != .Co and - uucode.get("block", cp) != .dingbats) - { - return cell.gridWidth(); - } + const grid_width = cell.gridWidth(); + + // If the grid width of the cell is 2, the constraint + // width will always be 2, so we can just return early. + if (grid_width > 1) return grid_width; + + // We allow "symbol-like" glyphs to extend to 2 cells wide if there's + // space, and if the previous glyph wasn't also a symbol. So if this + // codepoint isn't a symbol then we can return the grid width. + if (!isSymbol(cp)) return grid_width; // If we are at the end of the screen it must be constrained to one cell. if (cell_pin.x == cell_pin.node.data.size.cols - 1) return 1; - // If we have a previous cell and it was PUA then we need to - // also constrain. This is so that multiple PUA glyphs align. + // If we have a previous cell and it was a symbol then we need + // to also constrain. This is so that multiple PUA glyphs align. // As an exception, we ignore powerline glyphs since they are // used for box drawing and we consider them whitespace. if (cell_pin.x > 0) prev: { @@ -260,14 +275,13 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { // We consider powerline glyphs whitespace. if (isPowerline(prev_cp)) break :prev; - // If it's Private Use (Co) use 1 as the width. - if (uucode.get("general_category", prev_cp) == .Co) { + if (isSymbol(prev_cp)) { return 1; } } - // If the next cell is whitespace, then - // we allow it to be up to two cells wide. + // If the next cell is whitespace, then we + // allow the glyph to be up to two cells wide. const next_cp = next_cp: { var copy = cell_pin; copy.x += 1; @@ -281,7 +295,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { return 2; } - // Must be constrained + // Otherwise, this has to be 1 cell wide. return 1; } diff --git a/valgrind.supp b/valgrind.supp index 3535ecc45..cf82b7c2a 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -13,6 +13,66 @@ # You must gracefully exit Ghostty (do not SIGINT) by closing all windows # and quitting. Otherwise, we leave a number of GTK resources around. + +# Reproduction: +# 1. Launch Ghostty (no config) +# 2. Right Click on the terminal +# 3. Hover over "Split" to get a submenu +# 4. Close menu by clicking away +# 5. Exit +# +# The menu model and popover are fully defined in the blueprint so I don't +# THINK we need to do any manual unrefing. But there's a lot of leaks here +# so if someone wants to take a closer look I'd appreciate it. +{ + GTK PopOver Menu Model Leak + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gtk_menu_section_box_insert_func + ... + fun:gtk_popover_menu_set_menu_model + ... +} +{ + GTK/Blueprint Popover GSK Transform + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gtk_popover_size_allocate + fun:gtk_widget_allocate + fun:gtk_popover_native_layout + ... +} + +# Reproduction: +# +# 1. Launch Ghostty +# 2. Split Right +# 3. Hit "X" to close +{ + GTK CSS Node State + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + fun:g_malloc + fun:g_memdup2 + fun:gtk_css_node_declaration_set_state + fun:gtk_css_node_set_state + fun:gtk_widget_propagate_state + fun:gtk_widget_update_state_flags + fun:gtk_main_do_event + fun:surface_event + fun:_gdk_marshal_BOOLEAN__POINTERv + fun:gdk_surface_event_marshallerv + fun:_g_closure_invoke_va + fun:signal_emit_valist_unlocked + fun:g_signal_emit_valist + fun:g_signal_emit + fun:gdk_surface_handle_event + ... +} + { GTK CSS Provider Leak Memcheck:Leak @@ -484,9 +544,7 @@ pango font map Memcheck:Leak match-leak-kinds: possible - fun:calloc - fun:g_malloc0 - fun:g_rc_box_alloc_full + ... fun:pango_fc_font_map_load_fontset ... } @@ -840,6 +898,26 @@ fun:FcConfigSubstituteWithPat } +{ + FcConfigValues + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + obj:/usr/lib*/libfontconfig.so* + obj:/usr/lib*/libfontconfig.so* + fun:FcConfigValues +} + +{ + FcValueSave + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + obj:/usr/lib*/libfontconfig.so* + obj:/usr/lib*/libfontconfig.so* + fun:FcValueSave +} + # Pixman { pixman_image_composite32