From 2bcc2fa4bd63652fa7520650768719334929e963 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 11 Aug 2025 12:52:11 -0700 Subject: [PATCH 01/53] split_tree: resize function --- src/datastruct/split_tree.zig | 183 +++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 5 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 6d224757b..f7c15366c 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -698,6 +698,112 @@ pub fn SplitTree(comptime V: type) type { }; } + /// Resize the nearest split matching the layout by the given ratio. + /// Positive is right and down. + /// + /// The ratio is a value between 0 and 1 representing the percentage + /// to move the divider in the given direction. The percentage is + /// of the entire grid size, not just the specific split size. + /// We use the entire grid size because that's what Ghostty's + /// `resize_split` keybind does, because it maps to a general human + /// understanding of moving a split relative to the entire window + /// (generally). + /// + /// For example, a ratio of 0.1 and a layout of `vertical` will find + /// the nearest vertical split and move the divider down by 10% of + /// the total grid height. + /// + /// If no matching split is found, this does nothing, but will always + /// still return a cloned tree. + pub fn resize( + self: *const Self, + gpa: Allocator, + from: Node.Handle, + layout: Split.Layout, + ratio: f16, + ) Allocator.Error!Self { + assert(ratio >= 0 and ratio <= 1); + + // Fast path empty trees. + if (self.isEmpty()) return .empty; + + // From this point forward worst case we return a clone. + var result = try self.clone(gpa); + errdefer result.deinit(); + + // Find our nearest parent split node matching the layout. + const parent_handle = switch (self.findParentSplit( + layout, + from, + 0, + )) { + .deadend, .backtrack => return result, + .result => |v| v, + }; + + // Get our spatial layout, because we need the dimensions of this + // split with regards to the entire grid. + var sp = try result.spatial(gpa); + defer sp.deinit(gpa); + + // Get the ratio of the split relative to the full grid. + const full_ratio = full_ratio: { + // Our scale is the amount we need to multiply our individual + // ratio by to get the full ratio. Its actually a ratio on its + // own but I'm trying to avoid that word: its the ratio of + // our spatial width/height to the total. + const scale = switch (layout) { + .horizontal => sp.slots[parent_handle].width / sp.slots[0].width, + .vertical => sp.slots[parent_handle].height / sp.slots[0].height, + }; + + const current = result.nodes[parent_handle].split.ratio; + break :full_ratio current * scale; + }; + + // Set the final new ratio, clamping it to [0, 1] + result.resizeInPlace( + parent_handle, + @min(@max(full_ratio + ratio, 0), 1), + ); + return result; + } + + fn findParentSplit( + self: *const Self, + layout: Split.Layout, + from: Node.Handle, + current: Node.Handle, + ) Backtrack { + if (from == current) return .backtrack; + return switch (self.nodes[current]) { + .leaf => .deadend, + .split => |s| switch (self.findParentSplit( + layout, + from, + s.left, + )) { + .result => |v| .{ .result = v }, + .backtrack => if (s.layout == layout) + .{ .result = current } + else + .backtrack, + .deadend => switch (self.findParentSplit( + layout, + from, + s.right, + )) { + .deadend => .deadend, + .result => |v| .{ .result = v }, + .backtrack => if (s.layout == layout) + .{ .result = current } + else + .backtrack, + }, + }, + }; + } + /// Spatial representation of the split tree. See spatial. pub const Spatial = struct { /// The slots of the spatial representation in the same order @@ -732,11 +838,11 @@ pub fn SplitTree(comptime V: type) type { /// 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 bounds of the representation are always based on the total + /// 2D space being 1x1. The x/y coordinates and width/height dimensions + /// of each individual split and leaf are relative to this. + /// 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). /// @@ -766,6 +872,14 @@ pub fn SplitTree(comptime V: type) type { }; self.fillSpatialSlots(slots, 0); + // Normalize the dimensions to 1x1 grid. + for (slots) |*slot| { + slot.x /= @floatFromInt(dim.width); + slot.y /= @floatFromInt(dim.height); + slot.width /= @floatFromInt(dim.width); + slot.height /= @floatFromInt(dim.height); + } + return .{ .slots = slots }; } @@ -1639,6 +1753,65 @@ test "SplitTree: spatial goto" { } } +test "SplitTree: resize" { + 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(); + + // A | B horizontal + var split = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---++---+ + \\| A || B | + \\+---++---+ + \\ + ); + } + + // Resize + { + var resized = try split.resize( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + .horizontal, // resize right + 0.25, + ); + defer resized.deinit(); + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{resized}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+-------------++---+ + \\| A || B | + \\+-------------++---+ + \\ + ); + } +} + test "SplitTree: clone empty tree" { const testing = std.testing; const alloc = testing.allocator; From 8d8812cb6a11b5f564026a8551f72c44ac61bdf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 08:52:43 +0000 Subject: [PATCH 02/53] build(deps): bump actions/checkout from 4.2.2 to 5.0.0 Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/11bd71901bbe5b1630ceea73d27597364c9af683...08c6903cd8c0fde910a37f88322edcfb5dd907a8) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-pr.yml | 8 ++-- .github/workflows/release-tag.yml | 8 ++-- .github/workflows/release-tip.yml | 16 +++---- .github/workflows/test.yml | 52 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 2dd6a13e7..522847c88 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -34,7 +34,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d920b92f1..6fa813c31 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -8,7 +8,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos-debug] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -29,7 +29,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -51,7 +51,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -205,7 +205,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c0a051753..9c92d45a9 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -128,7 +128,7 @@ jobs: GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 with: @@ -299,7 +299,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download macOS Artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 616ee84fe..58e114f1b 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -19,7 +19,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -31,7 +31,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos-debug-slow] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -52,7 +52,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos-debug-fast] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -73,7 +73,7 @@ jobs: runs-on: namespace-profile-ghostty-sm needs: [build-macos] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install sentry-cli run: | @@ -105,7 +105,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -158,7 +158,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -378,7 +378,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -558,7 +558,7 @@ jobs: timeout-minutes: 90 steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: # Important so that build number generation works fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75db53a4d..5c6b57d69 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -98,7 +98,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -134,7 +134,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -163,7 +163,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -196,7 +196,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -240,7 +240,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -276,7 +276,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 @@ -319,7 +319,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main @@ -359,7 +359,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 @@ -437,7 +437,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # This could be from a script if we wanted to but inlining here for now # in one place. @@ -506,7 +506,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -551,7 +551,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -600,7 +600,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -648,7 +648,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -675,7 +675,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 @@ -704,7 +704,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -732,7 +732,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -759,7 +759,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -786,7 +786,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -813,7 +813,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -840,7 +840,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -874,7 +874,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -901,7 +901,7 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -935,7 +935,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 @@ -994,7 +994,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache uses: namespacelabs/nscloud-cache-action@305bfa7ea980a858d511af4899414a84847c7991 # v1.2.16 with: @@ -1030,7 +1030,7 @@ jobs: runs-on: ${{ matrix.variant.runner }} needs: [flatpak-check-zig-cache, test] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: flatpak/flatpak-github-actions/flatpak-builder@10a3c29f0162516f0f68006be14c92f34bd4fa6c # v6.5 with: bundle: com.mitchellh.ghostty diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index d614814ad..e1ee92168 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -17,7 +17,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 From 4afd3445c4013693a85ecdde04d02c17010decae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 10:46:36 -0700 Subject: [PATCH 03/53] split_tree: fix bugs for 0/1 sized ratios --- src/datastruct/split_tree.zig | 145 +++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index f7c15366c..191e64238 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -888,7 +888,7 @@ pub fn SplitTree(comptime V: type) type { slots: []Spatial.Slot, current: Node.Handle, ) void { - assert(slots[current].width > 0 and slots[current].height > 0); + assert(slots[current].width >= 0 and slots[current].height >= 0); switch (self.nodes[current]) { // Leaf node, current slot is already filled by caller. @@ -1040,8 +1040,8 @@ pub fn SplitTree(comptime V: type) type { 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); + if (slot.width > 0) min_w = @min(min_w, slot.width); + if (slot.height > 0) min_h = @min(min_h, slot.height); } const ratio_w: f16 = 1 / min_w; @@ -1121,6 +1121,9 @@ pub fn SplitTree(comptime V: type) type { .split => continue, } + // If our width/height is zero then we skip this. + if (slot.width == 0 or slot.height == 0) continue; + var x: usize = @intFromFloat(@floor(slot.x)); var y: usize = @intFromFloat(@floor(slot.y)); var width: usize = @intFromFloat(@max(@floor(slot.width), 1)); @@ -1492,6 +1495,142 @@ test "SplitTree: split vertical" { ); } +test "SplitTree: split horizontal with zero ratio" { + 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(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + 0, // at root + .right, // split right + 0, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); + } +} + +test "SplitTree: split vertical with zero ratio" { + 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(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + 0, // at root + .down, // split right + 0, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| B | + \\+---+ + \\ + ); + } +} + +test "SplitTree: split horizontal with full width" { + 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(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + 0, // at root + .right, // split right + 1, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\ + ); + } +} + +test "SplitTree: split vertical with full width" { + 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(); + + // A | B horizontal + var splitAB = try t1.split( + alloc, + 0, // at root + .down, // split right + 1, + &t2, // insert t2 + ); + defer splitAB.deinit(); + const split = splitAB; + + { + const str = try std.fmt.allocPrint(alloc, "{diagram}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\+---+ + \\| A | + \\+---+ + \\ + ); + } +} + test "SplitTree: remove leaf" { const testing = std.testing; const alloc = testing.allocator; From 93da59682f36f8c275721e9c01a006b0986ee994 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 11:04:17 -0700 Subject: [PATCH 04/53] apprt/gtk-ng: resizeSplit action --- src/apprt/gtk-ng/class/application.zig | 40 ++++++++++++++++++++++- src/apprt/gtk-ng/class/split_tree.zig | 45 ++++++++++++++++++++++++++ src/datastruct/split_tree.zig | 2 ++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 4a14434fa..3b5d5929b 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -597,6 +597,8 @@ pub const Application = extern struct { .render => Action.render(target), + .resize_split => return Action.resizeSplit(target, value), + .ring_bell => Action.ringBell(target), .set_title => Action.setTitle(target, value), @@ -618,7 +620,6 @@ pub const Application = extern struct { .prompt_title, .inspector, // TODO: splits - .resize_split, .toggle_split_zoom, => { log.warn("unimplemented action={}", .{action}); @@ -2003,6 +2004,43 @@ const Action = struct { } } + pub fn resizeSplit( + target: apprt.Target, + value: apprt.action.ResizeSplit, + ) bool { + switch (target) { + .app => { + log.warn("resize_split to app is unexpected", .{}); + return false; + }, + .surface => |core| { + 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.resize( + switch (value.direction) { + .up => .up, + .down => .down, + .left => .left, + .right => .right, + }, + value.amount, + ) catch |err| switch (err) { + error.OutOfMemory => { + log.warn("unable to resize split, out of memory", .{}); + return false; + }, + }; + }, + } + } + pub fn ringBell(target: apprt.Target) void { switch (target) { .app => {}, diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 5eb0a5472..f3fa46750 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -261,6 +261,51 @@ pub const SplitTree = extern struct { self.setTree(&new_tree); } + pub fn resize( + self: *Self, + direction: Surface.Tree.Split.Direction, + amount: u16, + ) Allocator.Error!bool { + // Avoid useless work + if (amount == 0) return false; + + const old_tree = self.getTree() orelse return false; + const active = self.getActiveSurfaceHandle() orelse return false; + + // Get all our dimensions we're going to need to turn our + // amount into a percentage. + const priv = self.private(); + const width = priv.tree_bin.as(gtk.Widget).getWidth(); + const height = priv.tree_bin.as(gtk.Widget).getHeight(); + if (width == 0 or height == 0) return false; + const width_f64: f64 = @floatFromInt(width); + const height_f64: f64 = @floatFromInt(height); + const amount_f64: f64 = @floatFromInt(amount); + + // Get our ratio and use positive/neg for directions. + const ratio: f64 = switch (direction) { + .right => amount_f64 / width_f64, + .left => -(amount_f64 / width_f64), + .down => amount_f64 / height_f64, + .up => -(amount_f64 / height_f64), + }; + + const layout: Surface.Tree.Split.Layout = switch (direction) { + .left, .right => .horizontal, + .up, .down => .vertical, + }; + + var new_tree = try old_tree.resize( + Application.default().allocator(), + active, + layout, + @floatCast(ratio), + ); + defer new_tree.deinit(); + self.setTree(&new_tree); + return true; + } + /// 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 { diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 191e64238..29aab3fdc 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -723,6 +723,8 @@ pub fn SplitTree(comptime V: type) type { ratio: f16, ) Allocator.Error!Self { assert(ratio >= 0 and ratio <= 1); + assert(!std.math.isNan(ratio)); + assert(!std.math.isInf(ratio)); // Fast path empty trees. if (self.isEmpty()) return .empty; From 145d1c17397f12d90808304fce9cdf94c230666b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 12:36:21 -0700 Subject: [PATCH 05/53] split_tree: track zoomed state --- src/apprt/gtk-ng/class/application.zig | 18 +- src/apprt/gtk-ng/class/split_tree.zig | 23 ++- src/datastruct/split_tree.zig | 257 +++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 22 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 3b5d5929b..ed5eb9ff0 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -615,12 +615,11 @@ pub const Application = extern struct { .toggle_tab_overview => return Action.toggleTabOverview(target), .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), + .toggle_split_zoom => return Action.toggleSplitZoom(target), // Unimplemented but todo on gtk-ng branch .prompt_title, .inspector, - // TODO: splits - .toggle_split_zoom, => { log.warn("unimplemented action={}", .{action}); return false; @@ -2121,6 +2120,21 @@ const Action = struct { return true; } + pub fn toggleSplitZoom(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("toggle_split_zoom to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + // TODO: pass surface ID when we have that + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("split-tree.zoom", null) != 0; + }, + } + } + fn getQuickTerminalWindow() ?*Window { // Find a quick terminal window. const list = gtk.Window.listToplevels(); diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index f3fa46750..7e370fe41 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -165,6 +165,7 @@ pub const SplitTree = extern struct { .{ "new-down", actionNewDown, null }, .{ "equalize", actionEqualize, null }, + .{ "zoom", actionZoom, null }, }; // We need to collect our actions into a group since we're just @@ -600,6 +601,23 @@ pub const SplitTree = extern struct { self.setTree(&new_tree); } + pub fn actionZoom( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tree = self.getTree() orelse return; + if (tree.zoomed != null) { + tree.zoomed = null; + } else { + const active = self.getActiveSurfaceHandle() orelse return; + if (tree.zoomed == active) return; + tree.zoom(active); + } + + self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); + } + fn surfaceCloseRequest( surface: *Surface, scope: *const Surface.CloseScope, @@ -797,7 +815,10 @@ pub const SplitTree = extern struct { // Rebuild our tree const tree: *const Surface.Tree = self.private().tree orelse &.empty; if (!tree.isEmpty()) { - priv.tree_bin.setChild(self.buildTree(tree, 0)); + priv.tree_bin.setChild(self.buildTree( + tree, + tree.zoomed orelse 0, + )); } // If we have a last focused surface, we need to refocus it, because diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 29aab3fdc..aa7d87c13 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -56,6 +56,11 @@ pub fn SplitTree(comptime V: type) type { /// All the nodes in the tree. Node at index 0 is always the root. nodes: []const Node, + /// The handle of the zoomed node. A "zoomed" node is one that is + /// expected to be made the full size of the split tree. Various + /// operations may unzoom (e.g. resize). + zoomed: ?Node.Handle, + /// An empty tree. pub const empty: Self = .{ // Arena can be undefined because we have zero allocated nodes. @@ -63,6 +68,7 @@ pub fn SplitTree(comptime V: type) type { // arena. .arena = undefined, .nodes = &.{}, + .zoomed = null, }; pub const Node = union(enum) { @@ -98,6 +104,7 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes, + .zoomed = null, }; } @@ -136,6 +143,7 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes, + .zoomed = self.zoomed, }; } @@ -177,6 +185,13 @@ pub fn SplitTree(comptime V: type) type { } }; + /// Change the zoomed state to the given node. Assumes the handle + /// is valid. + pub fn zoom(self: *Self, handle: ?Node.Handle) void { + if (handle) |v| assert(v >= 0 and v < self.nodes.len); + self.zoomed = handle; + } + pub const Goto = union(enum) { /// Previous view, null if we're the first view. previous, @@ -472,7 +487,12 @@ pub fn SplitTree(comptime V: type) type { // We need to increase the reference count of all the nodes. try refNodes(gpa, nodes); - return .{ .arena = arena, .nodes = nodes }; + return .{ + .arena = arena, + .nodes = nodes, + // Splitting always resets zoom state. + .zoomed = null, + }; } /// Remove a node from the tree. @@ -499,9 +519,15 @@ pub fn SplitTree(comptime V: type) type { 0, )); + var result: Self = .{ + .arena = arena, + .nodes = nodes, + .zoomed = null, + }; + // Traverse the tree and copy all our nodes into place. assert(self.removeNode( - nodes, + &result, 0, 0, at, @@ -510,27 +536,39 @@ pub fn SplitTree(comptime V: type) type { // Increase the reference count of all the nodes. try refNodes(gpa, nodes); - return .{ - .arena = arena, - .nodes = nodes, - }; + return result; } fn removeNode( - self: *Self, - nodes: []Node, + old: *Self, + new: *Self, new_offset: Node.Handle, current: Node.Handle, target: Node.Handle, ) Node.Handle { assert(current != target); - switch (self.nodes[current]) { + // If we have a zoomed node and this is it then we migrate it. + if (old.zoomed) |v| { + if (v == current) { + assert(new.zoomed == null); + new.zoomed = new_offset; + } + } + + // 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 new_nodes: []Node = @constCast(new.nodes); + + switch (old.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 }; + new_nodes[new_offset] = .{ .leaf = view }; return 1; }, @@ -538,35 +576,35 @@ pub fn SplitTree(comptime V: type) type { // 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, + if (s.left == target) return old.removeNode( + new, new_offset, s.right, target, ); - if (s.right == target) return self.removeNode( - nodes, + if (s.right == target) return old.removeNode( + new, new_offset, s.left, target, ); // Neither side is being directly removed, so we traverse. - const left = self.removeNode( - nodes, + const left = old.removeNode( + new, new_offset + 1, s.left, target, ); assert(left > 0); - const right = self.removeNode( - nodes, + const right = old.removeNode( + new, new_offset + 1 + left, s.right, target, ); assert(right > 0); - nodes[new_offset] = .{ .split = .{ + new_nodes[new_offset] = .{ .split = .{ .layout = s.layout, .ratio = s.ratio, .left = new_offset + 1, @@ -679,6 +717,7 @@ pub fn SplitTree(comptime V: type) type { return .{ .arena = arena, .nodes = nodes, + .zoomed = self.zoomed, }; } @@ -1005,6 +1044,10 @@ pub fn SplitTree(comptime V: type) type { ) !void { for (0..depth) |_| try writer.writeAll(" "); + if (self.zoomed) |zoomed| if (zoomed == current) { + try writer.writeAll("(zoomed) "); + }; + switch (self.nodes[current]) { .leaf => |v| if (@hasDecl(View, "splitTreeLabel")) try writer.print("leaf: {s}\n", .{v.splitTreeLabel()}) @@ -1970,3 +2013,179 @@ test "SplitTree: clone empty tree" { ); } } + +test "SplitTree: zoom" { + 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(); + + // A | B horizontal + var split = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + split.zoom(at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }); + + { + const str = try std.fmt.allocPrint(alloc, "{text}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ (zoomed) leaf: B + \\ + ); + } + + // Clone preserves zoom + var clone = try split.clone(alloc); + defer clone.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{text}", .{clone}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ (zoomed) leaf: B + \\ + ); + } +} + +test "SplitTree: split resets zoom" { + 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(); + + // Zoom A + t1.zoom(at: { + var it = t1.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }); + + // A | B horizontal + var split = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + + { + const str = try std.fmt.allocPrint(alloc, "{text}", .{split}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\split (layout: horizontal, ratio: 0.50) + \\ leaf: A + \\ leaf: B + \\ + ); + } +} + +test "SplitTree: remove and zoom" { + 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(); + + // A | B horizontal + var split = try t1.split( + alloc, + 0, // at root + .right, // split right + 0.5, + &t2, // insert t2 + ); + defer split.deinit(); + split.zoom(at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }); + + // Remove A, should unzoom + { + var removed = try split.remove( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "A")) { + break entry.handle; + } + } else return error.NotFound; + }, + ); + defer removed.deinit(); + try testing.expect(removed.zoomed == null); + + const str = try std.fmt.allocPrint(alloc, "{text}", .{removed}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\leaf: B + \\ + ); + } + + // Remove B, should keep zoom + { + var removed = try split.remove( + alloc, + at: { + var it = split.iterator(); + break :at while (it.next()) |entry| { + if (std.mem.eql(u8, entry.view.label, "B")) { + break entry.handle; + } + } else return error.NotFound; + }, + ); + defer removed.deinit(); + + const str = try std.fmt.allocPrint(alloc, "{text}", .{removed}); + defer alloc.free(str); + try testing.expectEqualStrings(str, + \\(zoomed) leaf: A + \\ + ); + } +} From fb846b669c5fea01768370b028f660895de9b02b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 13:34:34 -0700 Subject: [PATCH 06/53] split_tree: convert Handle to enum --- src/apprt/gtk-ng/class/split_tree.zig | 14 +-- src/datastruct/split_tree.zig | 172 ++++++++++++++------------ 2 files changed, 103 insertions(+), 83 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 7e370fe41..87eadf9c8 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -242,7 +242,7 @@ pub const SplitTree = extern struct { // 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; + const handle = self.getActiveSurfaceHandle() orelse .root; // Create our split! var new_tree = try old_tree.split( @@ -329,7 +329,7 @@ pub const SplitTree = extern struct { if (active == target) return false; // Get the surface at the target location and grab focus. - const surface = tree.nodes[target].leaf; + const surface = tree.nodes[target.idx()].leaf; surface.grabFocus(); return true; @@ -389,7 +389,7 @@ pub const SplitTree = extern struct { 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; + return tree.nodes[handle.idx()].leaf; } fn getActiveSurfaceHandle(self: *Self) ?Surface.Tree.Node.Handle { @@ -697,7 +697,7 @@ pub const SplitTree = extern struct { // 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; + break :next_focus old_tree.nodes[next_handle.idx()].leaf; }; // Remove it from the tree. @@ -817,7 +817,7 @@ pub const SplitTree = extern struct { if (!tree.isEmpty()) { priv.tree_bin.setChild(self.buildTree( tree, - tree.zoomed orelse 0, + tree.zoomed orelse .root, )); } @@ -844,7 +844,7 @@ pub const SplitTree = extern struct { tree: *const Surface.Tree, current: Surface.Tree.Node.Handle, ) *gtk.Widget { - return switch (tree.nodes[current]) { + return switch (tree.nodes[current.idx()]) { .leaf => |v| v.as(gtk.Widget), .split => |s| SplitTreeSplit.new( current, @@ -1003,7 +1003,7 @@ const SplitTreeSplit = extern struct { 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; + const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; // Current, min, and max positions as pixels. const pos = paned.getPosition(); diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index aa7d87c13..57da22109 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -78,7 +78,24 @@ pub fn SplitTree(comptime V: type) type { /// 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 Handle = enum(Backing) { + root = 0, + _, + + pub const Backing = u16; + + pub inline fn idx(self: Handle) usize { + return @intFromEnum(self); + } + + /// Offset the handle by a given amount. + pub fn offset(self: Handle, v: usize) Handle { + const self_usize: usize = @intCast(@intFromEnum(self)); + const final = self_usize + v; + assert(final < std.math.maxInt(Backing)); + return @enumFromInt(final); + } + }; }; pub const Split = struct { @@ -166,17 +183,17 @@ pub fn SplitTree(comptime V: type) type { }; pub const Iterator = struct { - i: Node.Handle = 0, + i: Node.Handle = .root, nodes: []const Node, pub fn next(self: *Iterator) ?ViewEntry { // If we have no nodes, return null. - if (self.i >= self.nodes.len) return null; + if (@intFromEnum(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]; + self.i = @enumFromInt(handle.idx() + 1); + const node = self.nodes[handle.idx()]; return switch (node) { .leaf => |v| .{ .handle = handle, .view = v }, @@ -188,7 +205,10 @@ pub fn SplitTree(comptime V: type) type { /// Change the zoomed state to the given node. Assumes the handle /// is valid. pub fn zoom(self: *Self, handle: ?Node.Handle) void { - if (handle) |v| assert(v >= 0 and v < self.nodes.len); + if (handle) |v| { + assert(@intFromEnum(v) >= 0); + assert(@intFromEnum(v) < self.nodes.len); + } self.zoomed = handle; } @@ -226,8 +246,8 @@ pub fn SplitTree(comptime V: type) type { 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), + .previous_wrapped => self.previous(from) orelse self.deepest(.right, .root), + .next_wrapped => self.next(from) orelse self.deepest(.left, .root), .spatial => |d| spatial: { // Get our spatial representation. var sp = try self.spatial(alloc); @@ -249,7 +269,7 @@ pub fn SplitTree(comptime V: type) type { ) Node.Handle { var current: Node.Handle = from; while (true) { - switch (self.nodes[current]) { + switch (self.nodes[current.idx()]) { .leaf => return current, .split => |s| current = switch (side) { .left => s.left, @@ -268,7 +288,7 @@ pub fn SplitTree(comptime V: type) type { /// 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)) { + return switch (self.previousBacktrack(from, .root)) { .result => |v| v, .backtrack, .deadend => null, }; @@ -276,7 +296,7 @@ pub fn SplitTree(comptime V: type) type { /// 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)) { + return switch (self.nextBacktrack(from, .root)) { .result => |v| v, .backtrack, .deadend => null, }; @@ -301,7 +321,7 @@ pub fn SplitTree(comptime V: type) type { // value of, then we need to backtrack from here. if (from == current) return .backtrack; - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { // If we hit a leaf that isn't our target, then deadend. .leaf => .deadend, @@ -337,7 +357,7 @@ pub fn SplitTree(comptime V: type) type { current: Node.Handle, ) Backtrack { if (from == current) return .backtrack; - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { .leaf => .deadend, .split => |s| switch (self.nextBacktrack(from, s.right)) { .result => |v| .{ .result = v }, @@ -358,7 +378,7 @@ pub fn SplitTree(comptime V: type) type { from: Node.Handle, direction: Spatial.Direction, ) ?Node.Handle { - const target = sp.slots[from]; + const target = sp.slots[from.idx()]; var result: ?struct { handle: Node.Handle, @@ -366,7 +386,7 @@ pub fn SplitTree(comptime V: type) type { } = null; for (sp.slots, 0..) |slot, handle| { // Never match ourself - if (handle == from) continue; + if (handle == from.idx()) continue; // Only match leaves switch (self.nodes[handle]) { @@ -392,7 +412,7 @@ pub fn SplitTree(comptime V: type) type { if (distance >= n.distance) continue; } result = .{ - .handle = @intCast(handle), + .handle = @enumFromInt(handle), .distance = distance, }; } @@ -417,7 +437,7 @@ pub fn SplitTree(comptime V: type) type { // 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); + const s: *Split = @constCast(&self.nodes[at.idx()].split); s.ratio = ratio; } @@ -445,7 +465,7 @@ pub fn SplitTree(comptime V: type) type { // 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; + if (nodes.len > std.math.maxInt(Node.Handle.Backing)) return error.OutOfMemory; // We can copy our nodes exactly as they are, since they're // mostly not changing (only `at` is changing). @@ -461,8 +481,8 @@ pub fn SplitTree(comptime V: type) type { .leaf => {}, .split => |*s| { // We need to offset the handles in the split - s.left += @intCast(self.nodes.len); - s.right += @intCast(self.nodes.len); + s.left = s.left.offset(self.nodes.len); + s.right = s.right.offset(self.nodes.len); }, }; @@ -476,12 +496,12 @@ pub fn SplitTree(comptime V: type) type { // 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 = .{ + nodes[nodes.len - 1] = nodes[at.idx()]; + nodes[at.idx()] = .{ .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), + .left = @enumFromInt(if (left) self.nodes.len else nodes.len - 1), + .right = @enumFromInt(if (left) nodes.len - 1 else self.nodes.len), } }; // We need to increase the reference count of all the nodes. @@ -501,10 +521,10 @@ pub fn SplitTree(comptime V: type) type { gpa: Allocator, at: Node.Handle, ) Allocator.Error!Self { - assert(at < self.nodes.len); + assert(at.idx() < self.nodes.len); // If we're removing node zero then we're clearing the tree. - if (at == 0) return .empty; + if (at == .root) return .empty; // The new arena for our new tree. var arena = ArenaAllocator.init(gpa); @@ -514,7 +534,7 @@ pub fn SplitTree(comptime V: type) type { // 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, + .root, at, 0, )); @@ -529,9 +549,9 @@ pub fn SplitTree(comptime V: type) type { assert(self.removeNode( &result, 0, - 0, + .root, at, - ) > 0); + ) != 0); // Increase the reference count of all the nodes. try refNodes(gpa, nodes); @@ -542,17 +562,17 @@ pub fn SplitTree(comptime V: type) type { fn removeNode( old: *Self, new: *Self, - new_offset: Node.Handle, + new_offset: usize, current: Node.Handle, target: Node.Handle, - ) Node.Handle { + ) usize { assert(current != target); // If we have a zoomed node and this is it then we migrate it. if (old.zoomed) |v| { if (v == current) { assert(new.zoomed == null); - new.zoomed = new_offset; + new.zoomed = @enumFromInt(new_offset); } } @@ -563,7 +583,7 @@ pub fn SplitTree(comptime V: type) type { // usage its perfectly fine to modify the node in-place. const new_nodes: []Node = @constCast(new.nodes); - switch (old.nodes[current]) { + switch (old.nodes[current.idx()]) { // 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. @@ -596,19 +616,19 @@ pub fn SplitTree(comptime V: type) type { s.left, target, ); - assert(left > 0); + assert(left != 0); const right = old.removeNode( new, - new_offset + 1 + left, + new_offset + left + 1, s.right, target, ); - assert(right > 0); + assert(right != 0); new_nodes[new_offset] = .{ .split = .{ .layout = s.layout, .ratio = s.ratio, - .left = new_offset + 1, - .right = new_offset + 1 + left, + .left = @enumFromInt(new_offset + 1), + .right = @enumFromInt(new_offset + 1 + left), } }; return left + right + 1; @@ -626,7 +646,7 @@ pub fn SplitTree(comptime V: type) type { ) usize { assert(current != target); - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { // Leaf is simple, always takes one node. .leaf => acc + 1, @@ -727,7 +747,7 @@ pub fn SplitTree(comptime V: type) type { layout: Split.Layout, acc: usize, ) usize { - return switch (self.nodes[from]) { + return switch (self.nodes[from.idx()]) { .leaf => acc + 1, .split => |s| if (s.layout == layout) self.weight(s.left, layout, acc) + @@ -776,7 +796,7 @@ pub fn SplitTree(comptime V: type) type { const parent_handle = switch (self.findParentSplit( layout, from, - 0, + .root, )) { .deadend, .backtrack => return result, .result => |v| v, @@ -794,11 +814,11 @@ pub fn SplitTree(comptime V: type) type { // own but I'm trying to avoid that word: its the ratio of // our spatial width/height to the total. const scale = switch (layout) { - .horizontal => sp.slots[parent_handle].width / sp.slots[0].width, - .vertical => sp.slots[parent_handle].height / sp.slots[0].height, + .horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width, + .vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height, }; - const current = result.nodes[parent_handle].split.ratio; + const current = result.nodes[parent_handle.idx()].split.ratio; break :full_ratio current * scale; }; @@ -817,7 +837,7 @@ pub fn SplitTree(comptime V: type) type { current: Node.Handle, ) Backtrack { if (from == current) return .backtrack; - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { .leaf => .deadend, .split => |s| switch (self.findParentSplit( layout, @@ -900,7 +920,7 @@ pub fn SplitTree(comptime V: type) type { if (self.nodes.len == 0) return .empty; // Get our total dimensions. - const dim = self.dimensions(0); + const dim = self.dimensions(.root); // Create our slots which will match our nodes exactly. const slots = try alloc.alloc(Spatial.Slot, self.nodes.len); @@ -911,7 +931,7 @@ pub fn SplitTree(comptime V: type) type { .width = @floatFromInt(dim.width), .height = @floatFromInt(dim.height), }; - self.fillSpatialSlots(slots, 0); + self.fillSpatialSlots(slots, .root); // Normalize the dimensions to 1x1 grid. for (slots) |*slot| { @@ -927,10 +947,10 @@ pub fn SplitTree(comptime V: type) type { fn fillSpatialSlots( self: *const Self, slots: []Spatial.Slot, - current: Node.Handle, + current_: Node.Handle, ) void { + const current = current_.idx(); assert(slots[current].width >= 0 and slots[current].height >= 0); - switch (self.nodes[current]) { // Leaf node, current slot is already filled by caller. .leaf => {}, @@ -938,13 +958,13 @@ pub fn SplitTree(comptime V: type) type { .split => |s| { switch (s.layout) { .horizontal => { - slots[s.left] = .{ + slots[s.left.idx()] = .{ .x = slots[current].x, .y = slots[current].y, .width = slots[current].width * s.ratio, .height = slots[current].height, }; - slots[s.right] = .{ + slots[s.right.idx()] = .{ .x = slots[current].x + slots[current].width * s.ratio, .y = slots[current].y, .width = slots[current].width * (1 - s.ratio), @@ -953,13 +973,13 @@ pub fn SplitTree(comptime V: type) type { }, .vertical => { - slots[s.left] = .{ + slots[s.left.idx()] = .{ .x = slots[current].x, .y = slots[current].y, .width = slots[current].width, .height = slots[current].height * s.ratio, }; - slots[s.right] = .{ + slots[s.right.idx()] = .{ .x = slots[current].x, .y = slots[current].y + slots[current].height * s.ratio, .width = slots[current].width, @@ -982,7 +1002,7 @@ pub fn SplitTree(comptime V: type) type { width: u16, height: u16, } { - return switch (self.nodes[current]) { + return switch (self.nodes[current.idx()]) { .leaf => .{ .width = 1, .height = 1 }, .split => |s| split: { const left = self.dimensions(s.left); @@ -1027,10 +1047,10 @@ pub fn SplitTree(comptime V: type) type { 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); + try self.formatText(writer, .root, 0); } else if (fmt.len == 0) { self.formatDiagram(writer) catch {}; - try self.formatText(writer, 0, 0); + try self.formatText(writer, .root, 0); } else { return error.InvalidFormat; } @@ -1048,7 +1068,7 @@ pub fn SplitTree(comptime V: type) type { try writer.writeAll("(zoomed) "); }; - switch (self.nodes[current]) { + switch (self.nodes[current.idx()]) { .leaf => |v| if (@hasDecl(View, "splitTreeLabel")) try writer.print("leaf: {s}\n", .{v.splitTreeLabel()}) else @@ -1355,7 +1375,7 @@ test "SplitTree: split horizontal" { defer t2.deinit(); var t3 = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1459,7 +1479,7 @@ test "SplitTree: split horizontal" { } else return error.NotFound, ).?; - const entry = t5.nodes[handle].leaf; + const entry = t5.nodes[handle.idx()].leaf; try testing.expectEqualStrings( entry.label, &.{current - 1}, @@ -1489,7 +1509,7 @@ test "SplitTree: split horizontal" { } else return error.NotFound, ).?; - const entry = t5.nodes[handle].leaf; + const entry = t5.nodes[handle.idx()].leaf; try testing.expectEqualStrings( entry.label, &.{current + 1}, @@ -1520,7 +1540,7 @@ test "SplitTree: split vertical" { var t3 = try t1.split( alloc, - 0, // at root + .root, // at root .down, // split down 0.5, &t2, // insert t2 @@ -1554,7 +1574,7 @@ test "SplitTree: split horizontal with zero ratio" { // A | B horizontal var splitAB = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0, &t2, // insert t2 @@ -1588,7 +1608,7 @@ test "SplitTree: split vertical with zero ratio" { // A | B horizontal var splitAB = try t1.split( alloc, - 0, // at root + .root, // at root .down, // split right 0, &t2, // insert t2 @@ -1622,7 +1642,7 @@ test "SplitTree: split horizontal with full width" { // A | B horizontal var splitAB = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 1, &t2, // insert t2 @@ -1656,7 +1676,7 @@ test "SplitTree: split vertical with full width" { // A | B horizontal var splitAB = try t1.split( alloc, - 0, // at root + .root, // at root .down, // split right 1, &t2, // insert t2 @@ -1688,7 +1708,7 @@ test "SplitTree: remove leaf" { defer t2.deinit(); var t3 = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1734,7 +1754,7 @@ test "SplitTree: split twice, remove intermediary" { // A | B horizontal. var split1 = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1744,7 +1764,7 @@ test "SplitTree: split twice, remove intermediary" { // Insert C below that. var split2 = try split1.split( alloc, - 0, // at root + .root, // at root .down, // split down 0.5, &t3, // insert t3 @@ -1795,7 +1815,7 @@ test "SplitTree: split twice, remove intermediary" { // 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)); + var t = try split2.remove(alloc, @enumFromInt(i)); t.deinit(); } } @@ -1820,7 +1840,7 @@ test "SplitTree: spatial goto" { // A | B horizontal var splitAB = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -1896,7 +1916,7 @@ test "SplitTree: spatial goto" { }, .{ .spatial = .right }, )).?; - const view = split.nodes[target].leaf; + const view = split.nodes[target.idx()].leaf; try testing.expectEqualStrings(view.label, "D"); } @@ -1914,7 +1934,7 @@ test "SplitTree: spatial goto" { }, .{ .spatial = .left }, )).?; - const view = split.nodes[target].leaf; + const view = split.nodes[target.idx()].leaf; try testing.expectEqualStrings("A", view.label); } @@ -1951,7 +1971,7 @@ test "SplitTree: resize" { // A | B horizontal var split = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -2028,7 +2048,7 @@ test "SplitTree: zoom" { // A | B horizontal var split = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -2094,7 +2114,7 @@ test "SplitTree: split resets zoom" { // A | B horizontal var split = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 @@ -2127,7 +2147,7 @@ test "SplitTree: remove and zoom" { // A | B horizontal var split = try t1.split( alloc, - 0, // at root + .root, // at root .right, // split right 0.5, &t2, // insert t2 From f130a724e52dd17e0ad6d1cbfa01d44999d6e2ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 13:39:34 -0700 Subject: [PATCH 07/53] apprt/gtk-ng: track is-zoomed property on surface tree --- src/apprt/gtk-ng/class/split_tree.zig | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 87eadf9c8..3ee36f646 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -79,6 +79,25 @@ pub const SplitTree = extern struct { ); }; + pub const @"is-zoomed" = struct { + pub const name = "is-zoomed"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ + .getter = getIsZoomed, + }, + ), + }, + ); + }; + pub const tree = struct { pub const name = "tree"; const impl = gobject.ext.defineProperty( @@ -430,6 +449,11 @@ pub const SplitTree = extern struct { return !tree.isEmpty(); } + pub fn getIsZoomed(self: *Self) bool { + const tree: *const Surface.Tree = self.private().tree orelse &.empty; + return tree.zoomed != null; + } + /// 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 { @@ -799,6 +823,7 @@ pub const SplitTree = extern struct { // Dependent properties self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec); + self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec); } fn onRebuild(ud: ?*anyopaque) callconv(.c) c_int { From aa4cbf444b2f4ba822c8e947a2ea30b1cf330071 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 13:45:31 -0700 Subject: [PATCH 08/53] apprt/gtk-ng: forgot to register a prop --- src/apprt/gtk-ng/class/split_tree.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 3ee36f646..10815bb0a 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -909,6 +909,7 @@ pub const SplitTree = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.@"has-surfaces".impl, + properties.@"is-zoomed".impl, properties.tree.impl, }); From 502040c86a0d407db33fbac90c632bea12001e77 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 15:02:07 -0700 Subject: [PATCH 09/53] apprt/gtk-ng: tab tooltips match our pwd --- src/apprt/gtk-ng/class/tab.zig | 19 +++++++++++++++++++ src/apprt/gtk-ng/class/window.zig | 6 ++++++ src/apprt/gtk-ng/ui/1.5/tab.blp | 1 + 3 files changed, 26 insertions(+) diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 428ce72d6..cbb646b14 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -88,6 +88,19 @@ pub const Tab = extern struct { ); }; + pub const tooltip = struct { + pub const name = "tooltip"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("tooltip"), + }, + ); + }; + pub const title = struct { pub const name = "title"; pub const get = impl.get; @@ -125,6 +138,7 @@ pub const Tab = extern struct { /// The title to show for this tab. This is usually set to a binding /// with the active surface but can be manually set to anything. title: ?[:0]const u8 = null, + tooltip: ?[:0]const u8 = null, /// The binding groups for the current active surface. surface_bindings: *gobject.BindingGroup, @@ -228,6 +242,10 @@ pub const Tab = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); + if (priv.tooltip) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.tooltip = null; + } if (priv.title) |v| { glib.free(@constCast(@ptrCast(v))); priv.title = null; @@ -305,6 +323,7 @@ pub const Tab = extern struct { properties.config.impl, properties.@"surface-tree".impl, properties.title.impl, + properties.tooltip.impl, }); // Bindings diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index eb41b61d0..3c818068b 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -416,6 +416,12 @@ pub const Window = extern struct { "title", .{ .sync_create = true }, ); + _ = tab.as(gobject.Object).bindProperty( + "tooltip", + page.as(gobject.Object), + "tooltip", + .{ .sync_create = true }, + ); // Bind signals const split_tree = tab.getSplitTree(); diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 4cb47487d..32938c7c2 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,6 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; + tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd as ; $GhosttySplitTree split_tree { notify::active-surface => $notify_active_surface(); From 12bc0d7b107ba35708d343c427b0826462ee2d9a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 15:11:23 -0700 Subject: [PATCH 10/53] apprt/gtk-ng: window-subtitle --- src/apprt/gtk-ng/class/window.zig | 18 ++++++++++++++++++ src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- src/apprt/gtk-ng/ui/1.5/window.blp | 7 +++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 3c818068b..fbee07801 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1066,6 +1066,21 @@ pub const Window = extern struct { }); } + fn closureSubtitle( + _: *Self, + config_: ?*Config, + pwd_: ?[*:0]const u8, + ) callconv(.c) ?[*:0]const u8 { + const config = if (config_) |v| v.get() else return null; + return switch (config.@"window-subtitle") { + .false => null, + .@"working-directory" => pwd: { + const pwd = pwd_ orelse return null; + break :pwd glib.ext.dupeZ(u8, std.mem.span(pwd)); + }, + }; + } + //--------------------------------------------------------------- // Virtual methods @@ -1789,6 +1804,8 @@ pub const Window = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(DebugWarning); + gobject.ext.ensureType(Surface); + gobject.ext.ensureType(Tab); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), comptime gresource.blueprint(.{ @@ -1838,6 +1855,7 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); + class.bindTemplateCallback("computed_subtitle", &closureSubtitle); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 32938c7c2..5c07209e8 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,7 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd as ; + tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { notify::active-surface => $notify_active_surface(); diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk-ng/ui/1.5/window.blp index 4ca90dfb5..b09c0d9b3 100644 --- a/src/apprt/gtk-ng/ui/1.5/window.blp +++ b/src/apprt/gtk-ng/ui/1.5/window.blp @@ -40,6 +40,13 @@ template $GhosttyWindow: Adw.ApplicationWindow { title-widget: Adw.WindowTitle { title: bind template.title; + // Blueprint auto-formatter won't let me split this into multiple + // lines. Let me explain myself. All parameters to a closure are used + // as notifications to recompute the value of the closure. All + // elements of a property chain are also subscribed to for changes. + // This one long, ugly line saves us from manually building up this + // massive notify chain in code. + subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; }; [start] From 798e872f482176eb149d5b78bdae6fcca5ac97fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 12 Aug 2025 15:37:53 -0700 Subject: [PATCH 11/53] apprt/gtk-ng: split zoom title --- src/apprt/gtk-ng/class/tab.zig | 74 +++++++++++++++++++++---------- src/apprt/gtk-ng/class/window.zig | 1 + src/apprt/gtk-ng/ui/1.5/tab.blp | 1 + 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index cbb646b14..247a0351c 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -70,6 +70,24 @@ pub const Tab = extern struct { ); }; + pub const @"split-tree" = struct { + pub const name = "split-tree"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*SplitTree, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*SplitTree, + .{ + .getter = getSplitTree, + }, + ), + }, + ); + }; + pub const @"surface-tree" = struct { pub const name = "surface-tree"; const impl = gobject.ext.defineProperty( @@ -103,8 +121,6 @@ pub const Tab = extern struct { pub const title = struct { pub const name = "title"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -135,13 +151,11 @@ pub const Tab = extern struct { /// The configuration that this surface is using. config: ?*Config = null, - /// The title to show for this tab. This is usually set to a binding - /// with the active surface but can be manually set to anything. + /// The title of this tab. This is usually bound to the active surface. title: ?[:0]const u8 = null, - tooltip: ?[:0]const u8 = null, - /// The binding groups for the current active surface. - surface_bindings: *gobject.BindingGroup, + /// The tooltip of this tab. This is usually bound to the active surface. + tooltip: ?[:0]const u8 = null, // Template bindings split_tree: *SplitTree, @@ -169,15 +183,6 @@ pub const Tab = extern struct { priv.config = app.getConfig(); } - // Setup binding groups for surface properties - priv.surface_bindings = gobject.BindingGroup.new(); - priv.surface_bindings.bind( - "title", - self.as(gobject.Object), - "title", - .{}, - ); - // Create our initial surface in the split tree. priv.split_tree.newSplit(.right, null) catch |err| switch (err) { error.OutOfMemory => { @@ -227,7 +232,6 @@ pub const Tab = extern struct { v.unref(); priv.config = null; } - priv.surface_bindings.setSource(null); gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -250,7 +254,6 @@ pub const Tab = extern struct { glib.free(@constCast(@ptrCast(v))); priv.title = null; } - priv.surface_bindings.unref(); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -285,13 +288,36 @@ pub const Tab = extern struct { _: *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); + } + + fn closureComputedTitle( + _: *Self, + plain_: ?[*:0]const u8, + zoomed_: c_int, + ) callconv(.c) ?[*:0]const u8 { + const zoomed = zoomed_ != 0; + const plain = plain: { + const default = "Ghostty"; + const plain = plain_ orelse break :plain default; + break :plain std.mem.span(plain); + }; + + // If we're zoomed, prefix with the magnifying glass emoji. + if (zoomed) zoomed: { + // This results in an extra allocation (that we free), but I + // prefer using the Zig APIs so much more than the libc ones. + const alloc = Application.default().allocator(); + const slice = std.fmt.allocPrint( + alloc, + "🔍 {s}", + .{plain}, + ) catch break :zoomed; + defer alloc.free(slice); + return glib.ext.dupeZ(u8, slice); } - self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); + return glib.ext.dupeZ(u8, plain); } const C = Common(Self, Private); @@ -321,6 +347,7 @@ pub const Tab = extern struct { gobject.ext.registerProperties(class, &.{ properties.@"active-surface".impl, properties.config.impl, + properties.@"split-tree".impl, properties.@"surface-tree".impl, properties.title.impl, properties.tooltip.impl, @@ -330,6 +357,7 @@ pub const Tab = extern struct { class.bindTemplateChildPrivate("split_tree", .{}); // Template Callbacks + class.bindTemplateCallback("computed_title", &closureComputedTitle); class.bindTemplateCallback("notify_active_surface", &propActiveSurface); class.bindTemplateCallback("notify_tree", &propSplitTree); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index fbee07801..447fb0a40 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1804,6 +1804,7 @@ pub const Window = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(DebugWarning); + gobject.ext.ensureType(SplitTree); gobject.ext.ensureType(Surface); gobject.ext.ensureType(Tab); gtk.Widget.Class.setTemplateFromResource( diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 5c07209e8..6431bb5c9 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,6 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; + title: bind $computed_title(split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { From 40427b06c79b1520e6e0b6838124dfbd888da264 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 08:32:52 -0700 Subject: [PATCH 12/53] apprt/gtk-ng: surface bell-ringing property Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk-ng/class.zig | 17 +++++++++++ src/apprt/gtk-ng/class/surface.zig | 47 ++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 82762b542..68879d19c 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -53,6 +53,23 @@ pub fn Common( } }).private else {}; + /// A helper that creates a property that reads and writes a + /// private field with only shallow copies. This is good for primitives + /// such as bools, numbers, etc. + pub fn privateShallowFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return gobject.ext.privateFieldAccessor( + Self, + Private.?, + &Private.?.offset, + name, + ); + } + /// A helper that can be used to create a property that reads and /// writes a private boxed gobject field type. /// diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 8487b24b0..cb0aae76b 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -47,6 +47,19 @@ pub const Surface = extern struct { pub const Tree = datastruct.SplitTree(Self); pub const properties = struct { + pub const @"bell-ringing" = struct { + pub const name = "bell-ringing"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("bell_ringing"), + }, + ); + }; + pub const config = struct { pub const name = "config"; const impl = gobject.ext.defineProperty( @@ -456,6 +469,11 @@ pub const Surface = extern struct { // Progress bar progress_bar_timer: ?c_uint = null, + // True while the bell is ringing. This will be set to false (after + // true) under various scenarios, but can also manually be set to + // false by a parent widget. + bell_ringing: bool = false, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -522,7 +540,11 @@ pub const Surface = extern struct { /// Ring the bell. pub fn ringBell(self: *Self) void { - // TODO: Audio feature + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + + // Enable our bell ringing state + self.setBellRinging(true); signals.bell.impl.emit( self, @@ -691,11 +713,14 @@ pub const Surface = extern struct { keycode: c_uint, gtk_mods: gdk.ModifierType, ) bool { - log.warn("keyEvent action={}", .{action}); + //log.warn("keyEvent action={}", .{action}); const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; const priv = self.private(); + // Bell stops ringing under any key event (press or release). + self.setBellRinging(false); + // The block below is all related to input method handling. See the function // comment for some high level details and then the comments within // the block for more specifics. @@ -1383,6 +1408,17 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec); } + pub fn getBellRinging(self: *Self) bool { + return self.private().bell_ringing; + } + + pub fn setBellRinging(self: *Self, ringing: bool) void { + const priv = self.private(); + if (priv.bell_ringing == ringing) return; + priv.bell_ringing = ringing; + self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -1668,6 +1704,9 @@ pub const Surface = extern struct { priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); + + // Bell stops ringing as soon as we gain focus + self.setBellRinging(false); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1704,6 +1743,9 @@ pub const Surface = extern struct { ) callconv(.c) void { const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + // Bell stops ringing if any mouse button is pressed. + self.setBellRinging(false); + // If we don't have focus, grab it. const priv = self.private(); const gl_area_widget = priv.gl_area.as(gtk.Widget); @@ -2381,6 +2423,7 @@ pub const Surface = extern struct { // Properties gobject.ext.registerProperties(class, &.{ + properties.@"bell-ringing".impl, properties.config.impl, properties.@"child-exited".impl, properties.@"default-size".impl, From 408ec24165d2c32cdbbcc2c399cc90fd74b851e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 08:35:41 -0700 Subject: [PATCH 13/53] apprt/gtk-ng: hook up bell into title Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk-ng/class/surface.zig | 7 ++--- src/apprt/gtk-ng/class/tab.zig | 42 +++++++++++++++++++++--------- src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index cb0aae76b..93afaf776 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -718,9 +718,6 @@ pub const Surface = extern struct { const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; const priv = self.private(); - // Bell stops ringing under any key event (press or release). - self.setBellRinging(false); - // The block below is all related to input method handling. See the function // comment for some high level details and then the comments within // the block for more specifics. @@ -906,6 +903,10 @@ pub const Surface = extern struct { surface.preeditCallback(null) catch {}; } + // Bell stops ringing when any key is pressed that is used by + // the core in any way. + self.setBellRinging(false); + return true; }, } diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 247a0351c..23916b2b1 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -293,31 +293,47 @@ pub const Tab = extern struct { fn closureComputedTitle( _: *Self, + config_: ?*Config, plain_: ?[*:0]const u8, zoomed_: c_int, + bell_ringing_: c_int, + _: *gobject.ParamSpec, ) callconv(.c) ?[*:0]const u8 { const zoomed = zoomed_ != 0; + const bell_ringing = bell_ringing_ != 0; + const plain = plain: { const default = "Ghostty"; const plain = plain_ orelse break :plain default; break :plain std.mem.span(plain); }; - // If we're zoomed, prefix with the magnifying glass emoji. - if (zoomed) zoomed: { - // This results in an extra allocation (that we free), but I - // prefer using the Zig APIs so much more than the libc ones. - const alloc = Application.default().allocator(); - const slice = std.fmt.allocPrint( - alloc, - "🔍 {s}", - .{plain}, - ) catch break :zoomed; - defer alloc.free(slice); - return glib.ext.dupeZ(u8, slice); + // We don't need a config in every case, but if we don't have a config + // let's just assume something went terribly wrong and use our + // default title. Its easier then guarding on the config existing + // in every case for something so unlikely. + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computed title, likely bug", .{}); + return glib.ext.dupeZ(u8, plain); + }; + + // Use an allocator to build up our string as we write it. + var buf: std.ArrayList(u8) = .init(Application.default().allocator()); + defer buf.deinit(); + const writer = buf.writer(); + + // If our bell is ringing, then we prefix the bell icon to the title. + if (bell_ringing and config.@"bell-features".title) { + writer.writeAll("🔔 ") catch {}; } - return glib.ext.dupeZ(u8, plain); + // If we're zoomed, prefix with the magnifying glass emoji. + if (zoomed) { + writer.writeAll("🔍 ") catch {}; + } + + writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain); + return glib.ext.dupeZ(u8, buf.items); } const C = Common(Self, Private); diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 6431bb5c9..4b92e38f2 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,7 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed) as ; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { From d37e3828a2c65ec77a1c9ef2d4e91cd6e92f2556 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 08:53:13 -0700 Subject: [PATCH 14/53] apprt/gtk-ng: win.ring-bell Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk-ng/class/surface.zig | 6 ++++++ src/apprt/gtk-ng/class/window.zig | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 93afaf776..962b01b18 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -274,6 +274,8 @@ pub const Surface = extern struct { /// /// The surface view handles the audio bell feature but none of the /// others so it is up to the embedding widget to react to this. + /// + /// Bell ringing will also emit the win.ring-bell action. pub const bell = struct { pub const name = "bell"; pub const connect = impl.connect; @@ -546,12 +548,16 @@ pub const Surface = extern struct { // Enable our bell ringing state self.setBellRinging(true); + // Emit our direct signal for anyone who cares signals.bell.impl.emit( self, null, .{}, null, ); + + // Activate a window action if it exists + _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); } pub fn toggleFullscreen(self: *Self) void { diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 447fb0a40..a480ed217 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -336,6 +336,7 @@ pub const Window = extern struct { .{ "close-tab", actionCloseTab, null }, .{ "new-tab", actionNewTab, null }, .{ "new-window", actionNewWindow, null }, + .{ "ring-bell", actionRingBell, null }, .{ "split-right", actionSplitRight, null }, .{ "split-left", actionSplitLeft, null }, .{ "split-up", actionSplitUp, null }, @@ -1317,6 +1318,10 @@ pub const Window = extern struct { // Setup our binding group. This ensures things like the title // are synced from the active tab. priv.tab_bindings.setSource(child.as(gobject.Object)); + + // If the tab was previously marked as needing attention + // (e.g. due to a bell character), we now unmark that + page.setNeedsAttention(@intFromBool(false)); } fn tabViewPageAttached( @@ -1729,6 +1734,30 @@ pub const Window = extern struct { self.performBindingAction(.clear_screen); } + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return; + + if (config.@"bell-features".system) system: { + const native = self.as(gtk.Native).getSurface() orelse { + log.warn("unable to get native surface from window", .{}); + break :system; + }; + native.beep(); + } + + if (config.@"bell-features".attention) { + // Request user attention + self.winproto().setUrgent(true) catch |err| { + log.warn("failed to request user attention={}", .{err}); + }; + } + } + /// Toggle the command palette. /// /// TODO: accept the surface that toggled the command palette as a parameter From 3680c8637edfc5929841722d5d283747b4fbd9a6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 08:55:24 -0700 Subject: [PATCH 15/53] apprt/gtk-ng: tab attention for bell Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk-ng/class/surface.zig | 4 +- src/apprt/gtk-ng/class/tab.zig | 70 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 962b01b18..00b5ab7dd 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -275,7 +275,8 @@ pub const Surface = extern struct { /// The surface view handles the audio bell feature but none of the /// others so it is up to the embedding widget to react to this. /// - /// Bell ringing will also emit the win.ring-bell action. + /// Bell ringing will also emit the tab.ring-bell and win.ring-bell + /// actions. pub const bell = struct { pub const name = "bell"; pub const connect = impl.connect; @@ -557,6 +558,7 @@ pub const Surface = extern struct { ); // Activate a window action if it exists + _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); } diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 23916b2b1..9a65cd2d7 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -11,6 +11,7 @@ const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); const gtk_version = @import("../gtk_version.zig"); const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); @@ -175,6 +176,9 @@ pub const Tab = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + // Init our actions + self.initActions(); + // If our configuration is null then we get the configuration // from the application. const priv = self.private(); @@ -194,6 +198,46 @@ pub const Tab = extern struct { }; } + /// Setup our action map. + 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 = .{ + .{ "ring-bell", actionRingBell, 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( + "tab", + group.as(gio.ActionGroup), + ); + } + //--------------------------------------------------------------- // Properties @@ -223,6 +267,15 @@ pub const Tab = extern struct { return core_surface.needsConfirmQuit(); } + /// Get the tab page holding this tab, if any. + fn getTabPage(self: *Self) ?*adw.TabPage { + const tab_view = ext.getAncestor( + adw.TabView, + self.as(gtk.Widget), + ) orelse return null; + return tab_view.getPage(self.as(gtk.Widget)); + } + //--------------------------------------------------------------- // Virtual methods @@ -291,6 +344,23 @@ pub const Tab = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + // Future note: I actually don't like this logic living here at all. + // I think a better approach will be for the ring bell action to + // specify its sending surface and then do all this in the window. + + // If the page is selected already we don't mark it as needing + // attention. We only want to mark unfocused pages. This will then + // clear when the page is selected. + const page = self.getTabPage() orelse return; + if (page.getSelected() != 0) return; + page.setNeedsAttention(@intFromBool(true)); + } + fn closureComputedTitle( _: *Self, config_: ?*Config, From d8a309c734dcf001d172804fa8d707e00479edd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 09:06:03 -0700 Subject: [PATCH 16/53] apprt/gtk-ng: split tree active focus should be last focused fallback Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk-ng/class/split_tree.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 10815bb0a..1da0896a2 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -418,6 +418,20 @@ pub const SplitTree = extern struct { if (entry.view.getFocused()) return entry.handle; } + // If none are currently focused, the most previously focused + // surface (if it exists) is our active surface. This lets things + // like apprt actions and bell ringing continue to work in the + // background. + if (self.private().last_focused.get()) |v| { + defer v.unref(); + + // We need to find the handle of the last focused surface. + it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == v) return entry.handle; + } + } + return null; } From 6de98eda04e562ca948d9dcb43b7157af3b059b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 09:15:23 -0700 Subject: [PATCH 17/53] apprt/gtk-ng: audio bell Co-authored-by: Jeffrey C. Ollie --- src/apprt/gtk-ng/class/application.zig | 2 +- src/apprt/gtk-ng/class/surface.zig | 128 +++++++++++++++++-------- src/apprt/gtk-ng/ui/1.2/surface.blp | 1 + 3 files changed, 90 insertions(+), 41 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ed5eb9ff0..28d1e6a22 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -2043,7 +2043,7 @@ const Action = struct { pub fn ringBell(target: apprt.Target) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.surface.ringBell(), + .surface => |v| v.rt_surface.surface.setBellRinging(true), } } diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 00b5ab7dd..631b93e42 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -270,24 +270,6 @@ pub const Surface = extern struct { ); }; - /// The bell is rung. - /// - /// The surface view handles the audio bell feature but none of the - /// others so it is up to the embedding widget to react to this. - /// - /// Bell ringing will also emit the tab.ring-bell and win.ring-bell - /// actions. - pub const bell = struct { - pub const name = "bell"; - pub const connect = impl.connect; - const impl = gobject.ext.defineSignal( - name, - Self, - &.{}, - void, - ); - }; - /// Emitted whenever the clipboard has been written. pub const @"clipboard-write" = struct { pub const name = "clipboard-write"; @@ -541,27 +523,6 @@ pub const Surface = extern struct { priv.gl_area.queueRender(); } - /// Ring the bell. - pub fn ringBell(self: *Self) void { - self.as(gobject.Object).freezeNotify(); - defer self.as(gobject.Object).thawNotify(); - - // Enable our bell ringing state - self.setBellRinging(true); - - // Emit our direct signal for anyone who cares - signals.bell.impl.emit( - self, - null, - .{}, - null, - ); - - // Activate a window action if it exists - _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); - _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); - } - pub fn toggleFullscreen(self: *Self) void { signals.@"toggle-fullscreen".impl.emit( self, @@ -1560,6 +1521,64 @@ pub const Surface = extern struct { priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr); } + fn propBellRinging( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + if (!priv.bell_ringing) return; + + // Activate actions if they exist + _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); + _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); + + // Do our sound + const config = if (priv.config) |c| c.get() else return; + if (config.@"bell-features".audio) audio: { + const config_path = config.@"bell-audio-path" orelse break :audio; + const path, const required = switch (config_path) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp( + config.@"bell-audio-volume", + 0.0, + 1.0, + ); + + assert(std.fs.path.isAbsolute(path)); + const media_file = gtk.MediaFile.newForFilename(path); + + // If the audio file is marked as required, we'll emit an error if + // there was a problem playing it. Otherwise there will be silence. + if (required) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileError, + null, + .{ .detail = "error" }, + ); + } + + // Watch for the "ended" signal so that we can clean up after + // ourselves. + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); + } + } + //--------------------------------------------------------------- // Signal Handlers @@ -2365,6 +2384,35 @@ pub const Surface = extern struct { right.setVisible(0); } + fn mediaFileError( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + log.warn("error playing bell from {s}: {s} {d} {s}", .{ + path orelse "<>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); + } + + fn mediaFileEnded( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + media_file.unref(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -2429,6 +2477,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); + class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); // Properties gobject.ext.registerProperties(class, &.{ @@ -2449,7 +2498,6 @@ pub const Surface = extern struct { // Signals signals.@"close-request".impl.register(.{}); - signals.bell.impl.register(.{}); signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-write".impl.register(.{}); signals.init.impl.register(.{}); diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 23499c7f3..49aae0a04 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -6,6 +6,7 @@ template $GhosttySurface: Adw.Bin { "surface", ] + notify::bell-ringing => $notify_bell_ringing(); notify::config => $notify_config(); notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); From 22fc90fd55c0fa4656a1dc10e4ddc6fb736eeb0c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 12:18:07 -0500 Subject: [PATCH 18/53] gtk-ng add border to bell features --- src/apprt/gtk-ng/class/surface.zig | 17 +++++++++++++++++ src/apprt/gtk-ng/css/style.css | 6 ++++++ src/apprt/gtk-ng/ui/1.2/surface.blp | 21 +++++++++++++++++++++ src/config/Config.zig | 8 +++++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 631b93e42..701497d17 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -523,6 +523,22 @@ pub const Surface = extern struct { priv.gl_area.queueRender(); } + /// Callback used to determine whether border should be shown around the + /// surface. + fn closureShouldBorderBeShown( + _: *Self, + config_: ?*Config, + bell_ringing_: c_int, + ) callconv(.c) c_int { + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computing whether border should be shown , likely bug", .{}); + return @intFromBool(false); + }; + + const bell_ringing = bell_ringing_ != 0; + return @intFromBool(config.@"bell-features".border and bell_ringing); + } + pub fn toggleFullscreen(self: *Self) void { signals.@"toggle-fullscreen".impl.emit( self, @@ -2478,6 +2494,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); + class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk-ng/css/style.css index a1a425f66..5901d1d7e 100644 --- a/src/apprt/gtk-ng/css/style.css +++ b/src/apprt/gtk-ng/css/style.css @@ -102,6 +102,12 @@ label.resize-overlay { /* background-color: color-mix(in srgb, var(--error-bg-color), transparent); */ } +.surface .bell-overlay { + border-color: color-mix(in srgb, var(--accent-color), transparent 50%); + border-width: 3px; + border-style: solid; +} + /* * Command Palette */ diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 49aae0a04..b558fc322 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -54,6 +54,27 @@ template $GhosttySurface: Adw.Bin { valign: start; } + [overlay] + // The "border" bell feature is implemented here as an overlay rather than + // just adding a border to the GLArea or other widget for two reasons. + // First, adding a border to an existing widget causes a resize of the + // widget which undesirable side effects. Second, we can make it reactive + // here in the blueprint with relatively little code. + Revealer { + reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; + transition-type: crossfade; + transition-duration: 500; + + Box bell_overlay { + styles [ + "bell-overlay", + ] + + halign: fill; + valign: fill; + } + } + [overlay] $GhosttySurfaceChildExited child_exited_overlay { visible: bind template.child-exited; diff --git a/src/config/Config.zig b/src/config/Config.zig index 2cf5a3e17..2f6643c7d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2433,7 +2433,12 @@ keybind: Keybinds = .{}, /// Prepend a bell emoji (🔔) to the title of the alerted surface until the /// terminal is re-focused or interacted with (such as on keyboard input). /// -/// Only implemented on macOS. +/// * `border` +/// +/// Display a border around the alerted surface until the terminal is +/// re-focused or interacted with (such as on keyboard input). +/// +/// GTK only. /// /// Example: `audio`, `no-audio`, `system`, `no-system` /// @@ -6988,6 +6993,7 @@ pub const BellFeatures = packed struct { audio: bool = false, attention: bool = true, title: bool = true, + border: bool = false, }; /// See mouse-shift-capture From 8edc041eafa6eb5a2302eddfc9ef187669f07cfa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 10:43:36 -0700 Subject: [PATCH 19/53] apprt/gtk-ng: prompt surface title --- src/apprt/gtk-ng/build/gresource.zig | 1 + src/apprt/gtk-ng/class/application.zig | 13 +- src/apprt/gtk-ng/class/surface.zig | 124 +++++++++++- .../gtk-ng/class/surface_title_dialog.zig | 190 ++++++++++++++++++ src/apprt/gtk-ng/class/tab.zig | 9 +- src/apprt/gtk-ng/ui/1.2/surface.blp | 2 +- .../gtk-ng/ui/1.5/surface-title-dialog.blp | 16 ++ src/apprt/gtk-ng/ui/1.5/tab.blp | 2 +- 8 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 src/apprt/gtk-ng/class/surface_title_dialog.zig create mode 100644 src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 0818a98f6..f606435c6 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, + .{ .major = 1, .minor = 5, .name = "surface-title-dialog" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 28d1e6a22..becfac14a 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -589,6 +589,8 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), + .prompt_title => return Action.promptTitle(target), + .quit => self.quit(), .quit_timer => try Action.quitTimer(self, value), @@ -618,7 +620,6 @@ pub const Application = extern struct { .toggle_split_zoom => return Action.toggleSplitZoom(target), // Unimplemented but todo on gtk-ng branch - .prompt_title, .inspector, => { log.warn("unimplemented action={}", .{action}); @@ -1955,6 +1956,16 @@ const Action = struct { }; } + pub fn promptTitle(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + } + } + /// Reload the configuration for the application and propagate it /// across the entire application and all terminals. pub fn reloadConfig( diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 631b93e42..adf72c5ce 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -27,6 +27,7 @@ const Config = @import("config.zig").Config; const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; +const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; const Window = @import("window.zig").Window; const log = std.log.scoped(.gtk_ghostty_surface); @@ -186,8 +187,6 @@ pub const Surface = extern struct { pub const @"mouse-hover-url" = struct { pub const name = "mouse-hover-url"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -201,8 +200,6 @@ pub const Surface = extern struct { pub const pwd = struct { pub const name = "pwd"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -216,8 +213,6 @@ pub const Surface = extern struct { pub const title = struct { pub const name = "title"; - pub const get = impl.get; - pub const set = impl.set; const impl = gobject.ext.defineProperty( name, Self, @@ -229,6 +224,19 @@ pub const Surface = extern struct { ); }; + pub const @"title-override" = struct { + pub const name = "title-override"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title_override"), + }, + ); + }; + pub const zoom = struct { pub const name = "zoom"; const impl = gobject.ext.defineProperty( @@ -401,6 +409,9 @@ pub const Surface = extern struct { /// The title of this surface, if any has been set. title: ?[:0]const u8 = null, + /// The manually overridden title of this surface from `promptTitle`. + title_override: ?[:0]const u8 = null, + /// The current focus state of the terminal based on the /// focus events. focused: bool = true, @@ -883,6 +894,26 @@ pub const Surface = extern struct { return false; } + /// Prompt for a manual title change for the surface. + pub fn promptTitle(self: *Self) void { + const priv = self.private(); + const dialog = gobject.ext.newInstance( + TitleDialog, + .{ + .@"initial-value" = priv.title_override orelse priv.title, + }, + ); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + /// Scale x/y by the GDK device scale. fn scaledCoordinates( self: *Self, @@ -1145,6 +1176,9 @@ pub const Surface = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + // Initialize our actions + self.initActions(); + const priv = self.private(); // Initialize some private fields so they aren't undefined @@ -1191,6 +1225,45 @@ pub const Surface = extern struct { self.propConfig(undefined, 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 = .{ + .{ "prompt-title", actionPromptTitle, 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( + "surface", + group.as(gio.ActionGroup), + ); + } + fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); if (priv.config) |v| { @@ -1254,6 +1327,10 @@ pub const Surface = extern struct { glib.free(@constCast(@ptrCast(v))); priv.title = null; } + if (priv.title_override) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.title_override = null; + } self.clearCgroup(); gobject.Object.virtual_methods.finalize.call( @@ -1270,7 +1347,9 @@ pub const Surface = extern struct { return self.private().title; } - /// Set the title for this surface, copies the value. + /// Set the title for this surface, copies the value. This should always + /// be the title as set by the terminal program, not any manually set + /// title. For manually set titles see `setTitleOverride`. pub fn setTitle(self: *Self, title: ?[:0]const u8) void { const priv = self.private(); if (priv.title) |v| glib.free(@constCast(@ptrCast(v))); @@ -1279,6 +1358,16 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.title.impl.param_spec); } + /// Overridden title. This will be generally be shown over the title + /// unless this is unset (null). + pub fn setTitleOverride(self: *Self, title: ?[:0]const u8) void { + const priv = self.private(); + if (priv.title_override) |v| glib.free(@constCast(@ptrCast(v))); + priv.title_override = null; + if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v); + self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec); + } + /// Returns the pwd property without a copy. pub fn getPwd(self: *Self) ?[:0]const u8 { return self.private().pwd; @@ -1582,6 +1671,17 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Signal Handlers + pub fn actionPromptTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.core() orelse return; + _ = surface.performBindingAction(.prompt_surface_title) catch |err| { + log.warn("unable to perform prompt title action err={}", .{err}); + }; + } + fn childExitedClose( _: *ChildExited, self: *Self, @@ -2413,6 +2513,15 @@ pub const Surface = extern struct { media_file.unref(); } + fn titleDialogSet( + _: *TitleDialog, + title_ptr: [*:0]const u8, + self: *Self, + ) callconv(.c) void { + const title = std.mem.span(title_ptr); + self.setTitleOverride(if (title.len == 0) null else title); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -2493,6 +2602,7 @@ pub const Surface = extern struct { properties.@"mouse-hover-url".impl, properties.pwd.impl, properties.title.impl, + properties.@"title-override".impl, properties.zoom.impl, }); diff --git a/src/apprt/gtk-ng/class/surface_title_dialog.zig b/src/apprt/gtk-ng/class/surface_title_dialog.zig new file mode 100644 index 000000000..de36f3090 --- /dev/null +++ b/src/apprt/gtk-ng/class/surface_title_dialog.zig @@ -0,0 +1,190 @@ +const std = @import("std"); +const adw = @import("adw"); +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const adw_version = @import("../adw_version.zig"); +const ext = @import("../ext.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_surface_title_dialog); + +pub const SurfaceTitleDialog = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.AlertDialog; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttySurfaceTitleDialog", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const @"initial-value" = struct { + pub const name = "initial-value"; + pub const get = impl.get; + pub const set = impl.set; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("initial_value"), + }, + ); + }; + }; + + pub const signals = struct { + /// Set the title to the given value. + pub const set = struct { + pub const name = "set"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{[*:0]const u8}, + void, + ); + }; + }; + + const Private = struct { + /// The initial value of the entry field. + initial_value: ?[:0]const u8 = null, + + // Template bindings + entry: *gtk.Entry, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + pub fn present(self: *Self, parent_: *gtk.Widget) void { + // If we have a window we can attach to, we prefer that. + const parent: *gtk.Widget = if (ext.getAncestor( + adw.ApplicationWindow, + parent_, + )) |window| + window.as(gtk.Widget) + else if (ext.getAncestor( + adw.Window, + parent_, + )) |window| + window.as(gtk.Widget) + else + parent_; + + // Set our initial value + const priv = self.private(); + if (priv.initial_value) |v| { + priv.entry.getBuffer().setText(v, -1); + } + + // Show it. We could also just use virtual methods to bind to + // response but this is pretty simple. + self.as(adw.AlertDialog).choose( + parent, + null, + alertDialogReady, + self, + ); + } + + fn alertDialogReady( + _: ?*gobject.Object, + result: *gio.AsyncResult, + ud: ?*anyopaque, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud)); + const response = self.as(adw.AlertDialog).chooseFinish(result); + + // If we didn't hit "okay" then we do nothing. + if (std.mem.orderZ(u8, "ok", response) != .eq) return; + + // Emit our signal with the new title. + const title = std.mem.span(self.private().entry.getBuffer().getText()); + signals.set.impl.emit( + self, + null, + .{title.ptr}, + null, + ); + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + if (priv.initial_value) |v| { + glib.free(@constCast(@ptrCast(v))); + priv.initial_value = 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; + 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 = "surface-title-dialog", + }), + ); + + // Signals + signals.set.impl.register(.{}); + + // Bindings + class.bindTemplateChildPrivate("entry", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.@"initial-value".impl, + }); + + // Virtual methods + gobject.Object.virtual_methods.dispose.implement(class, &dispose); + gobject.Object.virtual_methods.finalize.implement(class, &finalize); + } + + pub const as = C.Class.as; + pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 9a65cd2d7..520050cb6 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -364,7 +364,8 @@ pub const Tab = extern struct { fn closureComputedTitle( _: *Self, config_: ?*Config, - plain_: ?[*:0]const u8, + terminal_: ?[*:0]const u8, + override_: ?[*:0]const u8, zoomed_: c_int, bell_ringing_: c_int, _: *gobject.ParamSpec, @@ -372,9 +373,13 @@ pub const Tab = extern struct { const zoomed = zoomed_ != 0; const bell_ringing = bell_ringing_ != 0; + // Our plain title is the overridden title if it exists, otherwise + // the terminal title if it exists, otherwise a default string. const plain = plain: { const default = "Ghostty"; - const plain = plain_ orelse break :plain default; + const plain = override_ orelse + terminal_ orelse + break :plain default; break :plain std.mem.span(plain); }; diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 49aae0a04..1493b9997 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -168,7 +168,7 @@ menu context_menu_model { item { label: _("Change Title…"); - action: "win.prompt-title"; + action: "surface.prompt-title"; } item { diff --git a/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp b/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp new file mode 100644 index 000000000..24ae26f37 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp @@ -0,0 +1,16 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttySurfaceTitleDialog: Adw.AlertDialog { + heading: _("Change Terminal Title"); + body: _("Leave blank to restore the default title."); + + responses [ + cancel: _("Cancel") suggested, + ok: _("OK") destructive, + ] + + focus-widget: entry; + + extra-child: Entry entry {}; +} diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 4b92e38f2..687b18890 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,7 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { From 0d0d3118f4fe0afc1961555e193899194fd27611 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 14 Aug 2025 02:07:49 +0800 Subject: [PATCH 20/53] gtk-ng: show on-screen keyboard on LMB release This aligns with VTE behavior when the on-screen keyboard is enabled in GNOME's accessibility settings. Closes #7987 --- src/apprt/gtk-ng/class/surface.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index c12afc054..995f2fa0f 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1949,6 +1949,18 @@ pub const Surface = extern struct { if (priv.core_surface) |surface| { const gtk_mods = event.getModifierState(); const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + + // Trigger the on-screen keyboard if we have no selection. + // + // This cannot be easily implemented with e.g. bindings or apprt + // actions since the API accepts a gdk.Event, making it inherently + // apprt-specific. + if (button == .left and !surface.hasSelection()) { + if (priv.im_context.as(gtk.IMContext).activateOsk(event) == 0) { + log.warn("failed to activate the on-screen keyboard", .{}); + } + } + const mods = gtk_key.translateMods(gtk_mods); _ = surface.mouseButtonCallback( .release, From 23048dbd33a15913d4a3f34124a316fc5e5a142e Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 14 Aug 2025 02:38:45 +0800 Subject: [PATCH 21/53] gtk-ng: add show_on_screen_keyboard binding --- include/ghostty.h | 1 + src/Surface.zig | 6 ++++++ src/apprt/action.zig | 4 ++++ src/apprt/gtk-ng/class/application.zig | 16 ++++++++++++++++ src/apprt/gtk-ng/class/surface.zig | 12 ++++++++---- src/apprt/gtk/App.zig | 1 + src/input/Binding.zig | 9 +++++++++ src/input/command.zig | 6 ++++++ 8 files changed, 51 insertions(+), 4 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index c422c3584..082711836 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -757,6 +757,7 @@ typedef enum { GHOSTTY_ACTION_OPEN_URL, GHOSTTY_ACTION_SHOW_CHILD_EXITED, GHOSTTY_ACTION_PROGRESS_REPORT, + GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, } ghostty_action_tag_e; typedef union { diff --git a/src/Surface.zig b/src/Surface.zig index 1ab0fc59e..866505717 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4804,6 +4804,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .show_on_screen_keyboard => return try self.rt_app.performAction( + .{ .surface = self }, + .show_on_screen_keyboard, + {}, + ), + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 0f2b68087..d2d444c3a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -291,6 +291,9 @@ pub const Action = union(Key) { /// Show a native GUI notification about the progress of some TUI operation. progress_report: terminal.osc.Command.ProgressReport, + /// Show the on-screen keyboard. + show_on_screen_keyboard, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -345,6 +348,7 @@ pub const Action = union(Key) { open_url, show_child_exited, progress_report, + show_on_screen_keyboard, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index becfac14a..a47d80ed1 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -618,6 +618,7 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), + .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), // Unimplemented but todo on gtk-ng branch .inspector, @@ -2146,6 +2147,21 @@ const Action = struct { } } + pub fn showOnScreenKeyboard(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("show_on_screen_keyboard to app is unexpected", .{}); + return false; + }, + // NOTE: Even though `activateOsk` takes a gdk.Event, it's currently + // unused by all implementations of `activateOsk` as of GTK 4.18. + // The commit that introduced the method (ce6aa73c) clarifies that + // the event *may* be used by other IM backends, but for Linux desktop + // environments this doesn't matter. + .surface => |v| return v.rt_surface.surface.showOnScreenKeyboard(null), + } + } + fn getQuickTerminalWindow() ?*Window { // Find a quick terminal window. const list = gtk.Window.listToplevels(); diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 995f2fa0f..6d6f66d7c 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -573,6 +573,11 @@ pub const Surface = extern struct { return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } + pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { + const priv = self.private(); + return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; + } + /// Set the current progress report state. pub fn setProgressReport( self: *Self, @@ -1952,11 +1957,10 @@ pub const Surface = extern struct { // Trigger the on-screen keyboard if we have no selection. // - // This cannot be easily implemented with e.g. bindings or apprt - // actions since the API accepts a gdk.Event, making it inherently - // apprt-specific. + // It's better to do this here rather than in the core callback + // since we have direct access to the underlying gdk.Event here. if (button == .left and !surface.hasSelection()) { - if (priv.im_context.as(gtk.IMContext).activateOsk(event) == 0) { + if (!self.showOnScreenKeyboard(event)) { log.warn("failed to activate the on-screen keyboard", .{}); } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index faa4781f6..0f75a2d97 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -539,6 +539,7 @@ pub fn performAction( .check_for_updates, .undo, .redo, + .show_on_screen_keyboard, => { log.warn("unimplemented action={}", .{action}); return false; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b20319810..2ed6c2636 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -524,6 +524,14 @@ pub const Action = union(enum) { /// Has no effect on macOS. show_gtk_inspector, + /// Show the on-screen keyboard if one is present. + /// + /// Only implemented on Linux (GTK). On GNOME, the "Screen Keyboard" + /// accessibility feature must be turned on, which can be found under + /// Settings > Accessibility > Typing. Other platforms are as of now + /// untested. + show_on_screen_keyboard, + /// Open the configuration file in the default OS editor. /// /// If your default OS editor isn't configured then this will fail. @@ -1051,6 +1059,7 @@ pub const Action = union(enum) { .toggle_window_float_on_top, .toggle_secure_input, .toggle_command_palette, + .show_on_screen_keyboard, .reset_window_size, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 84e9afc79..615ffb713 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -369,6 +369,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Show the GTK inspector.", }}, + .show_on_screen_keyboard => comptime &.{.{ + .action = .show_on_screen_keyboard, + .title = "Show On-Screen Keyboard", + .description = "Show the on-screen keyboard if present.", + }}, + .open_config => comptime &.{.{ .action = .open_config, .title = "Open Config", From 1b1264e59218415c7eb11ac4ce6c23e829d3f3f2 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 14 Aug 2025 03:16:32 +0800 Subject: [PATCH 22/53] gtk-ng: only show OSD when mouse event isn't consumed --- src/apprt/gtk-ng/class/surface.zig | 42 +++++++++++++++--------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 6d6f66d7c..7e86354d3 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1951,29 +1951,29 @@ pub const Surface = extern struct { const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; const priv = self.private(); - if (priv.core_surface) |surface| { - const gtk_mods = event.getModifierState(); - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); + const surface = priv.core_surface orelse return; + const gtk_mods = event.getModifierState(); + const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - // Trigger the on-screen keyboard if we have no selection. - // - // It's better to do this here rather than in the core callback - // since we have direct access to the underlying gdk.Event here. - if (button == .left and !surface.hasSelection()) { - if (!self.showOnScreenKeyboard(event)) { - log.warn("failed to activate the on-screen keyboard", .{}); - } + const mods = gtk_key.translateMods(gtk_mods); + const consumed = surface.mouseButtonCallback( + .release, + button, + mods, + ) catch |err| { + log.warn("error in key callback err={}", .{err}); + return; + }; + + // Trigger the on-screen keyboard if we have no selection, + // and that the mouse event hasn't been intercepted by the callback. + // + // It's better to do this here rather than within the core callback + // since we have direct access to the underlying gdk.Event here. + if (!consumed and button == .left and !surface.hasSelection()) { + if (!self.showOnScreenKeyboard(event)) { + log.warn("failed to activate the on-screen keyboard", .{}); } - - const mods = gtk_key.translateMods(gtk_mods); - _ = surface.mouseButtonCallback( - .release, - button, - mods, - ) catch |err| { - log.warn("error in key callback err={}", .{err}); - return; - }; } } From 997d38c3624a8e311688c8f3a2710dbaa1c92605 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 15:21:06 -0700 Subject: [PATCH 23/53] apprt/gtk-ng: set cursor on Surface widget, not GL area This fixes `mouse-hide-while-typing`. Don't know why this worked before (I tested it yesterday!) but stopped working today. But this now works, and conceptually makes some sense. --- src/apprt/gtk-ng/class/surface.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 7e86354d3..2398f1502 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1240,7 +1240,7 @@ pub const Surface = extern struct { renderer.OpenGL.MIN_VERSION_MAJOR, renderer.OpenGL.MIN_VERSION_MINOR, ); - gl_area.as(gtk.Widget).setCursorFromName("text"); + self.as(gtk.Widget).setCursorFromName("text"); // Initialize our config self.propConfig(undefined, null); @@ -1570,7 +1570,7 @@ pub const Surface = extern struct { // If we're hidden we set it to "none" if (priv.mouse_hidden) { - priv.gl_area.as(gtk.Widget).setCursorFromName("none"); + self.as(gtk.Widget).setCursorFromName("none"); return; } @@ -1628,7 +1628,7 @@ pub const Surface = extern struct { }; // Set our new cursor. - priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr); + self.as(gtk.Widget).setCursorFromName(name.ptr); } fn propBellRinging( From 0979e6d2e9a1c5fefd78deb2a4fc16bbd525a55a Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 14 Aug 2025 03:19:54 +0800 Subject: [PATCH 24/53] gtk-ng: parametrize the new-split action why four when one do trick --- src/apprt/gtk-ng/class/application.zig | 20 +++---- src/apprt/gtk-ng/class/split_tree.zig | 82 ++++++++++---------------- src/apprt/gtk-ng/ui/1.2/surface.blp | 12 ++-- 3 files changed, 48 insertions(+), 66 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index becfac14a..d8b795f4d 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -886,10 +886,10 @@ pub const Application = extern struct { 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 }); + self.syncActionAccelerator("split-tree.new-split::left", .{ .new_split = .left }); + self.syncActionAccelerator("split-tree.new-split::right", .{ .new_split = .right }); + self.syncActionAccelerator("split-tree.new-split::up", .{ .new_split = .up }); + self.syncActionAccelerator("split-tree.new-split::down", .{ .new_split = .down }); } fn syncActionAccelerator( @@ -1814,12 +1814,12 @@ const Action = struct { .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; + + return surface.as(gtk.Widget).activateAction( + "split-tree.new-split", + "&s", + @tagName(direction).ptr, + ) != 0; }, } } diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 1da0896a2..a5e823158 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -175,16 +175,16 @@ pub const SplitTree = extern struct { // // For action names: // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html - const actions = .{ + const actions: []const struct { + [:0]const u8, + *const fn (*gio.SimpleAction, ?*glib.Variant, *Self) callconv(.c) void, + ?*glib.VariantType, + } = &.{ // 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 }, - .{ "zoom", actionZoom, null }, + .{ "new-split", &actionNewSplit, glib.ext.VariantType.newFor([:0]const u8) }, + .{ "equalize", &actionEqualize, null }, + .{ "zoom", &actionZoom, null }, }; // We need to collect our actions into a group since we're just @@ -192,12 +192,16 @@ pub const SplitTree = extern struct { const group = gio.SimpleActionGroup.new(); errdefer group.unref(); const map = group.as(gio.ActionMap); - inline for (actions) |entry| { + for (actions) |entry| { const action = gio.SimpleAction.new( entry[0], entry[2], ); - defer action.unref(); + defer { + action.unref(); + if (entry[2]) |ptype| ptype.free(); + } + _ = gio.SimpleAction.signals.activate.connect( action, *Self, @@ -567,56 +571,30 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Signal handlers - pub fn actionNewLeft( + pub fn actionNewSplit( _: *gio.SimpleAction, - parameter_: ?*glib.Variant, + args_: ?*glib.Variant, self: *Self, ) callconv(.c) void { - _ = parameter_; - self.newSplit( - .left, - self.getActiveSurface(), - ) catch |err| { - log.warn("new split failed error={}", .{err}); + const args = args_ orelse { + log.warn("split-tree.new-split called without a parameter", .{}); + return; }; - } - 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}); + var dir: ?[*:0]const u8 = null; + args.get("&s", &dir); + + const direction = std.meta.stringToEnum( + Surface.Tree.Split.Direction, + std.mem.span(dir) orelse return, + ) orelse { + // Need to be defensive here since actions can be triggered externally. + log.warn("invalid split direction for split-tree.new-split: {s}", .{dir.?}); + return; }; - } - 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, + direction, self.getActiveSurface(), ) catch |err| { log.warn("new split failed error={}", .{err}); diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 97fc7483b..98f4f5dd2 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -194,22 +194,26 @@ menu context_menu_model { item { label: _("Split Up"); - action: "split-tree.new-up"; + action: "split-tree.new-split"; + target: "up"; } item { label: _("Split Down"); - action: "split-tree.new-down"; + action: "split-tree.new-split"; + target: "down"; } item { label: _("Split Left"); - action: "split-tree.new-left"; + action: "split-tree.new-split"; + target: "left"; } item { label: _("Split Right"); - action: "split-tree.new-right"; + action: "split-tree.new-split"; + target: "right"; } } From bd7177a9244c0a8f99e97d7c6bb433737d0656ba Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 11 Aug 2025 22:48:37 -0500 Subject: [PATCH 25/53] gtk-ng: port the terminal inspector This is a (relatively) straightforward port of the terminal inspector from the old GTK application runtime. It's split into three widgets. At the lowest level is a widget designed for showing a generic Dear ImGui application. Above that is a widget that embeds the ImGui widget and plumbs it into the core Inspector. At the top is a custom Window widget that embeds the Inspector widget. And then there's all the plumbing necessary to hook everything into the rest of Ghostty. In theory this design _should_ allow showing the Inspector in a split or a tab in the future, not just in a separate window. It should also make it easier to display _other_ Dear ImGui applications if they are ever needed. --- src/apprt/gtk-ng/App.zig | 5 +- src/apprt/gtk-ng/Surface.zig | 5 + src/apprt/gtk-ng/build/gresource.zig | 3 + src/apprt/gtk-ng/class/application.zig | 18 +- src/apprt/gtk-ng/class/imgui_widget.zig | 492 +++++++++++++++++++ src/apprt/gtk-ng/class/inspector_widget.zig | 210 ++++++++ src/apprt/gtk-ng/class/inspector_window.zig | 225 +++++++++ src/apprt/gtk-ng/class/surface.zig | 64 +++ src/apprt/gtk-ng/class/window.zig | 19 + src/apprt/gtk-ng/ui/1.5/imgui-widget.blp | 51 ++ src/apprt/gtk-ng/ui/1.5/inspector-widget.blp | 15 + src/apprt/gtk-ng/ui/1.5/inspector-window.blp | 38 ++ 12 files changed, 1135 insertions(+), 10 deletions(-) create mode 100644 src/apprt/gtk-ng/class/imgui_widget.zig create mode 100644 src/apprt/gtk-ng/class/inspector_widget.zig create mode 100644 src/apprt/gtk-ng/class/inspector_window.zig create mode 100644 src/apprt/gtk-ng/ui/1.5/imgui-widget.blp create mode 100644 src/apprt/gtk-ng/ui/1.5/inspector-widget.blp create mode 100644 src/apprt/gtk-ng/ui/1.5/inspector-window.blp diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk-ng/App.zig index bc6c11102..4d2006fbb 100644 --- a/src/apprt/gtk-ng/App.zig +++ b/src/apprt/gtk-ng/App.zig @@ -99,7 +99,6 @@ pub fn performIpc( } /// Redraw the inspector for the given surface. -pub fn redrawInspector(self: *App, surface: *Surface) void { - _ = self; - _ = surface; +pub fn redrawInspector(_: *App, surface: *Surface) void { + surface.redrawInspector(); } diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index d1a5cbec3..1614d74d0 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -95,3 +95,8 @@ pub fn setClipboardString( pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { return try self.surface.defaultTermioEnv(); } + +/// Redraw the inspector for our surface. +pub fn redrawInspector(self: *Self) void { + self.surface.redrawInspector(); +} diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index f606435c6..2d2738fdb 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -48,6 +48,9 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "command-palette" }, + .{ .major = 1, .minor = 5, .name = "imgui-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-window" }, }; /// CSS files in css_path diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index b762eff12..1fa61f791 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -561,6 +561,8 @@ pub const Application = extern struct { .initial_size => return Action.initialSize(target, value), + .inspector => return Action.controlInspector(target, value), + .mouse_over_link => Action.mouseOverLink(target, value), .mouse_shape => Action.mouseShape(target, value), .mouse_visibility => Action.mouseVisibility(target, value), @@ -620,13 +622,6 @@ pub const Application = extern struct { .toggle_split_zoom => return Action.toggleSplitZoom(target), .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), - // Unimplemented but todo on gtk-ng branch - .inspector, - => { - log.warn("unimplemented action={}", .{action}); - return false; - }, - // Unimplemented .secure_input, .close_all_windows, @@ -2235,6 +2230,15 @@ const Action = struct { }, } } + + pub fn controlInspector(target: apprt.Target, value: apprt.Action.Value(.inspector)) bool { + switch (target) { + .app => return false, + .surface => |surface| { + return surface.rt_surface.gobj().controlInspector(value); + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk-ng/class/imgui_widget.zig b/src/apprt/gtk-ng/class/imgui_widget.zig new file mode 100644 index 000000000..56c5370c7 --- /dev/null +++ b/src/apprt/gtk-ng/class/imgui_widget.zig @@ -0,0 +1,492 @@ +const std = @import("std"); + +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const cimgui = @import("cimgui"); +const gl = @import("opengl"); + +const input = @import("../../../input.zig"); +const gresource = @import("../build/gresource.zig"); + +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_imgui_widget); + +pub const RenderCallback = *const fn (?*anyopaque) void; +pub const RenderUserdata = *anyopaque; + +/// A widget for embedding a Dear ImGui application. +pub const ImguiWidget = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyImguiWidget", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct {}; + + pub const signals = struct {}; + + const Private = struct { + /// GL area where we display the Dear ImGui application. + gl_area: *gtk.GLArea, + + /// GTK input method context + im_context: *gtk.IMMulticontext, + + /// Dear ImGui context + ig_context: ?*cimgui.c.ImGuiContext = null, + + /// True if the the Dear ImGui OpenGL backend was initialized. + ig_gl_backend_initialized: bool = false, + + /// Our previous instant used to calculate delta time for animations. + instant: ?std.time.Instant = null, + + /// This is called every frame to populate the Dear ImGui frame. + render_callback: ?RenderCallback = null, + render_userdata: ?RenderUserdata = null, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + + priv.ig_context = ig_context: { + const ig_context = cimgui.c.igCreateContext(null) orelse { + log.warn("unable to initialize Dear ImGui context", .{}); + break :ig_context null; + }; + errdefer cimgui.c.igDestroyContext(ig_context); + cimgui.c.igSetCurrentContext(ig_context); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + io.BackendPlatformName = "ghostty_gtk"; + + break :ig_context ig_context; + }; + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + fn finalize(self: *Self) callconv(.c) void { + const priv = self.private(); + + // If the the Dear ImGui OpenGL backend was never initialized then we + // need to destroy the Dear ImGui context manually here. If it _was_ + // initialized cleanup will be handled when the GLArea is unrealized. + if (!priv.ig_gl_backend_initialized) { + if (priv.ig_context) |ig_context| { + cimgui.c.igDestroyContext(ig_context); + priv.ig_context = null; + } + } + + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + pub fn new() *Self { + return gobject.ext.newInstance(Self, .{}); + } + + /// Use to setup the Dear ImGui application. + pub fn setup(self: *Self, callback: *const fn () void) void { + self.setCurrentContext() catch return; + callback(); + } + + /// Set the callback used to render every frame. + pub fn setRenderCallback( + self: *Self, + callback: ?RenderCallback, + userdata: ?RenderUserdata, + ) void { + const priv = self.private(); + priv.render_callback = callback; + priv.render_userdata = userdata; + } + + /// This should be called anytime the underlying data for the UI changes + /// so that the UI can be refreshed. + pub fn queueRender(self: *ImguiWidget) void { + const priv = self.private(); + priv.gl_area.queueRender(); + } + + //--------------------------------------------------------------- + // Private Methods + + /// Set our imgui context to be current, or return an error. + fn setCurrentContext(self: *Self) error{ContextNotInitialized}!void { + const priv = self.private(); + const ig_context = priv.ig_context orelse { + log.warn("Dear ImGui context not initialized", .{}); + return error.ContextNotInitialized; + }; + cimgui.c.igSetCurrentContext(ig_context); + } + + /// Initialize the frame. Expects that the context is already current. + fn newFrame(self: *Self) void { + // If we can't determine the time since the last frame we default to + // 1/60th of a second. + const default_delta_time = 1 / 60; + + const priv = self.private(); + + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + + // Determine our delta time + const now = std.time.Instant.now() catch unreachable; + io.DeltaTime = if (priv.instant) |prev| delta: { + const since_ns = now.since(prev); + const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s); + break :delta @max(0.00001, since_s); + } else default_delta_time; + + priv.instant = now; + } + + /// Handle key press/release events. + fn keyEvent( + self: *ImguiWidget, + action: input.Action, + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + _: c_uint, + gtk_mods: gdk.ModifierType, + ) bool { + self.queueRender(); + + self.setCurrentContext() catch return false; + + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + + const mods = key.translateMods(gtk_mods); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt); + cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super); + + // If our keyval has a key, then we send that key event + if (key.keyFromKeyval(keyval)) |inputkey| { + if (inputkey.imguiKey()) |imgui_key| { + cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press); + } + } + + // Try to process the event as text + if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| { + const priv = self.private(); + _ = priv.im_context.as(gtk.IMContext).filterKeypress(event); + } + + return true; + } + + /// Translate a GTK mouse button to a Dear ImGui mouse button. + fn translateMouseButton(button: c_uint) ?c_int { + return switch (button) { + 1 => cimgui.c.ImGuiMouseButton_Left, + 2 => cimgui.c.ImGuiMouseButton_Middle, + 3 => cimgui.c.ImGuiMouseButton_Right, + else => null, + }; + } + + /// Get the scale factor that the display is operating at. + fn getScaleFactor(self: *Self) f64 { + const priv = self.private(); + return @floatFromInt(priv.gl_area.as(gtk.Widget).getScaleFactor()); + } + + //--------------------------------------------------------------- + // Properties + + //--------------------------------------------------------------- + // Signal Handlers + + fn glAreaRealize(_: *gtk.GLArea, self: *Self) callconv(.c) void { + const priv = self.private(); + + priv.gl_area.makeCurrent(); + if (priv.gl_area.getError()) |err| { + log.err("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); + return; + } + + self.setCurrentContext() catch return; + + // realize means that our OpenGL context is ready, so we can now + // initialize the ImgUI OpenGL backend for our context. + _ = cimgui.ImGui_ImplOpenGL3_Init(null); + + priv.ig_gl_backend_initialized = true; + } + + /// Handle a request to unrealize the GLArea + fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { + self.setCurrentContext() catch return; + cimgui.ImGui_ImplOpenGL3_Shutdown(); + } + + /// Handle a request to resize the GLArea + fn glAreaResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *Self) callconv(.c) void { + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const scale_factor = area.as(gtk.Widget).getScaleFactor(); + + // Our display size is always unscaled. We'll do the scaling in the + // style instead. This creates crisper looking fonts. + io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; + io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; + + // Setup a new style and scale it appropriately. + const style = cimgui.c.ImGuiStyle_ImGuiStyle(); + defer cimgui.c.ImGuiStyle_destroy(style); + cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor)); + const active_style = cimgui.c.igGetStyle(); + active_style.* = style.*; + } + + /// Handle a request to render the contents of our GLArea + fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int { + self.setCurrentContext() catch return @intFromBool(false); + + // Setup our frame. We render twice because some ImGui behaviors + // take multiple renders to process. I don't know how to make this + // more efficient. + for (0..2) |_| { + cimgui.ImGui_ImplOpenGL3_NewFrame(); + self.newFrame(); + cimgui.c.igNewFrame(); + + // Use the callback to draw the UI. + const priv = self.private(); + if (priv.render_callback) |cb| cb(priv.render_userdata); + + // Render + cimgui.c.igRender(); + } + + // OpenGL final render + gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0); + gl.clear(gl.c.GL_COLOR_BUFFER_BIT); + cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData()); + + return @intFromBool(true); + } + + fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, true); + } + + fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddFocusEvent(io, false); + } + + fn ecKeyPressed( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *ImguiWidget, + ) callconv(.c) c_int { + return @intFromBool(self.keyEvent( + .press, + ec_key, + keyval, + keycode, + gtk_mods, + )); + } + + fn ecKeyReleased( + ec_key: *gtk.EventControllerKey, + keyval: c_uint, + keycode: c_uint, + gtk_mods: gdk.ModifierType, + self: *ImguiWidget, + ) callconv(.c) void { + _ = self.keyEvent( + .release, + ec_key, + keyval, + keycode, + gtk_mods, + ); + } + + fn ecMousePressed( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); + if (translateMouseButton(gdk_button)) |button| { + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true); + } + } + + fn ecMouseReleased( + gesture: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); + if (translateMouseButton(gdk_button)) |button| { + cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false); + } + } + + fn ecMouseMotion( + _: *gtk.EventControllerMotion, + x: f64, + y: f64, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + const scale_factor = self.getScaleFactor(); + cimgui.c.ImGuiIO_AddMousePosEvent( + io, + @floatCast(x * scale_factor), + @floatCast(y * scale_factor), + ); + } + + fn ecMouseScroll( + _: *gtk.EventControllerScroll, + x: f64, + y: f64, + self: *ImguiWidget, + ) callconv(.c) c_int { + self.queueRender(); + self.setCurrentContext() catch return @intFromBool(false); + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddMouseWheelEvent( + io, + @floatCast(x), + @floatCast(-y), + ); + return @intFromBool(true); + } + + fn imCommit( + _: *gtk.IMMulticontext, + bytes: [*:0]u8, + self: *ImguiWidget, + ) callconv(.c) void { + self.queueRender(); + self.setCurrentContext() catch return; + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); + } + + 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 { + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "imgui-widget", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("gl_area", .{}); + class.bindTemplateChildPrivate("im_context", .{}); + + // Template Callbacks + class.bindTemplateCallback("realize", &glAreaRealize); + class.bindTemplateCallback("unrealize", &glAreaUnrealize); + class.bindTemplateCallback("resize", &glAreaResize); + class.bindTemplateCallback("render", &glAreaRender); + + class.bindTemplateCallback("focus_enter", &ecFocusEnter); + class.bindTemplateCallback("focus_leave", &ecFocusLeave); + + class.bindTemplateCallback("key_pressed", &ecKeyPressed); + class.bindTemplateCallback("key_released", &ecKeyReleased); + + class.bindTemplateCallback("mouse_pressed", &ecMousePressed); + class.bindTemplateCallback("mouse_released", &ecMouseReleased); + class.bindTemplateCallback("mouse_motion", &ecMouseMotion); + + class.bindTemplateCallback("scroll", &ecMouseScroll); + + class.bindTemplateCallback("im_commit", &imCommit); + + // Properties + + // Signals + + // 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/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig new file mode 100644 index 000000000..b36d6b3c0 --- /dev/null +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -0,0 +1,210 @@ +const std = @import("std"); + +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Inspector = @import("../../../inspector/Inspector.zig"); + +const Common = @import("../class.zig").Common; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const Surface = @import("surface.zig").Surface; +const ImguiWidget = @import("imgui_widget.zig").ImguiWidget; + +const log = std.log.scoped(.gtk_ghostty_inspector_widget); + +/// Widget for displaying the Ghostty inspector. +pub const InspectorWidget = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyInspectorWidget", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor(Self, ?*Surface, .{ + .getter = getSurface, + .getter_transfer = .full, + .setter = setSurface, + .setter_transfer = .none, + }), + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to + surface: WeakRef(Surface) = .empty, + + /// The embedded Dear ImGui widget. + imgui_widget: *ImguiWidget, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + const priv = self.private(); + priv.imgui_widget.setup(Inspector.setup); + priv.imgui_widget.setRenderCallback(imguiRender, self); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + deactivate: { + const surface = priv.surface.get() orelse break :deactivate; + defer surface.unref(); + + const core_surface = surface.core() orelse break :deactivate; + core_surface.deactivateInspector(); + } + + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + pub fn new(surface: *Surface) *Self { + return gobject.ext.newInstance(Self, .{ + .surface = surface, + }); + } + + /// Queue a render of the Dear ImGui widget. + pub fn queueRender(self: *Self) void { + const priv = self.private(); + priv.imgui_widget.queueRender(); + } + + //--------------------------------------------------------------- + // Private Methods + + /// This is the callback from the embedded Dear ImGui widget that is called + /// to do the actual drawing. + fn imguiRender(ud: ?*anyopaque) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + const surface = priv.surface.get() orelse return; + defer surface.unref(); + const core_surface = surface.core() orelse return; + const inspector = core_surface.inspector orelse return; + inspector.render(); + } + + //--------------------------------------------------------------- + // Properties + + fn getSurface(self: *Self) ?*Surface { + const priv = self.private(); + return priv.surface.get(); + } + + fn setSurface(self: *Self, newvalue_: ?*Surface) void { + const priv = self.private(); + + if (priv.surface.get()) |oldvalue| oldvalue: { + defer oldvalue.unref(); + + // We don't need to do anything if we're just setting the same surface. + if (newvalue_) |newvalue| if (newvalue == oldvalue) return; + + // Deactivate the inspector on the old surface. + const core_surface = oldvalue.core() orelse break :oldvalue; + core_surface.deactivateInspector(); + } + + const newvalue = newvalue_ orelse { + priv.surface.set(null); + return; + }; + + const core_surface = newvalue.core() orelse { + priv.surface.set(null); + return; + }; + + // Activate the inspector on the new surface. + core_surface.activateInspector() catch |err| { + log.err("failed to activate inspector err={}", .{err}); + }; + + priv.surface.set(newvalue); + + self.queueRender(); + } + + //--------------------------------------------------------------- + // Signal Handlers + + 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(ImguiWidget); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "inspector-widget", + }), + ); + + // Bindings + class.bindTemplateChildPrivate("imgui_widget", .{}); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + }); + + // Signals + + // 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; + }; +}; diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig new file mode 100644 index 000000000..7f5c8fe10 --- /dev/null +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -0,0 +1,225 @@ +const std = @import("std"); +const build_config = @import("../../../build_config.zig"); + +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); + +const key = @import("../key.zig"); +const Common = @import("../class.zig").Common; +const Application = @import("application.zig").Application; +const Surface = @import("surface.zig").Surface; +const DebugWarning = @import("debug_warning.zig").DebugWarning; +const InspectorWidget = @import("inspector_widget.zig").InspectorWidget; +const WeakRef = @import("../weak_ref.zig").WeakRef; + +const log = std.log.scoped(.gtk_ghostty_inspector_window); + +/// Window for displaying the Ghostty inspector. +pub const InspectorWindow = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.ApplicationWindow; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyInspectorWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = gobject.ext.typedAccessor(Self, ?*Surface, .{ + .getter = getSurface, + .getter_transfer = .full, + .setter = setSurface, + .setter_transfer = .none, + }), + }, + ); + }; + + pub const debug = struct { + pub const name = "debug"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = build_config.is_debug, + .accessor = gobject.ext.typedAccessor(Self, bool, .{ + .getter = struct { + pub fn getter(_: *Self) bool { + return build_config.is_debug; + } + }.getter, + }), + }, + ); + }; + }; + + pub const signals = struct {}; + + const Private = struct { + /// The surface that we are attached to + surface: WeakRef(Surface) = .empty, + + /// The embedded inspector widget. + inspector_widget: *InspectorWidget, + + pub var offset: c_int = 0; + }; + + //--------------------------------------------------------------- + // Virtual Methods + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Add our dev CSS class if we're in debug mode. + if (comptime build_config.is_debug) { + self.as(gtk.Widget).addCssClass("devel"); + } + + // Set our window icon. We can't set this in the blueprint file + // because its dependent on the build config. + self.as(gtk.Window).setIconName(build_config.bundle_id); + } + + fn dispose(self: *Self) callconv(.c) void { + gtk.Widget.disposeTemplate( + self.as(gtk.Widget), + getGObjectType(), + ); + + gobject.Object.virtual_methods.dispose.call( + Class.parent, + self.as(Parent), + ); + } + + //--------------------------------------------------------------- + // Public methods + + pub fn new(surface: *Surface) *Self { + const self = gobject.ext.newInstance(Self, .{ + .surface = surface, + }); + + // Bump the ref so that we aren't immediately closed. + return self.ref(); + } + + /// Present the window. + pub fn present(self: *Self) void { + self.as(gtk.Window).present(); + } + + /// Queue a render of the embedded widget. + pub fn queueRender(self: *Self) void { + const priv = self.private(); + priv.inspector_widget.queueRender(); + } + + /// The surface we are connected to is going away, shut ourselves down. + pub fn shutdown(self: *Self) void { + const priv = self.private(); + priv.surface.set(null); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + self.as(gtk.Window).close(); + } + + //--------------------------------------------------------------- + // Private Methods + + fn isFullscreen(self: *Self) bool { + return self.as(gtk.Window).isFullscreen() != 0; + } + + fn isMaximized(self: *Self) bool { + return self.as(gtk.Window).isMaximized() != 0; + } + + //--------------------------------------------------------------- + // Properties + + fn getSurface(self: *Self) ?*Surface { + const priv = self.private(); + return priv.surface.get(); + } + + fn setSurface(self: *Self, newvalue: ?*Surface) void { + const priv = self.private(); + priv.surface.set(newvalue); + } + + //--------------------------------------------------------------- + // Signal Handlers + + /// The user has clicked on the close button. + fn closeRequest(_: *gtk.Window, self: *Self) callconv(.c) c_int { + const priv = self.private(); + priv.surface.set(null); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + self.as(gtk.Window).destroy(); + return @intFromBool(false); + } + + 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(DebugWarning); + gobject.ext.ensureType(InspectorWidget); + gtk.Widget.Class.setTemplateFromResource( + class.as(gtk.Widget.Class), + comptime gresource.blueprint(.{ + .major = 1, + .minor = 5, + .name = "inspector-window", + }), + ); + + // Template Bindings + class.bindTemplateChildPrivate("inspector_widget", .{}); + + // Template Callbacks + class.bindTemplateCallback("close_request", &closeRequest); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.surface.impl, + properties.debug.impl, + }); + + // Signals + + // 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; + }; +}; diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 2398f1502..65b128bdf 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -29,6 +29,8 @@ const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; const Window = @import("window.zig").Window; +const WeakRef = @import("../weak_ref.zig").WeakRef; +const InspectorWindow = @import("inspector_window.zig").InspectorWindow; const log = std.log.scoped(.gtk_ghostty_surface); @@ -470,6 +472,9 @@ pub const Surface = extern struct { // false by a parent widget. bell_ringing: bool = false, + /// A weak reference to an inspector window. + inspector: WeakRef(InspectorWindow) = .empty, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -573,6 +578,58 @@ pub const Surface = extern struct { return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } + pub fn toggleInspector(self: *Self) bool { + const priv = self.private(); + if (priv.inspector.get()) |inspector| { + defer inspector.unref(); + inspector.shutdown(); + priv.inspector.set(null); + return true; + } + const inspector = InspectorWindow.new(self); + defer inspector.unref(); + priv.inspector.set(inspector); + inspector.present(); + return true; + } + + pub fn showInspector(self: *Self) bool { + const priv = self.private(); + const inspector = priv.inspector.get() orelse inspector: { + const inspector = InspectorWindow.new(self); + priv.inspector.set(inspector); + break :inspector inspector; + }; + defer inspector.unref(); + inspector.present(); + return true; + } + + pub fn hideInspector(self: *Self) bool { + const priv = self.private(); + if (priv.inspector.get()) |inspector| { + defer inspector.unref(); + inspector.shutdown(); + priv.inspector.set(null); + } + return true; + } + + pub fn controlInspector(self: *Self, value: apprt.Action.Value(.inspector)) bool { + switch (value) { + .toggle => return self.toggleInspector(), + .show => return self.showInspector(), + .hide => return self.hideInspector(), + } + } + /// Redraw our inspector, if there is one associated with this surface. + pub fn redrawInspector(self: *Self) void { + const priv = self.private(); + const inspector = priv.inspector.get() orelse return; + defer inspector.unref(); + inspector.queueRender(); + } + pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { const priv = self.private(); return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; @@ -1287,10 +1344,12 @@ pub const Surface = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); + if (priv.config) |v| { v.unref(); priv.config = null; } + if (priv.progress_bar_timer) |timer| { if (glib.Source.remove(timer) == 0) { log.warn("unable to remove progress bar timer", .{}); @@ -1298,6 +1357,11 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.inspector.get()) |inspector| { + defer inspector.unref(); + inspector.shutdown(); + } + gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index a480ed217..da4e9574f 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -28,6 +28,7 @@ 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 InspectorWindow = @import("inspector_window.zig").InspectorWindow; const WeakRef = @import("../weak_ref.zig").WeakRef; const log = std.log.scoped(.gtk_ghostty_window); @@ -347,6 +348,7 @@ pub const Window = extern struct { .{ "clear", actionClear, null }, // TODO: accept the surface that toggled the command palette .{ "toggle-command-palette", actionToggleCommandPalette, null }, + .{ "toggle-inspector", actionToggleInspector, null }, }; const action_map = self.as(gio.ActionMap); @@ -1820,6 +1822,23 @@ pub const Window = extern struct { self.toggleCommandPalette(); } + /// Toggle the Ghostty inspector for the active surface. + fn toggleInspector(self: *Self) void { + const surface = self.getActiveSurface() orelse return; + _ = surface.toggleInspector(); + } + + /// React to a GTK action requesting that the Ghostty inspector be toggled. + fn actionToggleInspector( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + // TODO: accept the surface that toggled the command palette as a + // parameter + self.toggleInspector(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; diff --git a/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp b/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp new file mode 100644 index 000000000..d5b973a70 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp @@ -0,0 +1,51 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyImguiWidget: Adw.Bin { + styles [ + "imgui", + ] + + Adw.Bin { + Gtk.GLArea gl_area { + auto-render: true; + // needs to be focusable so that we can receive events + focusable: true; + focus-on-click: true; + allowed-apis: gl; + realize => $realize(); + unrealize => $unrealize(); + resize => $resize(); + render => $render(); + + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + GestureClick { + pressed => $mouse_pressed(); + released => $mouse_released(); + button: 0; + } + + EventControllerMotion { + motion => $mouse_motion(); + } + + EventControllerScroll { + scroll => $scroll(); + flags: both_axes; + } + } + } +} + +IMMulticontext im_context { + commit => $im_commit(); +} diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp new file mode 100644 index 000000000..0cbd45d8a --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp @@ -0,0 +1,15 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWidget: Adw.Bin { + styles [ + "inspector", + ] + + hexpand: true; + vexpand: true; + + Adw.Bin { + $GhosttyImguiWidget imgui_widget {} + } +} diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp new file mode 100644 index 000000000..a67e26622 --- /dev/null +++ b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp @@ -0,0 +1,38 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyInspectorWindow: Adw.ApplicationWindow { + title: _("Ghostty: Terminal Inspector"); + icon-name: "com.mitchellh.ghostty"; + default-width: 1000; + default-height: 600; + close-request => $close_request(); + + styles [ + "inspector", + ] + + content: Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: bind template.title; + }; + } + + Gtk.Box { + orientation: vertical; + spacing: 0; + hexpand: true; + vexpand: true; + + $GhosttyDebugWarning { + visible: bind template.debug; + } + + $GhosttyInspectorWidget inspector_widget { + surface: bind template.surface; + } + } + }; +} From 43550c18c0bd31bf5d7d995bbf8041df2e8bdea5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 12:21:42 -0700 Subject: [PATCH 26/53] apprt/gtk-ng: imguiwidget uses signals instead of callbacks --- src/apprt/gtk-ng/build/gresource.zig | 6 +- src/apprt/gtk-ng/class/imgui_widget.zig | 158 +++++++++---------- src/apprt/gtk-ng/class/inspector_widget.zig | 39 +++-- src/apprt/gtk-ng/ui/1.5/inspector-widget.blp | 5 +- 4 files changed, 102 insertions(+), 106 deletions(-) diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk-ng/build/gresource.zig index 2d2738fdb..3cd385483 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk-ng/build/gresource.zig @@ -39,6 +39,9 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, .{ .major = 1, .minor = 2, .name = "debug-warning" }, .{ .major = 1, .minor = 3, .name = "debug-warning" }, + .{ .major = 1, .minor = 5, .name = "imgui-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-widget" }, + .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, @@ -48,9 +51,6 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "tab" }, .{ .major = 1, .minor = 5, .name = "window" }, .{ .major = 1, .minor = 5, .name = "command-palette" }, - .{ .major = 1, .minor = 5, .name = "imgui-widget" }, - .{ .major = 1, .minor = 5, .name = "inspector-widget" }, - .{ .major = 1, .minor = 5, .name = "inspector-window" }, }; /// CSS files in css_path diff --git a/src/apprt/gtk-ng/class/imgui_widget.zig b/src/apprt/gtk-ng/class/imgui_widget.zig index 56c5370c7..1522f2bc1 100644 --- a/src/apprt/gtk-ng/class/imgui_widget.zig +++ b/src/apprt/gtk-ng/class/imgui_widget.zig @@ -1,13 +1,13 @@ const std = @import("std"); +const assert = std.debug.assert; +const cimgui = @import("cimgui"); +const gl = @import("opengl"); const adw = @import("adw"); const gdk = @import("gdk"); const gobject = @import("gobject"); const gtk = @import("gtk"); -const cimgui = @import("cimgui"); -const gl = @import("opengl"); - const input = @import("../../../input.zig"); const gresource = @import("../build/gresource.zig"); @@ -16,10 +16,11 @@ const Common = @import("../class.zig").Common; const log = std.log.scoped(.gtk_ghostty_imgui_widget); -pub const RenderCallback = *const fn (?*anyopaque) void; -pub const RenderUserdata = *anyopaque; - /// A widget for embedding a Dear ImGui application. +/// +/// It'd be a lot cleaner to use inheritance here but zig-gobject doesn't +/// currently have a way to define virtual methods, so we have to use +/// composition and signals instead. pub const ImguiWidget = extern struct { const Self = @This(); parent_instance: Parent, @@ -34,7 +35,37 @@ pub const ImguiWidget = extern struct { pub const properties = struct {}; - pub const signals = struct {}; + pub const signals = struct { + /// Emitted when the child widget should render. During the callback, + /// the Imgui context is valid. + pub const render = struct { + pub const name = "render"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + + /// Emitted when first realized to allow the embedded ImGui application + /// to initialize itself. When this is called, the ImGui context + /// is properly set. + /// + /// This might be called multiple times, but each time it is + /// called a new Imgui context will be created. + pub const setup = struct { + pub const name = "setup"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; + }; const Private = struct { /// GL area where we display the Dear ImGui application. @@ -43,19 +74,13 @@ pub const ImguiWidget = extern struct { /// GTK input method context im_context: *gtk.IMMulticontext, - /// Dear ImGui context + /// Dear ImGui context. We create a context per widget so that we can + /// have multiple active imgui views in the same application. ig_context: ?*cimgui.c.ImGuiContext = null, - /// True if the the Dear ImGui OpenGL backend was initialized. - ig_gl_backend_initialized: bool = false, - /// Our previous instant used to calculate delta time for animations. instant: ?std.time.Instant = null, - /// This is called every frame to populate the Dear ImGui frame. - render_callback: ?RenderCallback = null, - render_userdata: ?RenderUserdata = null, - pub var offset: c_int = 0; }; @@ -64,21 +89,6 @@ pub const ImguiWidget = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); - - const priv = self.private(); - - priv.ig_context = ig_context: { - const ig_context = cimgui.c.igCreateContext(null) orelse { - log.warn("unable to initialize Dear ImGui context", .{}); - break :ig_context null; - }; - errdefer cimgui.c.igDestroyContext(ig_context); - cimgui.c.igSetCurrentContext(ig_context); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - io.BackendPlatformName = "ghostty_gtk"; - - break :ig_context ig_context; - }; } fn dispose(self: *Self) callconv(.c) void { @@ -93,49 +103,9 @@ pub const ImguiWidget = extern struct { ); } - fn finalize(self: *Self) callconv(.c) void { - const priv = self.private(); - - // If the the Dear ImGui OpenGL backend was never initialized then we - // need to destroy the Dear ImGui context manually here. If it _was_ - // initialized cleanup will be handled when the GLArea is unrealized. - if (!priv.ig_gl_backend_initialized) { - if (priv.ig_context) |ig_context| { - cimgui.c.igDestroyContext(ig_context); - priv.ig_context = null; - } - } - - gobject.Object.virtual_methods.finalize.call( - Class.parent, - self.as(Parent), - ); - } - //--------------------------------------------------------------- // Public methods - pub fn new() *Self { - return gobject.ext.newInstance(Self, .{}); - } - - /// Use to setup the Dear ImGui application. - pub fn setup(self: *Self, callback: *const fn () void) void { - self.setCurrentContext() catch return; - callback(); - } - - /// Set the callback used to render every frame. - pub fn setRenderCallback( - self: *Self, - callback: ?RenderCallback, - userdata: ?RenderUserdata, - ) void { - const priv = self.private(); - priv.render_callback = callback; - priv.render_userdata = userdata; - } - /// This should be called anytime the underlying data for the UI changes /// so that the UI can be refreshed. pub fn queueRender(self: *ImguiWidget) void { @@ -146,7 +116,9 @@ pub const ImguiWidget = extern struct { //--------------------------------------------------------------- // Private Methods - /// Set our imgui context to be current, or return an error. + /// Set our imgui context to be current, or return an error. This must be + /// called before any Dear ImGui API calls so that they're made against + /// the proper context. fn setCurrentContext(self: *Self) error{ContextNotInitialized}!void { const priv = self.private(); const ig_context = priv.ig_context orelse { @@ -238,24 +210,40 @@ pub const ImguiWidget = extern struct { fn glAreaRealize(_: *gtk.GLArea, self: *Self) callconv(.c) void { const priv = self.private(); + assert(priv.ig_context == null); priv.gl_area.makeCurrent(); if (priv.gl_area.getError()) |err| { - log.err("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); + log.warn("GLArea for Dear ImGui widget failed to realize: {s}", .{err.f_message orelse "(unknown)"}); return; } + priv.ig_context = cimgui.c.igCreateContext(null) orelse { + log.warn("unable to initialize Dear ImGui context", .{}); + return; + }; self.setCurrentContext() catch return; - // realize means that our OpenGL context is ready, so we can now + // Setup some basic config + const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); + io.BackendPlatformName = "ghostty_gtk"; + + // Realize means that our OpenGL context is ready, so we can now // initialize the ImgUI OpenGL backend for our context. _ = cimgui.ImGui_ImplOpenGL3_Init(null); - priv.ig_gl_backend_initialized = true; + // Setup our app + signals.setup.impl.emit( + self, + null, + .{}, + null, + ); } /// Handle a request to unrealize the GLArea fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { + assert(self.private().ig_context != null); self.setCurrentContext() catch return; cimgui.ImGui_ImplOpenGL3_Shutdown(); } @@ -292,8 +280,12 @@ pub const ImguiWidget = extern struct { cimgui.c.igNewFrame(); // Use the callback to draw the UI. - const priv = self.private(); - if (priv.render_callback) |cb| cb(priv.render_userdata); + signals.render.impl.emit( + self, + null, + .{}, + null, + ); // Render cimgui.c.igRender(); @@ -308,17 +300,17 @@ pub const ImguiWidget = extern struct { } fn ecFocusEnter(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { - self.queueRender(); self.setCurrentContext() catch return; const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); cimgui.c.ImGuiIO_AddFocusEvent(io, true); + self.queueRender(); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { - self.queueRender(); self.setCurrentContext() catch return; const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); cimgui.c.ImGuiIO_AddFocusEvent(io, false); + self.queueRender(); } fn ecKeyPressed( @@ -461,28 +453,22 @@ pub const ImguiWidget = extern struct { class.bindTemplateCallback("unrealize", &glAreaUnrealize); class.bindTemplateCallback("resize", &glAreaResize); class.bindTemplateCallback("render", &glAreaRender); - class.bindTemplateCallback("focus_enter", &ecFocusEnter); class.bindTemplateCallback("focus_leave", &ecFocusLeave); - class.bindTemplateCallback("key_pressed", &ecKeyPressed); class.bindTemplateCallback("key_released", &ecKeyReleased); - class.bindTemplateCallback("mouse_pressed", &ecMousePressed); class.bindTemplateCallback("mouse_released", &ecMouseReleased); class.bindTemplateCallback("mouse_motion", &ecMouseMotion); - class.bindTemplateCallback("scroll", &ecMouseScroll); - class.bindTemplateCallback("im_commit", &imCommit); - // Properties - // Signals + signals.render.impl.register(.{}); + signals.setup.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; diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig index b36d6b3c0..92a512712 100644 --- a/src/apprt/gtk-ng/class/inspector_widget.zig +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -63,10 +63,6 @@ pub const InspectorWidget = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); - - const priv = self.private(); - priv.imgui_widget.setup(Inspector.setup); - priv.imgui_widget.setRenderCallback(imguiRender, self); } fn dispose(self: *Self) callconv(.c) void { @@ -109,18 +105,6 @@ pub const InspectorWidget = extern struct { //--------------------------------------------------------------- // Private Methods - /// This is the callback from the embedded Dear ImGui widget that is called - /// to do the actual drawing. - fn imguiRender(ud: ?*anyopaque) void { - const self: *Self = @ptrCast(@alignCast(ud orelse return)); - const priv = self.private(); - const surface = priv.surface.get() orelse return; - defer surface.unref(); - const core_surface = surface.core() orelse return; - const inspector = core_surface.inspector orelse return; - inspector.render(); - } - //--------------------------------------------------------------- // Properties @@ -166,6 +150,25 @@ pub const InspectorWidget = extern struct { //--------------------------------------------------------------- // Signal Handlers + fn imguiRender( + _: *ImguiWidget, + self: *Self, + ) callconv(.c) void { + const priv = self.private(); + const surface = priv.surface.get() orelse return; + defer surface.unref(); + const core_surface = surface.core() orelse return; + const inspector = core_surface.inspector orelse return; + inspector.render(); + } + + fn imguiSetup( + _: *ImguiWidget, + _: *Self, + ) callconv(.c) void { + Inspector.setup(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -192,6 +195,10 @@ pub const InspectorWidget = extern struct { // Bindings class.bindTemplateChildPrivate("imgui_widget", .{}); + // Template callbacks + class.bindTemplateCallback("imgui_render", &imguiRender); + class.bindTemplateCallback("imgui_setup", &imguiSetup); + // Properties gobject.ext.registerProperties(class, &.{ properties.surface.impl, diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp index 0cbd45d8a..985a7ed23 100644 --- a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp +++ b/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp @@ -10,6 +10,9 @@ template $GhosttyInspectorWidget: Adw.Bin { vexpand: true; Adw.Bin { - $GhosttyImguiWidget imgui_widget {} + $GhosttyImguiWidget imgui_widget { + render => $imgui_render(); + setup => $imgui_setup(); + } } } From 48a65b05d0afca79d5bef11dadd4de1bd3792883 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 13 Aug 2025 12:52:32 -0700 Subject: [PATCH 27/53] apprt/gtk-ng: use a weak_ref on surface for inspector --- src/apprt/gtk-ng/class/inspector_widget.zig | 125 +++++++++++++------- 1 file changed, 84 insertions(+), 41 deletions(-) diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig index 92a512712..5032dd168 100644 --- a/src/apprt/gtk-ng/class/inspector_widget.zig +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -35,12 +35,10 @@ pub const InspectorWidget = extern struct { Self, ?*Surface, .{ - .accessor = gobject.ext.typedAccessor(Self, ?*Surface, .{ - .getter = getSurface, - .getter_transfer = .full, - .setter = setSurface, - .setter_transfer = .none, - }), + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, }, ); }; @@ -49,8 +47,9 @@ pub const InspectorWidget = extern struct { pub const signals = struct {}; const Private = struct { - /// The surface that we are attached to - surface: WeakRef(Surface) = .empty, + /// The surface that we are attached to. This is NOT referenced. + /// We attach a weak notify to the object. + surface: ?*Surface = null, /// The embedded Dear ImGui widget. imgui_widget: *ImguiWidget, @@ -66,15 +65,8 @@ pub const InspectorWidget = extern struct { } fn dispose(self: *Self) callconv(.c) void { - const priv = self.private(); - - deactivate: { - const surface = priv.surface.get() orelse break :deactivate; - defer surface.unref(); - - const core_surface = surface.core() orelse break :deactivate; - core_surface.deactivateInspector(); - } + // Clear our surface so it deactivates the inspector. + self.setSurface(null); gtk.Widget.disposeTemplate( self.as(gtk.Widget), @@ -108,41 +100,66 @@ pub const InspectorWidget = extern struct { //--------------------------------------------------------------- // Properties - fn getSurface(self: *Self) ?*Surface { - const priv = self.private(); - return priv.surface.get(); + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().surface, + ); } - fn setSurface(self: *Self, newvalue_: ?*Surface) void { + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + pub fn setSurface(self: *Self, surface_: ?*Surface) void { const priv = self.private(); - if (priv.surface.get()) |oldvalue| oldvalue: { - defer oldvalue.unref(); + // Do nothing if we're not changing the value. + if (surface_ == priv.surface) return; - // We don't need to do anything if we're just setting the same surface. - if (newvalue_) |newvalue| if (newvalue == oldvalue) return; + // Setup our notification to happen at the end because we're + // changing values no matter what. + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); - // Deactivate the inspector on the old surface. - const core_surface = oldvalue.core() orelse break :oldvalue; + // Deactivate the inspector on the old surface if it exists + // and set our value to null. + if (priv.surface) |old| old: { + priv.surface = null; + + // Remove our weak ref + old.as(gobject.Object).weakUnref( + surfaceWeakNotify, + self, + ); + + // Deactivate the inspector + const core_surface = old.core() orelse break :old; core_surface.deactivateInspector(); } - const newvalue = newvalue_ orelse { - priv.surface.set(null); - return; - }; - - const core_surface = newvalue.core() orelse { - priv.surface.set(null); - return; - }; - // Activate the inspector on the new surface. + const surface = surface_ orelse return; + const core_surface = surface.core() orelse return; core_surface.activateInspector() catch |err| { - log.err("failed to activate inspector err={}", .{err}); + log.warn("failed to activate inspector err={}", .{err}); + return; }; - priv.surface.set(newvalue); + // We use a weak reference on surface to determine if the surface + // was closed while our inspector was active. + surface.as(gobject.Object).weakRef( + surfaceWeakNotify, + self, + ); + + // Store our surface. We don't need to ref this because we setup + // the weak notify above. + priv.surface = surface; self.queueRender(); } @@ -150,13 +167,39 @@ pub const InspectorWidget = extern struct { //--------------------------------------------------------------- // Signal Handlers + fn surfaceWeakNotify( + ud: ?*anyopaque, + surface: *gobject.Object, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + + // The weak notify docs call out that we can specifically use the + // pointer values for comparison, but the objects themselves are unsafe. + if (@intFromPtr(priv.surface) != @intFromPtr(surface)) return; + + // Note: we very explicitly DO NOT want to call setSurface here + // because we can't safely use `surface` so we want to ensure we + // manually clear our ref and notify. + const old = priv.surface orelse return; + const core_surface = old.core() orelse return; + core_surface.deactivateInspector(); + priv.surface = null; + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + // Note: in the future we should probably show some content on our + // window to note that the surface went away in case our embedding + // widget doesn't close itself. As I type this, our window closes + // immediately when the surface goes away so you don't see this, but + // for completeness sake we should clean this up. + } + fn imguiRender( _: *ImguiWidget, self: *Self, ) callconv(.c) void { const priv = self.private(); - const surface = priv.surface.get() orelse return; - defer surface.unref(); + const surface = priv.surface orelse return; const core_surface = surface.core() orelse return; const inspector = core_surface.inspector orelse return; inspector.render(); From 3fc33089f30714b51eb717ae38b75d93a2c42f61 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 08:35:57 -0700 Subject: [PATCH 28/53] apprt/gtk-ng: clean up a bunch of unused window stuff --- src/apprt/gtk-ng/class/inspector_widget.zig | 16 ++---- src/apprt/gtk-ng/class/inspector_window.zig | 60 ++++++-------------- src/apprt/gtk-ng/class/surface.zig | 8 ++- src/apprt/gtk-ng/ui/1.5/inspector-window.blp | 1 - 4 files changed, 25 insertions(+), 60 deletions(-) diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig index 5032dd168..aae8fd4ac 100644 --- a/src/apprt/gtk-ng/class/inspector_widget.zig +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -82,21 +82,12 @@ pub const InspectorWidget = extern struct { //--------------------------------------------------------------- // Public methods - pub fn new(surface: *Surface) *Self { - return gobject.ext.newInstance(Self, .{ - .surface = surface, - }); - } - /// Queue a render of the Dear ImGui widget. pub fn queueRender(self: *Self) void { const priv = self.private(); priv.imgui_widget.queueRender(); } - //--------------------------------------------------------------- - // Private Methods - //--------------------------------------------------------------- // Properties @@ -178,9 +169,10 @@ pub const InspectorWidget = extern struct { // pointer values for comparison, but the objects themselves are unsafe. if (@intFromPtr(priv.surface) != @intFromPtr(surface)) return; - // Note: we very explicitly DO NOT want to call setSurface here - // because we can't safely use `surface` so we want to ensure we - // manually clear our ref and notify. + // According to weak notify docs, "surface" is in the "dispose" state. + // Our surface doesn't clear the core surface until the "finalize" + // state so we should be able to safely access it here. We need to + // be really careful though. const old = priv.surface orelse return; const core_surface = old.core() orelse return; core_surface.deactivateInspector(); diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig index 7f5c8fe10..c75c6fecb 100644 --- a/src/apprt/gtk-ng/class/inspector_window.zig +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -39,12 +39,10 @@ pub const InspectorWindow = extern struct { Self, ?*Surface, .{ - .accessor = gobject.ext.typedAccessor(Self, ?*Surface, .{ - .getter = getSurface, - .getter_transfer = .full, - .setter = setSurface, - .setter_transfer = .none, - }), + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, }, ); }; @@ -132,48 +130,27 @@ pub const InspectorWindow = extern struct { priv.inspector_widget.queueRender(); } - /// The surface we are connected to is going away, shut ourselves down. - pub fn shutdown(self: *Self) void { - const priv = self.private(); - priv.surface.set(null); - self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); - self.as(gtk.Window).close(); - } - - //--------------------------------------------------------------- - // Private Methods - - fn isFullscreen(self: *Self) bool { - return self.as(gtk.Window).isFullscreen() != 0; - } - - fn isMaximized(self: *Self) bool { - return self.as(gtk.Window).isMaximized() != 0; - } - //--------------------------------------------------------------- // Properties - fn getSurface(self: *Self) ?*Surface { - const priv = self.private(); - return priv.surface.get(); - } - fn setSurface(self: *Self, newvalue: ?*Surface) void { const priv = self.private(); priv.surface.set(newvalue); } - //--------------------------------------------------------------- - // Signal Handlers + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + // Important: get() refs, so we take to not increase ref twice + gobject.ext.Value.take( + value, + self.private().surface.get(), + ); + } - /// The user has clicked on the close button. - fn closeRequest(_: *gtk.Window, self: *Self) callconv(.c) c_int { - const priv = self.private(); - priv.surface.set(null); - self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); - self.as(gtk.Window).destroy(); - return @intFromBool(false); + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); } const C = Common(Self, Private); @@ -203,17 +180,12 @@ pub const InspectorWindow = extern struct { // Template Bindings class.bindTemplateChildPrivate("inspector_widget", .{}); - // Template Callbacks - class.bindTemplateCallback("close_request", &closeRequest); - // Properties gobject.ext.registerProperties(class, &.{ properties.surface.impl, properties.debug.impl, }); - // Signals - // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); } diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 65b128bdf..5a3107000 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -582,7 +582,6 @@ pub const Surface = extern struct { const priv = self.private(); if (priv.inspector.get()) |inspector| { defer inspector.unref(); - inspector.shutdown(); priv.inspector.set(null); return true; } @@ -609,7 +608,6 @@ pub const Surface = extern struct { const priv = self.private(); if (priv.inspector.get()) |inspector| { defer inspector.unref(); - inspector.shutdown(); priv.inspector.set(null); } return true; @@ -622,6 +620,7 @@ pub const Surface = extern struct { .hide => return self.hideInspector(), } } + /// Redraw our inspector, if there is one associated with this surface. pub fn redrawInspector(self: *Self) void { const priv = self.private(); @@ -1359,7 +1358,6 @@ pub const Surface = extern struct { if (priv.inspector.get()) |inspector| { defer inspector.unref(); - inspector.shutdown(); } gtk.Widget.disposeTemplate( @@ -1381,6 +1379,10 @@ pub const Surface = extern struct { // searching for this surface. Application.default().core().deleteSurface(self.rt()); + // NOTE: We must deinit the surface in the finalize call and NOT + // the dispose call because the inspector widget relies on this + // behavior with a weakRef to properly deactivate. + // Deinit the surface v.deinit(); const alloc = Application.default().allocator(); diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp index a67e26622..2457450ee 100644 --- a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp +++ b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp @@ -6,7 +6,6 @@ template $GhosttyInspectorWindow: Adw.ApplicationWindow { icon-name: "com.mitchellh.ghostty"; default-width: 1000; default-height: 600; - close-request => $close_request(); styles [ "inspector", From 6280bd7a4267e7d254381fc7a1d0b11fee940f90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 08:57:06 -0700 Subject: [PATCH 29/53] apprt/gtk-ng: far less control inspector complexity --- src/apprt/gtk-ng/class/inspector_widget.zig | 1 - src/apprt/gtk-ng/class/inspector_window.zig | 5 +- src/apprt/gtk-ng/class/surface.zig | 74 ++++++++------------- src/apprt/gtk-ng/class/window.zig | 2 +- 4 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig index aae8fd4ac..b27aaf3ed 100644 --- a/src/apprt/gtk-ng/class/inspector_widget.zig +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -8,7 +8,6 @@ const gresource = @import("../build/gresource.zig"); const Inspector = @import("../../../inspector/Inspector.zig"); const Common = @import("../class.zig").Common; -const WeakRef = @import("../weak_ref.zig").WeakRef; const Surface = @import("surface.zig").Surface; const ImguiWidget = @import("imgui_widget.zig").ImguiWidget; diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig index c75c6fecb..f848d327b 100644 --- a/src/apprt/gtk-ng/class/inspector_window.zig +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -111,12 +111,9 @@ pub const InspectorWindow = extern struct { // Public methods pub fn new(surface: *Surface) *Self { - const self = gobject.ext.newInstance(Self, .{ + return gobject.ext.newInstance(Self, .{ .surface = surface, }); - - // Bump the ref so that we aren't immediately closed. - return self.ref(); } /// Present the window. diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 5a3107000..4b2d972bb 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -473,7 +473,7 @@ pub const Surface = extern struct { bell_ringing: bool = false, /// A weak reference to an inspector window. - inspector: WeakRef(InspectorWindow) = .empty, + inspector: ?*InspectorWindow = null, // Template binds child_exited_overlay: *ChildExited, @@ -578,55 +578,34 @@ pub const Surface = extern struct { return self.as(gtk.Widget).activateAction("win.toggle-command-palette", null) != 0; } - pub fn toggleInspector(self: *Self) bool { + pub fn controlInspector( + self: *Self, + value: apprt.Action.Value(.inspector), + ) bool { + // Let's see if we have an inspector already. const priv = self.private(); - if (priv.inspector.get()) |inspector| { - defer inspector.unref(); - priv.inspector.set(null); - return true; - } - const inspector = InspectorWindow.new(self); - defer inspector.unref(); - priv.inspector.set(inspector); - inspector.present(); - return true; - } + if (priv.inspector) |inspector| switch (value) { + .show => {}, + // Our weak ref will set our private value to null + .toggle, .hide => inspector.as(gtk.Window).destroy(), + } else switch (value) { + .toggle, .show => { + const inspector = InspectorWindow.new(self); + inspector.present(); + inspector.as(gobject.Object).weakRef(inspectorWeakNotify, self); + priv.inspector = inspector; + }, - pub fn showInspector(self: *Self) bool { - const priv = self.private(); - const inspector = priv.inspector.get() orelse inspector: { - const inspector = InspectorWindow.new(self); - priv.inspector.set(inspector); - break :inspector inspector; - }; - defer inspector.unref(); - inspector.present(); - return true; - } - - pub fn hideInspector(self: *Self) bool { - const priv = self.private(); - if (priv.inspector.get()) |inspector| { - defer inspector.unref(); - priv.inspector.set(null); + .hide => {}, } - return true; - } - pub fn controlInspector(self: *Self, value: apprt.Action.Value(.inspector)) bool { - switch (value) { - .toggle => return self.toggleInspector(), - .show => return self.showInspector(), - .hide => return self.hideInspector(), - } + return true; } /// Redraw our inspector, if there is one associated with this surface. pub fn redrawInspector(self: *Self) void { const priv = self.private(); - const inspector = priv.inspector.get() orelse return; - defer inspector.unref(); - inspector.queueRender(); + if (priv.inspector) |v| v.queueRender(); } pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { @@ -1356,10 +1335,6 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } - if (priv.inspector.get()) |inspector| { - defer inspector.unref(); - } - gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -1787,6 +1762,15 @@ pub const Surface = extern struct { self.grabFocus(); } + fn inspectorWeakNotify( + ud: ?*anyopaque, + _: *gobject.Object, + ) callconv(.c) void { + const self: *Self = @ptrCast(@alignCast(ud orelse return)); + const priv = self.private(); + priv.inspector = null; + } + fn dtDrop( _: *gtk.DropTarget, value: *gobject.Value, diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index da4e9574f..64aff1f9a 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -1825,7 +1825,7 @@ pub const Window = extern struct { /// Toggle the Ghostty inspector for the active surface. fn toggleInspector(self: *Self) void { const surface = self.getActiveSurface() orelse return; - _ = surface.toggleInspector(); + _ = surface.controlInspector(.toggle); } /// React to a GTK action requesting that the Ghostty inspector be toggled. From 76d84ff35c95b5ade562711674d4258479d5d10b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 09:18:50 -0700 Subject: [PATCH 30/53] valgrind supps --- valgrind.supp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/valgrind.supp b/valgrind.supp index cf82b7c2a..162f3393a 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -529,6 +529,17 @@ ... } +{ + pango fontset + Memcheck:Leak + match-leak-kinds: possible + fun:*alloc + ... + fun:FcFontRenderPrepare + fun:pango_fc_fontset_get_font_at + ... +} + { pango and fontconfig Memcheck:Leak From 7548dcfe634cd9447e0b7a0f5e2900fe7094a225 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 09:29:00 -0700 Subject: [PATCH 31/53] apprt/gtk-ng: clear weakrefs on dispose --- src/apprt/gtk-ng/class/inspector_window.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig index f848d327b..01e4caa49 100644 --- a/src/apprt/gtk-ng/class/inspector_window.zig +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -96,6 +96,13 @@ pub const InspectorWindow = extern struct { } fn dispose(self: *Self) callconv(.c) void { + // You MUST clear all weak refs in dispose, otherwise it causes + // memory corruption on dispose on the TARGET (weak referenced) + // object. The only way we caught this is via Valgrind. Its not a leak, + // its an invalid memory read. In practice, I found this sometimes + // caused hanging! + self.setSurface(null); + gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), From 68f337e39882c8e27c1b7b48639b66df5de29d1c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 09:42:21 -0700 Subject: [PATCH 32/53] apprt/gtk-ng: close inspector window when widget loses surface --- src/apprt/gtk-ng/class/inspector_widget.zig | 4 ++++ src/apprt/gtk-ng/class/inspector_window.zig | 19 +++++++++++++++++++ src/apprt/gtk-ng/ui/1.5/inspector-window.blp | 1 + 3 files changed, 24 insertions(+) diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk-ng/class/inspector_widget.zig index b27aaf3ed..f71970a88 100644 --- a/src/apprt/gtk-ng/class/inspector_widget.zig +++ b/src/apprt/gtk-ng/class/inspector_widget.zig @@ -104,6 +104,10 @@ pub const InspectorWidget = extern struct { )); } + pub fn getSurface(self: *Self) ?*Surface { + return self.private().surface; + } + pub fn setSurface(self: *Self, surface_: ?*Surface) void { const priv = self.private(); diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk-ng/class/inspector_window.zig index 01e4caa49..701718229 100644 --- a/src/apprt/gtk-ng/class/inspector_window.zig +++ b/src/apprt/gtk-ng/class/inspector_window.zig @@ -157,6 +157,22 @@ pub const InspectorWindow = extern struct { )); } + //--------------------------------------------------------------- + // Signal Handlers + + fn propInspectorSurface( + inspector: *InspectorWidget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // If the inspector's surface went away, we destroy the window. + // The inspector has a weak notify on the surface so it knows + // if it goes nil. + if (inspector.getSurface() == null) { + self.as(gtk.Window).destroy(); + } + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -184,6 +200,9 @@ pub const InspectorWindow = extern struct { // Template Bindings class.bindTemplateChildPrivate("inspector_widget", .{}); + // Template callbacks + class.bindTemplateCallback("notify_inspector_surface", &propInspectorSurface); + // Properties gobject.ext.registerProperties(class, &.{ properties.surface.impl, diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp index 2457450ee..a7625bc2c 100644 --- a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp +++ b/src/apprt/gtk-ng/ui/1.5/inspector-window.blp @@ -30,6 +30,7 @@ template $GhosttyInspectorWindow: Adw.ApplicationWindow { } $GhosttyInspectorWidget inspector_widget { + notify::surface => $notify_inspector_surface(); surface: bind template.surface; } } From 83d1bdcfcba9c32d7f56daed530b1faa3f1068e3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 10:01:00 -0700 Subject: [PATCH 33/53] apprt/gtk-ng: clean up close handling of all types This cleans up our close handling of all types (surfaces, tabs, windows). Surfaces no longer emit their scope; their scope is always just the surface itself. For tab and window scope we use widget actions. This makes `close_tab` work properly (previously broken). --- src/apprt/gtk-ng/Surface.zig | 3 ++- src/apprt/gtk-ng/class/application.zig | 26 +++++++++++++++++-------- src/apprt/gtk-ng/class/split_tree.zig | 26 +++++++++++++++---------- src/apprt/gtk-ng/class/surface.zig | 27 ++++---------------------- src/apprt/gtk-ng/class/tab.zig | 22 ++++++++++++++++++--- src/apprt/gtk-ng/class/window.zig | 26 ------------------------- 6 files changed, 59 insertions(+), 71 deletions(-) diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk-ng/Surface.zig index 1614d74d0..ac82f941b 100644 --- a/src/apprt/gtk-ng/Surface.zig +++ b/src/apprt/gtk-ng/Surface.zig @@ -32,7 +32,8 @@ pub fn rtApp(self: *Self) *ApprtApp { } pub fn close(self: *Self, process_active: bool) void { - self.surface.close(.{ .surface = process_active }); + _ = process_active; + self.surface.close(); } pub fn cgroup(self: *Self) ?[]const u8 { diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 1fa61f791..511c1aa85 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -542,8 +542,8 @@ pub const Application = extern struct { value: apprt.Action.Value(action), ) !bool { switch (action) { - .close_tab => Action.close(target, .tab), - .close_window => Action.close(target, .window), + .close_tab => return Action.closeTab(target), + .close_window => return Action.closeWindow(target), .config_change => try Action.configChange( self, @@ -1582,13 +1582,23 @@ pub const Application = extern struct { /// All apprt action handlers const Action = struct { - pub fn close( - target: apprt.Target, - scope: Surface.CloseScope, - ) void { + pub fn closeTab(target: apprt.Target) bool { switch (target) { - .app => {}, - .surface => |v| v.rt_surface.surface.close(scope), + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("tab.close", null) != 0; + }, + } + } + + pub fn closeWindow(target: apprt.Target) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + return surface.as(gtk.Widget).activateAction("win.close", null) != 0; + }, } } diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index a5e823158..3e06bf983 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -407,6 +407,22 @@ pub const SplitTree = extern struct { //--------------------------------------------------------------- // Properties + /// Returns true if this split tree needs confirmation before quitting based + /// on the various Ghostty configurations. + pub fn getNeedsConfirmQuit(self: *Self) bool { + const tree = self.getTree() orelse return false; + var it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view.core()) |core| { + if (core.needsConfirmQuit()) { + return true; + } + } + } + + return false; + } + /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. pub fn getActiveSurface(self: *Self) ?*Surface { @@ -636,18 +652,8 @@ pub const SplitTree = extern struct { 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 diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 4b2d972bb..b8db91a2b 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -275,7 +275,7 @@ pub const Surface = extern struct { const impl = gobject.ext.defineSignal( name, Self, - &.{*const CloseScope}, + &.{}, void, ); }; @@ -1047,11 +1047,11 @@ pub const Surface = extern struct { //--------------------------------------------------------------- // Libghostty Callbacks - pub fn close(self: *Self, scope: CloseScope) void { + pub fn close(self: *Self) void { signals.@"close-request".impl.emit( self, null, - .{&scope}, + .{}, null, ); } @@ -1749,7 +1749,7 @@ pub const Surface = extern struct { self: *Self, ) callconv(.c) void { // This closes the surface with no confirmation. - self.close(.{ .surface = false }); + self.close(); } fn contextMenuClosed( @@ -2709,25 +2709,6 @@ pub const Surface = extern struct { pub const bindTemplateCallback = C.Class.bindTemplateCallback; }; - /// The scope of a close request. - pub const CloseScope = union(enum) { - /// Close the surface. The boolean determines if there is a - /// process active. - surface: bool, - - /// Close the tab. We can't know if there are processes active - /// for the entire tab scope so listeners must query the app. - tab, - - /// Close the window. - window, - - pub const getGObjectType = gobject.ext.defineBoxed( - CloseScope, - .{ .name = "GhosttySurfaceCloseScope" }, - ); - }; - /// Simple dimensions struct for the surface used by various properties. pub const Size = extern struct { width: u32, diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 520050cb6..b24123fd8 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -208,6 +208,7 @@ pub const Tab = extern struct { // For action names: // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html const actions = .{ + .{ "close", actionClose, null }, .{ "ring-bell", actionRingBell, null }, }; @@ -262,9 +263,8 @@ pub const Tab = extern struct { /// 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() orelse return false; - const core_surface = surface.core() orelse return false; - return core_surface.needsConfirmQuit(); + const tree = self.getSplitTree(); + return tree.getNeedsConfirmQuit(); } /// Get the tab page holding this tab, if any. @@ -344,6 +344,22 @@ pub const Tab = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } + fn actionClose( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const tab_view = ext.getAncestor( + adw.TabView, + self.as(gtk.Widget), + ) orelse return; + const page = tab_view.getPage(self.as(gtk.Widget)); + + // Delegate to our parent to handle this, since this will emit + // a close-page signal that the parent can intercept. + tab_view.closePage(page); + } + fn actionRingBell( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 64aff1f9a..07801089e 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -683,13 +683,6 @@ pub const Window = extern struct { 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, @@ -1458,25 +1451,6 @@ pub const Window = extern struct { self.addToast(i18n._("Cleared clipboard")); } - fn surfaceCloseRequest( - _: *Surface, - scope: *const Surface.CloseScope, - self: *Self, - ) callconv(.c) void { - switch (scope.*) { - // Handled directly by the tab. If the surface is the last - // surface then the tab will emit its own signal to request - // closing itself. - .surface => return, - - // Also handled directly by the tab. - .tab => return, - - // The only one we care about! - .window => self.as(gtk.Window).close(), - } - } - fn surfaceMenu( _: *Surface, self: *Self, From 96e252872fcd4bed4de632e168034b71985709ad Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 18:43:55 -0500 Subject: [PATCH 34/53] gtk-ng: add a helper for creating GTK actions - Reduces boilerplate. - Adds type safety. - Adds comptime checks for action and group names which otherwise could cause panics at runtime. --- src/apprt/gtk-ng.zig | 1 + src/apprt/gtk-ng/class/surface.zig | 19 +--- src/apprt/gtk-ng/ext.zig | 157 +++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 16 deletions(-) diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk-ng.zig index de9255fe9..fe1bac023 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk-ng.zig @@ -11,4 +11,5 @@ pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; test { @import("std").testing.refAllDecls(@This()); + _ = @import("gtk-ng/ext.zig"); } diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index b8db91a2b..86e57dd79 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1780,10 +1780,7 @@ pub const Surface = extern struct { ) callconv(.c) c_int { const alloc = Application.default().allocator(); - if (g_value_holds( - value, - gdk.FileList.getGObjectType(), - )) { + if (ext.gValueHolds(value, gdk.FileList.getGObjectType())) { var data = std.ArrayList(u8).init(alloc); defer data.deinit(); @@ -1827,7 +1824,7 @@ pub const Surface = extern struct { return 1; } - if (g_value_holds(value, gio.File.getGObjectType())) { + if (ext.gValueHolds(value, gio.File.getGObjectType())) { const object = value.getObject() orelse return 0; const file = gobject.ext.cast(gio.File, object) orelse return 0; const path = file.getPath() orelse return 0; @@ -1855,7 +1852,7 @@ pub const Surface = extern struct { return 1; } - if (g_value_holds(value, gobject.ext.types.string)) { + if (ext.gValueHolds(value, gobject.ext.types.string)) { if (value.getString()) |string| { Clipboard.paste(self, std.mem.span(string)); } @@ -3039,16 +3036,6 @@ const Clipboard = struct { }; }; -/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's -/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it. -fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool { - if (value_) |value| { - if (value.f_g_type == g_type) return true; - return gobject.typeCheckValueHolds(value, g_type) != 0; - } - return false; -} - /// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped /// to [0, 100]. fn computeFraction(progress: u8) f64 { diff --git a/src/apprt/gtk-ng/ext.zig b/src/apprt/gtk-ng/ext.zig index 3e80a9998..75188535a 100644 --- a/src/apprt/gtk-ng/ext.zig +++ b/src/apprt/gtk-ng/ext.zig @@ -5,7 +5,9 @@ const std = @import("std"); const assert = std.debug.assert; +const testing = std.testing; +const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); @@ -50,3 +52,158 @@ pub fn getAncestor(comptime T: type, widget: *gtk.Widget) ?*T { // We can assert the unwrap because getAncestor above return gobject.ext.cast(T, ancestor).?; } + +/// Check a gobject.Value to see what type it is wrapping. This is equivalent to GTK's +/// `G_VALUE_HOLDS()` macro but Zig's C translator does not like it. +pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool { + const value = value_ orelse return false; + if (value.f_g_type == g_type) return true; + return gobject.typeCheckValueHolds(value, g_type) != 0; +} + +/// Check that an action name is valid. +/// +/// Reimplementation of `g_action_name_is_valid()` so that it can be +/// used at comptime. +/// +/// See: +/// https://docs.gtk.org/gio/type_func.Action.name_is_valid.html +fn gActionNameIsValid(name: [:0]const u8) bool { + if (name.len == 0) return false; + + for (name) |c| switch (c) { + '-' => continue, + '.' => continue, + '0'...'9' => continue, + 'a'...'z' => continue, + 'A'...'Z' => continue, + else => return false, + }; + + return true; +} + +test "gActionNameIsValid" { + try testing.expect(gActionNameIsValid("ring-bell")); + try testing.expect(!gActionNameIsValid("ring_bell")); +} + +/// Function to create a structure for describing an action. +pub fn Action(comptime T: type) type { + return struct { + pub const Callback = *const fn (*gio.SimpleAction, ?*glib.Variant, *T) callconv(.c) void; + + name: [:0]const u8, + callback: Callback, + parameter_type: ?*const glib.VariantType, + + /// Function to initialize a new action so that we can comptime check the name. + pub fn init(comptime name: [:0]const u8, callback: Callback, parameter_type: ?*const glib.VariantType) @This() { + comptime assert(gActionNameIsValid(name)); + + return .{ + .name = name, + .callback = callback, + .parameter_type = parameter_type, + }; + } + }; +} + +/// Add actions to a widget that implements gio.ActionMap. +pub fn addActions(comptime T: type, self: *T, actions: []const Action(T)) void { + addActionsToMap(T, self, self.as(gio.ActionMap), actions); +} + +/// Add actions to the given map. +pub fn addActionsToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []const Action(T)) void { + for (actions) |entry| { + assert(gActionNameIsValid(entry.name)); + const action = gio.SimpleAction.new( + entry.name, + entry.parameter_type, + ); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *T, + entry.callback, + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } +} + +/// Add actions to a widget that doesn't implement ActionGroup directly. +pub fn addActionsAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) void { + comptime assert(gActionNameIsValid(name)); + + // 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(); + + addActionsToMap(T, self, group.as(gio.ActionMap), actions); + + self.as(gtk.Widget).insertActionGroup( + name, + group.as(gio.ActionGroup), + ); +} + +test "adding actions to an object" { + // This test requires a connection to an active display environment. + if (gtk.initCheck() == 0) return; + + const callbacks = struct { + fn callback(_: *gio.SimpleAction, variant_: ?*glib.Variant, self: *gtk.Box) callconv(.c) void { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const variant = variant_ orelse return; + assert(variant.isOfType(i32_variant_type) != 0); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + value.setInt(variant.getInt32()); + + self.as(gobject.Object).setProperty("spacing", &value); + } + }; + + const box = gtk.Box.new(.vertical, 0); + _ = box.as(gobject.Object).refSink(); + defer box.unref(); + + { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const actions = [_]Action(gtk.Box){ + .init("test", callbacks.callback, i32_variant_type), + }; + + addActionsAsGroup(gtk.Box, box, "test", &actions); + } + + const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31)); + const parameter = glib.Variant.newInt32(expected); + + try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0); + + _ = glib.MainContext.iteration(null, @intFromBool(true)); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + box.as(gobject.Object).getProperty("spacing", &value); + + try testing.expect(gValueHolds(&value, gobject.ext.types.int)); + + const actual = value.getInt(); + try testing.expectEqual(expected, actual); +} From a10b95f0527753c58e8dfced3682326e6796359d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 18:48:08 -0500 Subject: [PATCH 35/53] gtk-ng: use action helper in application --- src/apprt/gtk-ng/class/application.zig | 38 ++++++-------------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 511c1aa85..1515aa29f 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1112,38 +1112,16 @@ pub const Application = extern struct { const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); - // 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 = .{ - .{ "new-window", actionNewWindow, null }, - .{ "new-window-command", actionNewWindow, as_variant_type }, - .{ "open-config", actionOpenConfig, null }, - .{ "present-surface", actionPresentSurface, t_variant_type }, - .{ "quit", actionQuit, null }, - .{ "reload-config", actionReloadConfig, null }, + const actions = [_]ext.Action(Self){ + .init("new-window", actionNewWindow, null), + .init("new-window-command", actionNewWindow, as_variant_type), + .init("open-config", actionOpenConfig, null), + .init("present-surface", actionPresentSurface, t_variant_type), + .init("quit", actionQuit, null), + .init("reload-config", actionReloadConfig, null), }; - const action_map = self.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, - .{}, - ); - action_map.addAction(action.as(gio.Action)); - } + ext.addActions(Self, self, &actions); } /// Setup our global shortcuts. From d66212dcce91a39bfbce909a714ec463a7657f9a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 18:48:23 -0500 Subject: [PATCH 36/53] gtk-ng: use action helper in window --- src/apprt/gtk-ng/class/window.zig | 51 +++++++++++-------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 07801089e..3758d859f 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -331,42 +331,27 @@ pub const Window = extern struct { /// Setup our action map. fn initActionMap(self: *Self) void { - const actions = .{ - .{ "about", actionAbout, null }, - .{ "close", actionClose, null }, - .{ "close-tab", actionCloseTab, null }, - .{ "new-tab", actionNewTab, null }, - .{ "new-window", actionNewWindow, null }, - .{ "ring-bell", actionRingBell, 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 }, + const actions = [_]ext.Action(Self){ + .init("about", actionAbout, null), + .init("close", actionClose, null), + .init("close-tab", actionCloseTab, null), + .init("new-tab", actionNewTab, null), + .init("new-window", actionNewWindow, null), + .init("ring-bell", actionRingBell, null), + .init("split-right", actionSplitRight, null), + .init("split-left", actionSplitLeft, null), + .init("split-up", actionSplitUp, null), + .init("split-down", actionSplitDown, null), + .init("copy", actionCopy, null), + .init("paste", actionPaste, null), + .init("reset", actionReset, null), + .init("clear", actionClear, null), // TODO: accept the surface that toggled the command palette - .{ "toggle-command-palette", actionToggleCommandPalette, null }, - .{ "toggle-inspector", actionToggleInspector, null }, + .init("toggle-command-palette", actionToggleCommandPalette, null), + .init("toggle-inspector", actionToggleInspector, null), }; - const action_map = self.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, - .{}, - ); - action_map.addAction(action.as(gio.Action)); - } + ext.addActions(Self, self, &actions); } /// Winproto backend for this window. From 31c71c6c5a33850f323af5916754085e7d33b87f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 18:48:37 -0500 Subject: [PATCH 37/53] gtk-ng: use action helper in tab --- src/apprt/gtk-ng/class/tab.zig | 44 +++++----------------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index b24123fd8..fad4b52e1 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -177,7 +177,7 @@ pub const Tab = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Init our actions - self.initActions(); + self.initActionMap(); // If our configuration is null then we get the configuration // from the application. @@ -198,45 +198,13 @@ pub const Tab = extern struct { }; } - /// Setup our action map. - 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 = .{ - .{ "close", actionClose, null }, - .{ "ring-bell", actionRingBell, null }, + fn initActionMap(self: *Self) void { + const actions = [_]ext.Action(Self){ + .init("close", actionClose, null), + .init("ring-bell", actionRingBell, 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( - "tab", - group.as(gio.ActionGroup), - ); + ext.addActionsAsGroup(Self, self, "tab", &actions); } //--------------------------------------------------------------- From 6b690e6b4e0440326123db24eabfe50ccd0fdcdf Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 18:48:52 -0500 Subject: [PATCH 38/53] gtk-ng: use action helper in split-tree --- src/apprt/gtk-ng/class/split_tree.zig | 56 +++++---------------------- 1 file changed, 10 insertions(+), 46 deletions(-) diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 3e06bf983..997945f18 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -160,62 +160,26 @@ pub const SplitTree = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Initialize our actions - self.initActions(); + self.initActionMap(); // 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: []const struct { - [:0]const u8, - *const fn (*gio.SimpleAction, ?*glib.Variant, *Self) callconv(.c) void, - ?*glib.VariantType, - } = &.{ + fn initActionMap(self: *Self) void { + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + const actions = [_]ext.Action(Self){ // All of these will eventually take a target surface parameter. // For now all our targets originate from the focused surface. - .{ "new-split", &actionNewSplit, glib.ext.VariantType.newFor([:0]const u8) }, - .{ "equalize", &actionEqualize, null }, - .{ "zoom", &actionZoom, null }, + .init("new-split", actionNewSplit, s_variant_type), + .init("equalize", actionEqualize, null), + .init("zoom", actionZoom, 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); - for (actions) |entry| { - const action = gio.SimpleAction.new( - entry[0], - entry[2], - ); - defer { - action.unref(); - if (entry[2]) |ptype| ptype.free(); - } - - _ = 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), - ); + ext.addActionsAsGroup(Self, self, "split-tree", &actions); } /// Create a new split in the given direction from the currently From 0e3ec24d2cc3add0c9b1ffe2cb2a8983b5accbe5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 13 Aug 2025 18:49:05 -0500 Subject: [PATCH 39/53] gtk-ng: use action helper in surface --- src/apprt/gtk-ng/class/surface.zig | 41 ++++-------------------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 86e57dd79..46f0a19c3 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1233,7 +1233,7 @@ pub const Surface = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); // Initialize our actions - self.initActions(); + self.initActionMap(); const priv = self.private(); @@ -1281,43 +1281,12 @@ pub const Surface = extern struct { self.propConfig(undefined, 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 = .{ - .{ "prompt-title", actionPromptTitle, null }, + fn initActionMap(self: *Self) void { + const actions = [_]ext.Action(Self){ + .init("prompt-title", actionPromptTitle, 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( - "surface", - group.as(gio.ActionGroup), - ); + ext.addActionsAsGroup(Self, self, "surface", &actions); } fn dispose(self: *Self) callconv(.c) void { From d251695fa2cc51747735cc99b91935956d6b43b5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 14 Aug 2025 10:32:45 -0500 Subject: [PATCH 40/53] gtk-ng: move actions helper to namespace --- src/apprt/gtk-ng/class/application.zig | 4 +- src/apprt/gtk-ng/class/split_tree.zig | 4 +- src/apprt/gtk-ng/class/surface.zig | 4 +- src/apprt/gtk-ng/class/tab.zig | 4 +- src/apprt/gtk-ng/class/window.zig | 4 +- src/apprt/gtk-ng/ext.zig | 149 +---------------------- src/apprt/gtk-ng/ext/actions.zig | 158 +++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 155 deletions(-) create mode 100644 src/apprt/gtk-ng/ext/actions.zig diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 1515aa29f..2278a0f4c 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1112,7 +1112,7 @@ pub const Application = extern struct { const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); - const actions = [_]ext.Action(Self){ + const actions = [_]ext.actions.Action(Self){ .init("new-window", actionNewWindow, null), .init("new-window-command", actionNewWindow, as_variant_type), .init("open-config", actionOpenConfig, null), @@ -1121,7 +1121,7 @@ pub const Application = extern struct { .init("reload-config", actionReloadConfig, null), }; - ext.addActions(Self, self, &actions); + ext.actions.add(Self, self, &actions); } /// Setup our global shortcuts. diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 997945f18..3b6dcb4a9 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -171,7 +171,7 @@ pub const SplitTree = extern struct { const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); defer s_variant_type.free(); - const actions = [_]ext.Action(Self){ + const actions = [_]ext.actions.Action(Self){ // All of these will eventually take a target surface parameter. // For now all our targets originate from the focused surface. .init("new-split", actionNewSplit, s_variant_type), @@ -179,7 +179,7 @@ pub const SplitTree = extern struct { .init("zoom", actionZoom, null), }; - ext.addActionsAsGroup(Self, self, "split-tree", &actions); + ext.actions.addAsGroup(Self, self, "split-tree", &actions); } /// Create a new split in the given direction from the currently diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 46f0a19c3..9fa82f4ee 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -1282,11 +1282,11 @@ pub const Surface = extern struct { } fn initActionMap(self: *Self) void { - const actions = [_]ext.Action(Self){ + const actions = [_]ext.actions.Action(Self){ .init("prompt-title", actionPromptTitle, null), }; - ext.addActionsAsGroup(Self, self, "surface", &actions); + ext.actions.addAsGroup(Self, self, "surface", &actions); } fn dispose(self: *Self) callconv(.c) void { diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index fad4b52e1..5f1cf50de 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -199,12 +199,12 @@ pub const Tab = extern struct { } fn initActionMap(self: *Self) void { - const actions = [_]ext.Action(Self){ + const actions = [_]ext.actions.Action(Self){ .init("close", actionClose, null), .init("ring-bell", actionRingBell, null), }; - ext.addActionsAsGroup(Self, self, "tab", &actions); + ext.actions.addAsGroup(Self, self, "tab", &actions); } //--------------------------------------------------------------- diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 3758d859f..acba271c1 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -331,7 +331,7 @@ pub const Window = extern struct { /// Setup our action map. fn initActionMap(self: *Self) void { - const actions = [_]ext.Action(Self){ + const actions = [_]ext.actions.Action(Self){ .init("about", actionAbout, null), .init("close", actionClose, null), .init("close-tab", actionCloseTab, null), @@ -351,7 +351,7 @@ pub const Window = extern struct { .init("toggle-inspector", actionToggleInspector, null), }; - ext.addActions(Self, self, &actions); + ext.actions.add(Self, self, &actions); } /// Winproto backend for this window. diff --git a/src/apprt/gtk-ng/ext.zig b/src/apprt/gtk-ng/ext.zig index 75188535a..18587d9ca 100644 --- a/src/apprt/gtk-ng/ext.zig +++ b/src/apprt/gtk-ng/ext.zig @@ -12,6 +12,8 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +pub const actions = @import("ext/actions.zig"); + /// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`. pub fn boxedCopy(comptime T: type, ptr: *const T) *T { const copy = gobject.boxedCopy(T.getGObjectType(), ptr); @@ -61,149 +63,6 @@ pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool { return gobject.typeCheckValueHolds(value, g_type) != 0; } -/// Check that an action name is valid. -/// -/// Reimplementation of `g_action_name_is_valid()` so that it can be -/// used at comptime. -/// -/// See: -/// https://docs.gtk.org/gio/type_func.Action.name_is_valid.html -fn gActionNameIsValid(name: [:0]const u8) bool { - if (name.len == 0) return false; - - for (name) |c| switch (c) { - '-' => continue, - '.' => continue, - '0'...'9' => continue, - 'a'...'z' => continue, - 'A'...'Z' => continue, - else => return false, - }; - - return true; -} - -test "gActionNameIsValid" { - try testing.expect(gActionNameIsValid("ring-bell")); - try testing.expect(!gActionNameIsValid("ring_bell")); -} - -/// Function to create a structure for describing an action. -pub fn Action(comptime T: type) type { - return struct { - pub const Callback = *const fn (*gio.SimpleAction, ?*glib.Variant, *T) callconv(.c) void; - - name: [:0]const u8, - callback: Callback, - parameter_type: ?*const glib.VariantType, - - /// Function to initialize a new action so that we can comptime check the name. - pub fn init(comptime name: [:0]const u8, callback: Callback, parameter_type: ?*const glib.VariantType) @This() { - comptime assert(gActionNameIsValid(name)); - - return .{ - .name = name, - .callback = callback, - .parameter_type = parameter_type, - }; - } - }; -} - -/// Add actions to a widget that implements gio.ActionMap. -pub fn addActions(comptime T: type, self: *T, actions: []const Action(T)) void { - addActionsToMap(T, self, self.as(gio.ActionMap), actions); -} - -/// Add actions to the given map. -pub fn addActionsToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []const Action(T)) void { - for (actions) |entry| { - assert(gActionNameIsValid(entry.name)); - const action = gio.SimpleAction.new( - entry.name, - entry.parameter_type, - ); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *T, - entry.callback, - self, - .{}, - ); - map.addAction(action.as(gio.Action)); - } -} - -/// Add actions to a widget that doesn't implement ActionGroup directly. -pub fn addActionsAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) void { - comptime assert(gActionNameIsValid(name)); - - // 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(); - - addActionsToMap(T, self, group.as(gio.ActionMap), actions); - - self.as(gtk.Widget).insertActionGroup( - name, - group.as(gio.ActionGroup), - ); -} - -test "adding actions to an object" { - // This test requires a connection to an active display environment. - if (gtk.initCheck() == 0) return; - - const callbacks = struct { - fn callback(_: *gio.SimpleAction, variant_: ?*glib.Variant, self: *gtk.Box) callconv(.c) void { - const i32_variant_type = glib.ext.VariantType.newFor(i32); - defer i32_variant_type.free(); - - const variant = variant_ orelse return; - assert(variant.isOfType(i32_variant_type) != 0); - - var value = std.mem.zeroes(gobject.Value); - _ = value.init(gobject.ext.types.int); - defer value.unset(); - - value.setInt(variant.getInt32()); - - self.as(gobject.Object).setProperty("spacing", &value); - } - }; - - const box = gtk.Box.new(.vertical, 0); - _ = box.as(gobject.Object).refSink(); - defer box.unref(); - - { - const i32_variant_type = glib.ext.VariantType.newFor(i32); - defer i32_variant_type.free(); - - const actions = [_]Action(gtk.Box){ - .init("test", callbacks.callback, i32_variant_type), - }; - - addActionsAsGroup(gtk.Box, box, "test", &actions); - } - - const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31)); - const parameter = glib.Variant.newInt32(expected); - - try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0); - - _ = glib.MainContext.iteration(null, @intFromBool(true)); - - var value = std.mem.zeroes(gobject.Value); - _ = value.init(gobject.ext.types.int); - defer value.unset(); - - box.as(gobject.Object).getProperty("spacing", &value); - - try testing.expect(gValueHolds(&value, gobject.ext.types.int)); - - const actual = value.getInt(); - try testing.expectEqual(expected, actual); +test { + _ = actions; } diff --git a/src/apprt/gtk-ng/ext/actions.zig b/src/apprt/gtk-ng/ext/actions.zig new file mode 100644 index 000000000..9f724c850 --- /dev/null +++ b/src/apprt/gtk-ng/ext/actions.zig @@ -0,0 +1,158 @@ +const std = @import("std"); + +const assert = std.debug.assert; +const testing = std.testing; + +const gio = @import("gio"); +const glib = @import("glib"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gValueHolds = @import("../ext.zig").gValueHolds; + +/// Check that an action name is valid. +/// +/// Reimplementation of `g_action_name_is_valid()` so that it can be +/// used at comptime. +/// +/// See: +/// https://docs.gtk.org/gio/type_func.Action.name_is_valid.html +fn gActionNameIsValid(name: [:0]const u8) bool { + if (name.len == 0) return false; + + for (name) |c| switch (c) { + '-' => continue, + '.' => continue, + '0'...'9' => continue, + 'a'...'z' => continue, + 'A'...'Z' => continue, + else => return false, + }; + + return true; +} + +test "gActionNameIsValid" { + try testing.expect(gActionNameIsValid("ring-bell")); + try testing.expect(!gActionNameIsValid("ring_bell")); +} + +/// Function to create a structure for describing an action. +pub fn Action(comptime T: type) type { + return struct { + pub const Callback = *const fn (*gio.SimpleAction, ?*glib.Variant, *T) callconv(.c) void; + + name: [:0]const u8, + callback: Callback, + parameter_type: ?*const glib.VariantType, + + /// Function to initialize a new action so that we can comptime check the name. + pub fn init(comptime name: [:0]const u8, callback: Callback, parameter_type: ?*const glib.VariantType) @This() { + comptime assert(gActionNameIsValid(name)); + + return .{ + .name = name, + .callback = callback, + .parameter_type = parameter_type, + }; + } + }; +} + +/// Add actions to a widget that implements gio.ActionMap. +pub fn add(comptime T: type, self: *T, actions: []const Action(T)) void { + addToMap(T, self, self.as(gio.ActionMap), actions); +} + +/// Add actions to the given map. +pub fn addToMap(comptime T: type, self: *T, map: *gio.ActionMap, actions: []const Action(T)) void { + for (actions) |entry| { + assert(gActionNameIsValid(entry.name)); + const action = gio.SimpleAction.new( + entry.name, + entry.parameter_type, + ); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *T, + entry.callback, + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } +} + +/// Add actions to a widget that doesn't implement ActionGroup directly. +pub fn addAsGroup(comptime T: type, self: *T, comptime name: [:0]const u8, actions: []const Action(T)) void { + comptime assert(gActionNameIsValid(name)); + + // 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(); + + addToMap(T, self, group.as(gio.ActionMap), actions); + + self.as(gtk.Widget).insertActionGroup( + name, + group.as(gio.ActionGroup), + ); +} + +test "adding actions to an object" { + // This test requires a connection to an active display environment. + if (gtk.initCheck() == 0) return; + + const callbacks = struct { + fn callback(_: *gio.SimpleAction, variant_: ?*glib.Variant, self: *gtk.Box) callconv(.c) void { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const variant = variant_ orelse return; + assert(variant.isOfType(i32_variant_type) != 0); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + value.setInt(variant.getInt32()); + + self.as(gobject.Object).setProperty("spacing", &value); + } + }; + + const box = gtk.Box.new(.vertical, 0); + _ = box.as(gobject.Object).refSink(); + defer box.unref(); + + { + const i32_variant_type = glib.ext.VariantType.newFor(i32); + defer i32_variant_type.free(); + + const actions = [_]Action(gtk.Box){ + .init("test", callbacks.callback, i32_variant_type), + }; + + addAsGroup(gtk.Box, box, "test", &actions); + } + + const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31)); + const parameter = glib.Variant.newInt32(expected); + + try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0); + + _ = glib.MainContext.iteration(null, @intFromBool(true)); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.int); + defer value.unset(); + + box.as(gobject.Object).getProperty("spacing", &value); + + try testing.expect(gValueHolds(&value, gobject.ext.types.int)); + + const actual = value.getInt(); + try testing.expectEqual(expected, actual); +} From add7f762a6fda5ea01b31e3ea92d18c2906c321c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 14 Aug 2025 11:47:05 -0600 Subject: [PATCH 41/53] fix(renderer/generic): deinit render targets with framestate This was a memory leak under Metal, leaked 1 swapchain worth of targets every time a surface was closed. Under OpenGL I think it was all cleaned up when the GL context was destroyed. --- src/renderer/generic.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 1517ec662..d975f0f96 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -395,6 +395,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *FrameState) void { + self.target.deinit(); self.uniforms.deinit(); self.cells.deinit(); self.cells_bg.deinit(); From a148adc5e4018577fe370e9cad74e621234a05b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 12:28:25 -0700 Subject: [PATCH 42/53] apprt: make gtk-ng the default apprt on Linux --- src/apprt.zig | 20 +++++++++++--------- src/build/GhosttyDist.zig | 5 +++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/apprt.zig b/src/apprt.zig index 706287302..6c1f040ea 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -60,20 +60,22 @@ pub const Runtime = enum { /// This is only useful if you're only interested in the lib only (macOS). none, - /// GTK-backed. Rich windowed application. GTK is dynamically linked. - gtk, - - /// GTK4. The "-ng" variant is a rewrite of the GTK backend using - /// GTK-native technologies such as full GObject classes, Blueprint - /// files, etc. + /// GTK4. Rich windowed application. This uses a full GObject-based + /// approach to building the application. @"gtk-ng", + /// GTK-backed. Rich windowed application. GTK is dynamically linked. + /// WARNING: Deprecated. This will be removed very soon. All bug fixes + /// and features should go into the gtk-ng backend. + gtk, + pub fn default(target: std.Target) Runtime { - // The Linux default is GTK because it is full featured. - if (target.os.tag == .linux) return .gtk; + // The Linux default is GTK because it is a full featured application. + if (target.os.tag == .linux) return .@"gtk-ng"; // Otherwise, we do NONE so we don't create an exe and we - // create libghostty. + // create libghostty. On macOS, Xcode is used to build the app + // that links to libghostty. return .none; } }; diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 25ec7182b..6123582b7 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -25,6 +25,11 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { try resources.append(alloc, gtk.resources_c); try resources.append(alloc, gtk.resources_h); } + { + const gtk = SharedDeps.gtkNgDistResources(b); + try resources.append(alloc, gtk.resources_c); + try resources.append(alloc, gtk.resources_h); + } // git archive to create the final tarball. "git archive" is the // easiest way I can find to create a tarball that ignores stuff From 6b1dd3e4419df5fcf0ea5493e1f4468e07aed6d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 14 Aug 2025 15:01:00 -0700 Subject: [PATCH 43/53] apprt/gtk-ng: implement `maximize` and `fullscreen` These fell through the cracks. --- src/apprt/gtk-ng/class/window.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index acba271c1..18e9178ac 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -319,8 +319,15 @@ pub const Window = extern struct { ); } + // Start states based on config. + if (priv.config) |config_obj| { + const config = config_obj.get(); + if (config.maximize) self.as(gtk.Window).maximize(); + if (config.fullscreen) self.as(gtk.Window).fullscreen(); + } + // We always sync our appearance at the end because loading our - // config and such can affect our bindings which ar setup initially + // config and such can affect our bindings which are setup initially // in initTemplate. self.syncAppearance(); From 63869d8e3790036560b3064339876cf596119acb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 14 Aug 2025 21:44:36 -0500 Subject: [PATCH 44/53] ci: switch to debian 13 --- .github/workflows/test.yml | 8 ++++---- src/build/docker/debian/Dockerfile | 24 ++++++++++-------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c6b57d69..c00816b38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: - translations - blueprint-compiler - test-pkg-linux - - test-debian-12 + - test-debian-13 - zig-fmt steps: - id: status @@ -957,8 +957,8 @@ jobs: run: | nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" - test-debian-12: - name: Test build on Debian 12 + test-debian-13: + name: Test build on Debian 13 runs-on: namespace-profile-ghostty-sm needs: [test, build-dist] steps: @@ -984,7 +984,7 @@ jobs: context: dist file: dist/src/build/docker/debian/Dockerfile build-args: | - DISTRO_VERSION=12 + DISTRO_VERSION=13 flatpak-check-zig-cache: if: github.repository == 'ghostty-org/ghostty' diff --git a/src/build/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index b1389aa17..73c7da7c8 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -1,10 +1,11 @@ -ARG DISTRO_VERSION="12" +ARG DISTRO_VERSION="13" FROM docker.io/library/debian:${DISTRO_VERSION} # Install Dependencies RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ apt-get -qq -y --no-install-recommends install \ # Build Tools + blueprint-compiler \ build-essential \ curl \ libbz2-dev \ @@ -16,33 +17,28 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ pandoc \ # Ghostty Dependencies libadwaita-1-dev \ - libgtk-4-dev && \ - # TODO: Add when this is updated to Debian 13++ - # gtk4-layer-shell + libgtk-4-dev \ + libgtk4-layer-shell-dev && \ # Clean up for better caching rm -rf /var/lib/apt/lists/* -# work around the fact that Debian 12 doesn't ship a pkg-config file for bzip2 -RUN . /etc/os-release; if [ $VERSION_ID -le 12 ]; then ln -s libbz2.so /usr/lib/$(gcc -dumpmachine)/libbzip2.so; fi +WORKDIR /src + +COPY ./build.zig /src # Install zig # https://ziglang.org/download/ -COPY . /src - -WORKDIR /src - RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-linux-$(uname -m)-$ZIG_VERSION.tar.xz" && \ tar -xf /tmp/zig.tar.xz -C /opt && \ rm /tmp/zig.tar.xz && \ ln -s "/opt/zig-linux-$(uname -m)-$ZIG_VERSION/zig" /usr/local/bin/zig -# Debian 12 doesn't have gtk4-layer-shell, so we have to manually compile it ourselves +COPY . /src + RUN zig build \ -Doptimize=Debug \ - -Dcpu=baseline \ - -Dapp-runtime=gtk \ - -fno-sys=gtk4-layer-shell + -Dcpu=baseline RUN ./zig-out/bin/ghostty +version From 9ccc02b1311ea8e05d014da6089826850ca9a2ae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Aug 2025 08:59:22 -0700 Subject: [PATCH 45/53] renderer: don't assume non-zero sized grid Fixes #8243 This adds a check for a zero-sized grid in cursor-related functions. As an alternate approach, I did look into simply skipping a bunch of work on zero-sized grids, but that looked like a scarier change to make now. That may be the better long-term solution but this was an easily unit testable, focused fix on the crash to start. --- src/renderer/cell.zig | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index e1cd6153f..eeb51bdf0 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -141,7 +141,12 @@ pub const Contents = struct { } /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText, cursor_style: ?renderer.CursorStyle) void { + pub fn setCursor( + self: *Contents, + v: ?shaderpkg.CellText, + cursor_style: ?renderer.CursorStyle, + ) void { + if (self.size.rows == 0) return; self.fg_rows.lists[0].clearRetainingCapacity(); self.fg_rows.lists[self.size.rows + 1].clearRetainingCapacity(); @@ -158,6 +163,7 @@ pub const Contents = struct { /// Returns the current cursor glyph if present, checking both cursor lists. pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText { + if (self.size.rows == 0) return null; if (self.fg_rows.lists[0].items.len > 0) { return self.fg_rows.lists[0].items[0]; } @@ -469,3 +475,14 @@ test "Contents clear last added content" { // Fg row index is +1 because of cursor list at start try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); } + +test "Contents with zero-sized screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Contents = .{}; + defer c.deinit(alloc); + + c.setCursor(null, null); + try testing.expect(c.getCursorGlyph() == null); +} From 997e013d7eff370f2fafa2ac3ba8b28932c92500 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Aug 2025 09:18:26 -0700 Subject: [PATCH 46/53] apprt/gtk-ng: respect window-inherit-working-directory=false Fixes #8244 --- src/apprt/gtk-ng/class/surface.zig | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 9fa82f4ee..580436bd3 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -523,10 +523,18 @@ pub const Surface = extern struct { priv.font_size_request = font_size_ptr; self.as(gobject.Object).notifyByPspec(properties.@"font-size-request".impl.param_spec); - // Setup our pwd - if (parent.rt_surface.surface.getPwd()) |pwd| { - priv.pwd = glib.ext.dupeZ(u8, pwd); - self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + // Remainder needs a config. If there is no config we just assume + // we aren't inheriting any of these values. + if (priv.config) |config_obj| { + const config = config_obj.get(); + + // Setup our pwd if configured to inherit + if (config.@"window-inherit-working-directory") { + if (parent.rt_surface.surface.getPwd()) |pwd| { + priv.pwd = glib.ext.dupeZ(u8, pwd); + self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); + } + } } } From 4bcaac50f254defc11deeddf5d957d5ad7b20e31 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 15 Aug 2025 09:37:48 -0700 Subject: [PATCH 47/53] apprt/gtk-ng: actually handle color scheme events Fixes #8245 --- src/apprt/gtk-ng/class/application.zig | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 2278a0f4c..bfecab3e1 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -1091,6 +1091,11 @@ pub const Application = extern struct { self, .{ .detail = "dark" }, ); + + // Do an initial color scheme sync. This is idempotent and does nothing + // if our current theme matches what libghostty has so its safe to + // call. + handleStyleManagerDark(style, undefined, self); } /// Setup signal handlers @@ -1304,14 +1309,25 @@ pub const Application = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { - _ = self; - - const color_scheme: apprt.ColorScheme = if (style.getDark() == 0) + const scheme: apprt.ColorScheme = if (style.getDark() == 0) .light else .dark; + log.debug("style manager changed scheme={}", .{scheme}); - log.debug("style manager changed scheme={}", .{color_scheme}); + const priv = self.private(); + const core_app = priv.core_app; + core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { + log.warn("error updating app color scheme err={}", .{err}); + }; + for (core_app.surfaces.items) |surface| { + surface.core().colorSchemeCallback(scheme) catch |err| { + log.warn( + "unable to tell surface about color scheme change err={}", + .{err}, + ); + }; + } } fn handleReloadConfig( From 11ecb516d4fa751b7cb209e3a65cb2b28ff67844 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 14 Aug 2025 12:43:32 +0800 Subject: [PATCH 48/53] gtk-ng: refactor CSD/SSD style class settings Fixes #8127 --- src/apprt/gtk-ng/class/window.zig | 48 +++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 18e9178ac..82d961e17 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -557,16 +557,46 @@ pub const Window = extern struct { /// fullscreen, etc.). fn syncAppearance(self: *Self) void { const priv = self.private(); - const csd_enabled = priv.winproto.clientSideDecorationEnabled(); - self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled)); + const widget = self.as(gtk.Widget); - // Fix any artifacting that may occur in window corners. The .ssd CSS - // class is defined in the GtkWindow documentation: - // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition - // for .ssd is provided by GTK and Adwaita. - self.toggleCssClass("csd", csd_enabled); - self.toggleCssClass("ssd", !csd_enabled); - self.toggleCssClass("no-border-radius", !csd_enabled); + // Toggle style classes based on whether we're using CSDs or SSDs. + // + // These classes are defined in the gtk.Window documentation: + // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. + { + // Reset all style classes first + inline for (&.{ + "ssd", + "csd", + "solid-csd", + "no-border-radius", + }) |class| + widget.removeCssClass(class); + + const csd_enabled = priv.winproto.clientSideDecorationEnabled(); + self.as(gtk.Window).setDecorated(@intFromBool(csd_enabled)); + + if (csd_enabled) { + const display = widget.getDisplay(); + + // We do the exact same check GTK is doing internally and toggle + // either the `csd` or `solid-csd` style, based on whether the user's + // window manager is deemed _non-compositing_. + // + // In practice this only impacts users of traditional X11 window + // managers (e.g. i3, dwm, awesomewm, etc.) and not X11 desktop + // environments or Wayland compositors/DEs. + if (display.isRgba() != 0 and display.isComposited() != 0) { + widget.addCssClass("csd"); + } else { + widget.addCssClass("solid-csd"); + } + } else { + widget.addCssClass("ssd"); + // Fix any artifacting that may occur in window corners. + widget.addCssClass("no-border-radius"); + } + } // Trigger all our dynamic properties that depend on the config. inline for (&.{ From ed603b07a52c459a8cab2f80b88a0fce56cad23a Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 16 Aug 2025 01:28:49 +0800 Subject: [PATCH 49/53] gtk-ng: set IM context's input-purpose as terminal See https://github.com/ghostty-org/ghostty/issues/7987#issuecomment-3187597026 --- src/apprt/gtk-ng/ui/1.2/surface.blp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 98f4f5dd2..6c027e735 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -152,6 +152,7 @@ template $GhosttySurface: Adw.Bin { } IMMulticontext im_context { + input-purpose: terminal; preedit-start => $im_preedit_start(); preedit-changed => $im_preedit_changed(); preedit-end => $im_preedit_end(); From 4c4d3cfc3f3260c786edfab48dffa0e86ee2a041 Mon Sep 17 00:00:00 2001 From: Alex Kladov Date: Fri, 15 Aug 2025 18:06:25 +0100 Subject: [PATCH 50/53] fix UAF in grow Grow needs to allocate and might fail midway. It tries to handle this using "undo" pattern, and restoring old state on error. But this is exactly what steps into UAF, as, on error, both errdefer and defer are run, and the old data is freed. Instead, use a more robust "reservation" pattern, where we first fallibly resrve all the resources we need, without applying any changes, and than do the actual change once we are sure that cannot fail. --- src/font/Atlas.zig | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 7b31e2794..3f1caf562 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -287,22 +287,19 @@ pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void assert(size_new >= self.size); if (size_new == self.size) return; + try self.nodes.ensureUnusedCapacity(alloc, 1); + const data_new = try alloc.alloc(u8, size_new * size_new * self.format.depth()); + errdefer comptime unreachable; // End resource reservation phase. + // Preserve our old values so we can copy the old data const data_old = self.data; const size_old = self.size; - // Allocate our new data - self.data = try alloc.alloc(u8, size_new * size_new * self.format.depth()); + self.data = data_new; defer alloc.free(data_old); - errdefer { - alloc.free(self.data); - self.data = data_old; - } - // Add our new rectangle for our added righthand space. We do this - // right away since its the only operation that can fail and we want - // to make error cleanup easier. - try self.nodes.append(alloc, .{ + // Add our new rectangle for our added righthand space. + self.nodes.appendAssumeCapacity(alloc, .{ .x = size_old - 1, .y = 1, .width = size_new - size_old, From 37ebf212d5c5ef19080f04a91f134e77bea6e81b Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 15 Aug 2025 12:26:01 -0600 Subject: [PATCH 51/53] font/Atlas: cleanup `grow` Reordered to form a more logical sequence of steps, cleaned up and clarified comments, fixed invalid `appendAssumeCapacity` call which erroneously passed `alloc`, so this compiles again. --- src/font/Atlas.zig | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 3f1caf562..b1f9ad425 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -287,27 +287,30 @@ pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void assert(size_new >= self.size); if (size_new == self.size) return; + // We reserve space ahead of time for the new node, so that we + // won't have to handle any errors after allocating our new data. try self.nodes.ensureUnusedCapacity(alloc, 1); - const data_new = try alloc.alloc(u8, size_new * size_new * self.format.depth()); - errdefer comptime unreachable; // End resource reservation phase. - // Preserve our old values so we can copy the old data + const data_new = try alloc.alloc( + u8, + size_new * size_new * self.format.depth(), + ); + + // Function is infallible from this point. + errdefer comptime unreachable; + + // Keep track of our old data so that we can copy it. const data_old = self.data; const size_old = self.size; + // Update our data and size to our new ones. self.data = data_new; + self.size = size_new; + + // Free the old data once we're done with it. defer alloc.free(data_old); - // Add our new rectangle for our added righthand space. - self.nodes.appendAssumeCapacity(alloc, .{ - .x = size_old - 1, - .y = 1, - .width = size_new - size_old, - }); - - // If our allocation and rectangle add succeeded, we can go ahead - // and persist our new size and copy over the old data. - self.size = size_new; + // Zero the new data out and copy the old data over. @memset(self.data, 0); self.set(.{ .x = 0, // don't bother skipping border so we can avoid strides @@ -316,6 +319,13 @@ pub fn grow(self: *Atlas, alloc: Allocator, size_new: u32) Allocator.Error!void .height = size_old - 2, // skip the last border row }, data_old[size_old * self.format.depth() ..]); + // Add the new rectangle for our added righthand space. + self.nodes.appendAssumeCapacity(.{ + .x = size_old - 1, + .y = 1, + .width = size_new - size_old, + }); + // We are both modified and resized _ = self.modified.fetchAdd(1, .monotonic); _ = self.resized.fetchAdd(1, .monotonic); From 0d4e6733661a45841c13b704c9ba504176a4e569 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 15 Aug 2025 12:49:09 -0600 Subject: [PATCH 52/53] font/Atlas: add test for OOM behavior of `grow` Similar tests should be added throughout the codebase for any function that's supposed to gracefully handle OOM conditions. This one was added because grow previously had a use-after-free bug under OOM, which this would have caught. --- src/font/Atlas.zig | 55 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index b1f9ad425..68ccaddcc 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -86,6 +86,11 @@ pub const Region = extern struct { height: u32, }; +/// Number of nodes to preallocate in the list on init. +/// +/// TODO: figure out optimal prealloc based on real world usage +const node_prealloc: usize = 64; + pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas { var result = Atlas{ .data = try alloc.alloc(u8, size * size * format.depth()), @@ -95,8 +100,8 @@ pub fn init(alloc: Allocator, size: u32, format: Format) Allocator.Error!Atlas { }; errdefer result.deinit(alloc); - // TODO: figure out optimal prealloc based on real world usage - try result.nodes.ensureUnusedCapacity(alloc, 64); + // Prealloc some nodes. + result.nodes = try .initCapacity(alloc, node_prealloc); // This sets up our initial state result.clear(); @@ -744,3 +749,49 @@ test "grow BGR" { _ = try atlas.reserve(alloc, 2, 1); try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); } + +test "grow OOM" { + // We use a fixed buffer allocator so that we can consistently hit OOM. + // + // We calculate the size to exactly fit the 4x4 pixels and node list. + var buf: [ + 4 * 4 * 1 // 4x4 pixels, each 1 byte. + + node_prealloc * @sizeOf(Node) // preallocated nodes. + ]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buf); + const alloc = fba.allocator(); + + var atlas = try init(alloc, 4, .grayscale); // +2 for 1px border + defer atlas.deinit(alloc); + + const reg = try atlas.reserve(alloc, 2, 2); + try testing.expectError( + Error.AtlasFull, + atlas.reserve(alloc, 1, 1), + ); + + // Write some data so we can verify that attempted growing doesn't mess it up. + atlas.set(reg, &[_]u8{ 1, 2, 3, 4 }); + try testing.expectEqual(@as(u8, 1), atlas.data[5]); + try testing.expectEqual(@as(u8, 2), atlas.data[6]); + try testing.expectEqual(@as(u8, 3), atlas.data[9]); + try testing.expectEqual(@as(u8, 4), atlas.data[10]); + + // Expand by 1, should give OOM, modified and resized should be unchanged. + const old_modified = atlas.modified.load(.monotonic); + const old_resized = atlas.resized.load(.monotonic); + try testing.expectError( + Allocator.Error.OutOfMemory, + atlas.grow(alloc, atlas.size + 1), + ); + const new_modified = atlas.modified.load(.monotonic); + const new_resized = atlas.resized.load(.monotonic); + try testing.expectEqual(old_modified, new_modified); + try testing.expectEqual(old_resized, new_resized); + + // Ensure our data is still set. + try testing.expectEqual(@as(u8, 1), atlas.data[5]); + try testing.expectEqual(@as(u8, 2), atlas.data[6]); + try testing.expectEqual(@as(u8, 3), atlas.data[9]); + try testing.expectEqual(@as(u8, 4), atlas.data[10]); +} From 2a9ba56cdc47a2435d16b29311bdf7666f49e209 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 14 Aug 2025 13:21:41 -0600 Subject: [PATCH 53/53] deps: update z2d to 0.7.1 tagged release This release contains performance and memory use improvements. Some of the sprite font test renders had to be updated due to very minor differences in the anti-aliasing, since the default anti-aliasing method in z2d has been changed to MSAA rather than SSAA. --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- .../testdata/U+1FB00...U+1FBFF-12x24+3.png | Bin 5718 -> 5724 bytes .../testdata/U+1FB00...U+1FBFF-18x36+4.png | Bin 9974 -> 9975 bytes .../testdata/U+1FB00...U+1FBFF-9x17+1.png | Bin 4334 -> 4341 bytes .../testdata/U+E000...U+E0FF-12x24+3.png | Bin 1252 -> 1255 bytes .../testdata/U+E000...U+E0FF-18x36+4.png | Bin 2220 -> 2217 bytes 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 55a693496..d7e3bb300 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -20,8 +20,8 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz", - .hash = "z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg", + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz", + .hash = "z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5", .lazy = true, }, .zig_objc = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index 24f1053ba..2ed4f63ce 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -129,10 +129,10 @@ "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", "hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=" }, - "z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg": { + "z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz", - "hash": "sha256-wfadegeixcbgxRzRtf6M2H34CTuvDM22XVIhuufFBG4=" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz", + "hash": "sha256-6ZqgrO/bcjgnuQcfq89VYptUUodsErM8Fz6nwBZhTJs=" }, "zf-0.10.3-OIRy8aiIAACLrBllz0zjxaH0aOe5oNm3KtEMyCntST-9": { "name": "zf", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 380bafaeb..b1b19be37 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -290,11 +290,11 @@ in }; } { - name = "z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg"; + name = "z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz"; - hash = "sha256-wfadegeixcbgxRzRtf6M2H34CTuvDM22XVIhuufFBG4="; + url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz"; + hash = "sha256-6ZqgrO/bcjgnuQcfq89VYptUUodsErM8Fz6nwBZhTJs="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 14bb0e8df..849c679fc 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -32,4 +32,4 @@ https://github.com/mbadolato/iTerm2-Color-Schemes/archive/3cbeca99efa10beba24b0e 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 -https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz +https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index d50371f5f..a67ccef59 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -157,9 +157,9 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/8bbd035f4101f02b1d27947def0d7da3215df7fe.tar.gz", - "dest": "vendor/p/z2d-0.7.0-j5P_Hg_DDACq-2H2Zh7rAq6_TXWdQzv7JAUfnrdeDosg", - "sha256": "c1f69d7a07a2c5c6e0c51cd1b5fe8cd87df8093baf0ccdb65d5221bae7c5046e" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.7.1.tar.gz", + "dest": "vendor/p/z2d-0.7.1-j5P_HhFQDQC6T5srG235r_nQpCrNwI15Gu2zqVrtUxN5", + "sha256": "e99aa0acefdb723827b9071fabcf55629b5452876c12b33c173ea7c016614c9b" }, { "type": "archive", diff --git a/src/font/sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png b/src/font/sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png index 2eff59c76114698bf12ffc228db36a935a03c16e..1a4dcb01ff0fa66a0e34ddbcba8fff27e76b5f09 100644 GIT binary patch delta 1115 zcmcbnb4O=_3ZwEyRWU)s!v;L9?k73qRFz9u`Y!~UG+jR-CV9|7EMei2P7T{m|6QUl zem5Li=kd3;wem&l|2OWFD+OiiI}{vQIF&pE9Gf_VTqYseS1`IOj$NeBk7Jp|Y`3+chJmNzJ-daY6n2z4=@1VwBt;E8SZ<+v(X_k%bFb ze9O3;qw3$+{{2g^3n(wgaQ#= zH!V`>YWrqfZ1=Ta9WDFJd9`6SUzKp-7MC?XA<=_9`dWrTWa1#3bfYG zRM|1bB;iSq+qyQnEAFvIkt=H6MG3T)XKLsbZ)jmopTwonY`vy`TFFL_pLez@aJ|13 z8S~75Q$^a)Lv8byz)NLf8uR-jCRD2$ot%8a%&B16?TI?IKaS}qI5~?wiCcB{lggcr zZ*zXOnfc2971zFKFTYu#{`sepSs8}|+aCDzy9j+vZ1-mrmfEcF{8#O)jMIk=PR^8F zwe*#9pFXpiNrvMxy_)4}eZh^!meYK%x!2hwIV~1TIj57l{F>|u=JcWs{#Q1X#R-;Z zT8c!R_+0Dgrp=YPB2`|8H}U53McOYJ(u1UuvLdY4bNo`0>3=!@wW~A#`+8X^G4V>z zjrv?l={kDnR&CsX00M{q{l9zOeCkyXuVCgW9yOWnT2H6n44AU)yv}_8?2i?f-sj$) zcYXc(*TuK@sVdlT@>3YlY3r@1H6@SH`H_d~&9H(hugUtz!J?fB#i#ez7R{ zv2wee{)MMY17`l+7nS|7zTnpTr{Dh8)ZKsfGsW`t)|_b18S(S4Jv!L_>#v|+wL5Dm z>m{b9Q&P^pA|^fmgZ-;Lmp|FbG;^|QuGg`^v$qvmmes9uSax^W%qp4EpV#kQ+VUWz z@eQlBwys6PqyM2R|D5s@J>mEB)2Akbm}3$Js~pOFt}#_HFf(18QG4Sh(_iMv)uP8m ZKD%TUWfZdiVPIfj@O1TaS?83{1OUfh8zKMz delta 1114 zcmcbkb4_Q03Zv{sRWU(>BMJho-ZzC}PEDI26eFa1;aJ&5)rebcYDyE29%!yC=$`W3 zbE0nB!{^;L?R(GI|DHU#P*AJ9A8d<_-*$3aH(C|e9w*tYWh#UF7G)0_h)pw_T6;?A>sAk z-~M-ho7(q@`M4oxn@MnGoSF6Y z2diqv#$bs{+UBcpZqOf1@;P0n`(#pMcJfEx$-Patb5Mpb&_kDt=G3)n2@BbbDo@*wo z*thaieZb#2_gg}^i|&aWb5uR^c-lX~pMOLQj;npX!+z;V^}OWsA7)R;bG+o6_+(W| z-P^1Ctpra#p7!rz_0#1YmL67p%l6;;GTHSDNX>_*I~`(Nlmu!kkId|fIKZ0a>&*AV z_rwMTj^A1iR!3ianW)2I^!(uTFBjb&FL*nR)$zu3Pow(ji`dRs7AuQ8>%UUgo2fBn z+iWqG#8|0Q(=E5P7@tefR@ieVwRVZGc-NA*%UB&FuAlpq+LzDdsC3M1a^NqH+hrmi zx1)7V>=BZDGuc7P(c*B4ikJMHAC^8VPBxwK-g?olQ+<(X`kV`GFO~l%X}zB^=pR)N}X8rA;1AnZlKC{P5eOI3Ig@-GPONG^9YG|BkK!ADOvUy(w{7zQ0 z2B~}BnDRw=_I9U^*}wKOtt^vHdsX-3X?zvecGm4&OZ0C9-frUd_!d9)()u@HjbC)* z@9aCkJK2zbN2r3WS{CEp2DZk8GnQ}48vikaQ|%2$E+$7Fmy5sWPo5yUL&Wz+R@0hU ST7C=+3=E#GelF{r5}E+5SspI{ diff --git a/src/font/sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png b/src/font/sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png index b77f7dfae74ed67f1b9ca3ef40d4bd09f0142509..73ead485dacbb6d9ea2e3fd5d527d069730ecfe2 100644 GIT binary patch delta 867 zcmez7``vef3gfv=k;p1N>=C8VY_rBfqI8-MudKK&AoKGk1kDBFpywVk%HBo`vO!&x> ztR{w>ZpH~!9BLYR;mIkfAE$BndQRZgDvS|Zc;sc#Q^O0X9|d9_?drANRWHlF^o-{O z-YlUV8x_2^KCtFlu~DMw|H0h4O=6c>m-={3;O#Zv{_Ms#$J03m2d>#z-8!@lO(Zcg_34UMuJb~Vqf|I@O+fNSn0O-G^PZ5nqTG_GZib5H0_61ATi z`Skp&DSn)j7HmHGoQ?lj&14A?wfkCiT)VH!U)5YIlp-GR`D#dhyf+Wm>>5e?y2M9K z9ACXA#5$Y}5B{$=yE^~qqLWT_Z%$og70jBZu)AT_x@CFOkFK6$@L_e7_s`Cdpmwj! zX$tjFiKnY&3^$za>;7q&yab8&Umg}Q!ZCKvsCH+WS%@txu~A!jGLsQa>bFJM}jt9 zZWol-mu_I1IM+ixlw#_;mv4}UdB?_V#zpFc@_jnJ1}p{ouAZaAItFlF*o&4nI{ zi$dgu1Vwo-CQGJiJg;e}vsk#SL!r!m{e;)f`~Lmi&T7LHb)J#&Z-JAqpyQkW6X!ph zbMLCWM#KT-1y5xJF7T^gsTY}e{TiMY-GNL}@lkcskHU9rZQ1Qu%kkUQk!jaP%iw6c*_wYgTd1yL a6p`6u@?X+<{&NNf1_n=8KbLh*2~7YtG?rih delta 866 zcmezF`^|TP3gh05s)dU62Xl`a@Ho4_@TlJ+_kZK!lA}zlQb#!DUDdl*XFolp@X^(E z%Fd*^NBl3Yw^pyVuc_I8z@gSVKY#a|3eoP{)3Od)%&z(5zmZpZQk8d0n#zf$vn+-y zXDKk;PGVRh*TmU%`l>`^+0 z`?bMRsvHW>8CUWrZ_JaTXe?DC9&J=t#eZdC(6=%agPD$yoWqWp#Yx9SyKKYtGCb~|4g+$7( zZT(qQ*HhXd%l7BR>5U)0%GjT(I5fRU@9CF&6IkNB|1nISrd(7naN12$QMtmU|B;}L zm)nKMd&Ak|71GteKjab=kt*;PI`ox&dtJQ#K6wT4HHW_J3N^VAIN@~4*^^?r!9EkY z6t=drEb!`(J00L``#kwYyO39xL6UL9CAm{y-|shlAQ)iBo&pv9-}k5F`ImM792}YD z7;~RD@ca#M@)WH1dy~K3(ew1AfA>8r)KX{gakoB8e%UVMl~s^*eed_p-REcbrruzC z0x~3PLsGT-wz?RmqA$*zs;U(mUw>R1kn{Hk%aiBrLS9-uNAh+unMU_7_ha1ob79M* z^V%JACm#^L%bT(2@8dJ3e^?IXs6X4CHK(EE&yj;)3{LQuB{BsWeG+E-EbLkLPDE8f z*FLdeP3rN4@ZEt-v*M%bq;9P*bA3_Xd@SKxcN0rVa`CdQHDfK|LxhY z5IiwH|DFB{<^AilSKi5G(fswgxKn4@+w~KqOt*aD-R99F$x;7Xk+(%7X2MyP$ERjY zD=L4^=`@9Tr4h`TmP29h@QE0~R_4Vg^)m`Qqx=k+^U^XvWbq z+Xa?nKiih2n>q97o=yoPF!;w&qaJwf+vYUx9SvD2V*3T?(^*zH(ZX>30tUmn};W(d(jJ?!rk+1Ud~u>$#d<_ zD%A5)a9g-*+PBz?)rZFFYj>#xm-}0gj;7VQog=^X7MOt2;6aD)8w6nGzx5I=Z z++Lo(qoxw}BK)Nn_tHDtzTUPivk2BIy!&35^`~da($=soIZ85_84C9!gR;WX{Y0K@ zu3r@%CE>y?Y3F*=xSOjt@^)!v(;fETUff<=M9daNv}mSfHs>!nu;SIE1wCMpf2hMf ze*TYB^XwYD|DDt4$v0fW`Ka*1OXZ-+Qk`pi-b4h=N}l-Yr}LX95tXS2U;Bj5T3p@9 zeVu1gxv_Tg&j&5f42t#@398iY-)VExMCxQczuPv2_s7@AT;)t-G0>M$KT&3(_9J=u zzBz^y72}t2JVVzYgA#V_^|}lU?rI{PlC{TARf_ynXTIbmoozP3!0Q zpJMlB`x__M{Cu*XbMzaQ+Q%%G5}O+q*FE!pDEy@ML*KtYeS z0gk&5HaXsG=$XJ);G2v+PN+*HjZx_zrQ|?QXGvsAYYmb@lA(*N#hVFZbBkK3#wM!zWRjR#KoWf8)<%=AUgBx7~jw70SAt+i0?S z>arcHYv!?VO+Rs~x3GWHzKJjY7S1W>Pc7_a?Av?&Z;RTIn5VL-4!rf9hZv%NE3?=C zGWA$5_d@vY%EUimF^~5LnZ!qwN-ETMM*U|=k-4Y3yY-Oc5tTpMrMcd@4$HnT)9+(6 zl{s8(P+sy=UF^6(xZAV7`C3!GmInP@@FwK+skJBhrv*NWm6|DB%qrxw^7`(ht+TfW zC>)&fu}pt1Q|e=_ir^isevO+7|B77j+2zWtT7Q4F!)Z3JIUXfkQ+i*2pa1m3Ugg&g zjdijbh58p2*L%+Y`@SIrWI@$}C!N>t_8w&Z)b?kQM1alT*R}c*Z$2Eed&Sw1qIrR- zaYseo^56xZ9E_Jb@H0=^eP>eRGd?eGHlrtZ)fsoWa&0M5P+`^LpYqm_RY5evGS+YD zuAu)644WUaH?!35op{?uAe8;bBYgqy07h{c>j~BgoLY|W9Hv_buqScu;yQC$b_Gl9 zy9YLx7`tnKGkmOjBYEkbdGh@Rrj7YKSvGLo`y~9I>-UA^JE`)w?#$rln&~uqpX6mb zk7T1{7LOaKbz%qqJav28#d=vv{Z_0?_`{IZA1_Yinl3W; zcSZi>kaQ(`=FWpBUrlguR4@D1IDnq5$o}1ct_GX-7XUe2AX@&(; z7^UiCO?ub5wkEl2R#;DT&8<^e9J=V+ye#*s4YFCGpPJvLwrFIm;XL4LzG3m9X@4WX z+&}UnxBrUm);??L2?*xJW6LBH zrhN~rSGc}3QvI^k(tC5JTrSv{plr@!x%?WB0YAI=F@9l-d2`%Wd})`7y7aK4-?!!R zsX0?tEb5q3)xbDsN)PvYo9X}R8rbHto+NPjJAi5?s@or<+KNJ z=U*fne_Xwjp?m-NeMcYqZnr-?tEcwcW$w2d_kKC@$S3#^i)`Hg*Vk9xSuj)WX}w`m z(B_qqPqba;-Lz+PtbfX+ob4sPK+eeX2rKVv#TPPQhIRXb z)pIaBK;d>T;dp?*>6#R_L7zq4LbR`rH26j{-p@o|n4-xI|x@(wmFEM=AA ztc`l-rK0aYUHLXpzAjMU#o^wx1MRDoS3J#J!QFIV;-r-U2Ru3joH!I);KX9FpNwl6 XB>gx1v@>I1U|{fc^>bP0l+XkKl|GC- delta 2980 zcmeyW_)c+x3ggy^s^{vxHn4baVDa|g)I2Dp13`^K8jbl$EZ_X}7Nx{Z%D*wwd~z-O z6SF_E>`&jbE57&I{a@`(%KNp!ETWFC0fHs26DIjCS?N-=R4!0mSkheOalJx@+Wor! zwT|1=?%(^jZ^68p-7A+C*(-Zo&W{(_G-aE;lhl<97vncLC%yHkzagHf;PSXBCtV`^ znqNpjgoNGuZ`@3A_Gw=IQm?waGB|$A@lH8#r~XfICbQzJHCx^OHYv=|I$zI!O7N^s z*-uW#Wr=yO&rR%$eAC^rZ~+KB=s(~ulvQ|pmi7bYRh^NC94nqo`@SRfV}ij&W7(GF zPi)TJcxGx*Zr*q0Ops~)ycgTKcUf-Vd;i)2&+4rftz*=IyFqWUi2= z{=)U0Kg+e9lP^>|GBp=|YRZY=y0J2#>&bT2gS+p}aFDuI9$LDXIkIJj{+vsGl3Tw^ z^-E?pRaxyWaN5|qWYzO4AKQN(vMyEe43OXR?I-)BrPYca!j79g%xXQWvNW#Uvb7Xh zaxnY*<2UEteiVyZl&A2DBlBm{4<_3$tLhJ0)xJ8ojn&8^KjG};Xo-n;I;>(|UG12r zoDrYX>)({(y%on1X) z+uHOEK9|njNmU7Zv94q(tLpZ*f4<3A&(Ykv;~oFZrb8;bE;*@3nF%JoU({IhzQbtE zxk;KD^(mq2w-|JZ8P#Z+8g%#dMqV$iZ2HqyyOdQoN=rO&%^{P`B^TqD99r>e(t;*1 z@ITZQ9{>JEs(<`})c2pY4~O$qHdV-!{OY>$%q_{^%}>Yj>_(5Te^krL*8EAi_}nLa z*5dR|?(;mG{EhvOeL5(4Ht|W#Q$>$|mBoLbT*|0dv7d3HsYY)9rx5j7f(P~#%u=}i zaK;1wxxb2zxwQS6p*3x@Waz8~f?7X${+~Hswx;#ToM{>!HSe~aUzL0F4U6N9Z}Xg& z&NC5zf9=bf#R?uZ6Ly@gR@DPc~WELOpAnV{kEFd6)mNzlvdD50tE z{g1tDvcg}!uQymC{xo_=-5;*S;H=U8k)MI#|Nl30dP^S}@EqUkSTCt4J%x7`?=9X_ zY^lx3%o2@)yAGx{Z(@{Gl>QO-XQIb6k@g4MD%=0PziaKoa!KE?Q0H`QOMQlp>*6(< zyBMw?aFg}P&bT(={DQd|iO(`F#&q3WeT=C$cuJD@649J32fy%~Eqh|D!6@}SVJGh- z9f1=kr$ikwOMh{kHD4>^n$g4h2M-FL8+vZMpdv6QQdG;>U^d^6mu7$K)ibuGA9CC} z{oj#=b6ymx39PBm3FSyS!}!ax;>g7_%F7-ecr)R(u;wBjmdTawDI0f$sB8YYxkcg9 zI-@lHo&Ilry8gEOox;QQwz+y$fBXkNsr6TSg4fM>#hL!*4Bxkw&4#)xt0ENkT>4y} z7x-`We*I1d4Ns$f_2_qQUC;G4WapP}NS{||@mTPR{k|YO!58|MPiTct$hh?Bn3UTs z69J7r9~KUN>y#9shIJY359PCtY6ly=TItC6YWCASah4ati~M|C6)(JQ$a;8a;diC9 zNkOSyRf|KC z{bXDz*>|Eo@<9Be53ekq9eFt^va#l{@#XDj%~BnD)!Yg`+MC_~k+d)U%7^G$x6p$x zo6bD(oT>RvD_*Vfkm{zg>^qCsX`s-s2y{&dA38>pW_BWa`A7kC%Hb4O$s;U&H$9rlRam&8br=rn@~m64S^M{OW67 z;lpRTA|3{*d+vHKceuHO>z>H83z`9D>)tcX^4y-a;7PS;#@%{{PT@|!#!ZEP|7@1o zSDqrPaiO1C^_{BK6VsQQ|I1DQo4;S9@=n5y{E9Fia{Zg zYm1YD-n^$JTp@B9%S2vWZ+N1&J#E3x=1a30c`Cms<#k;4sW9M7L}t zj2L(i|KG5*%{kcJ!lbkXlM>-zVQREo7@+xKv&Xab`625fDxw~{?CyS{H8;!D&T9Fg z(ynv0`?f1yl{|gkA*o=Kl}f;cjdS-VU7Dz{=I7krYn`8L*Jmxhy7SUT%iG$1N}iJ< z1)0+&7hH6$-@S3`iV1HuSo4o>T5(q2C`vW!{mQZu{%y`{yKG*}b3f5^Y!<_v5@u_y zoJZ#u{XgiIU%lkr%F;VG8&rfh^LjQ+ef0d-o8wF^il@pdOwQ&O#&uncmdj-~)xR7t zfnQJT^}RSrgS1+`^P8of+;Tk^a_ORE#?|0$&WjnhUtQ1}))7~qcDUj6g@8#9Z#V3i zA-J+Ptor?Sza4QY3RgDCO7ZTUQ0TyBRmLjr_E&Sy!H(*!^FHo=yKGk5EN6q=3r;o7(>%bCcHRBY$LyZQMUJcc z<9Qf`lp_8waAH}L+SM5Oe~$h3I9|nu-3^ZVhKv6m%nxr+k@P&&#!xPum{6`!Uy!IH znsmS}SAF7<9_GhFT-I@QGZoqmT2)Hy7D&vHVT!5PY(K;E=4_RRkB*#MS|b*+;i9>O zR`O*YgVVoP*iU%9EAsf|SwZ>pr|f=MbkOa7f`{K)$pkq*y&ieB0vo&D9hc;5G8P`5 zXm4gYC+yjhCt6(}R&|x6qLjm4?^~(ZM!6Ca)IfJEtf}qVL_lq2|ipon0W*<`0Y;}*kaJkkYG31}3e%IN_y(fhK&CB9^ z_qE5)OnCn+?hQYG%S6w)XYyb2TFRTv^W!r5|6CI8-mJqZTvGLSef;vG=CoN;j(2d@ zd+9q?rM#FV==k*b7Q1h`{T5f2dSCeXt@nTVrLU*Uk03$8>Y8f8Co!+|f-w z)fW>UEyb`e{b8=)7(26FP&VFJJ9^zj1SH*e!OBU7^=ydYkVS z7vH<~Y34}=$M4G&o=c0RX5Tf`4DYb%u4ig`#p+gnZHl6UZOWt$?pfEIFIa*ZWqgj| zmXlp#E^JvH6WHE$YT>I1ev8~MM7F!knKpGrsPuX#%>e$3k1btgPB>@SH&k$NzWJob z#S}Zwblbku!M7LN{aK`Np^s|!GKgToKVa^Y37N$lA1py8)wSjq=^-bYF`zOjYybWXZ-!(IM*MciQ-1Y=` z+)Hr!WB2c1sT_Nmr~5sLW0kJjlTsXyoR#bI`hQEH-!SP|(o>y!m6L0hUYYU1Nzp<3 z=L(}h$C4)p4CXRxx%!x;p7=6Z`C(qxycUO}QBl3ztqd2v{%U@%x@3L&MBj7nRtKA; z%bJRv?9()@c#i0#T+>>SsKxT<aAd>{OBQ<*4Fu(M`GLwB1Qz8JoYA_TC$- zK1XDxRCqRiJMk;w@y^7Bt{g5uDj|y2J1Hz{^EjFIbbob@ijk-H`-#1;K8G%LnRsN% z@-H!LN@7OYqLHE^Om)grC#+PiFI*}k=@HadVEby0g|*?a$#YLT9C4~oTl=Xvoaw~T zP)?5>{awebjkFrwRNI?Hl--%9%sKCH(2J@J=f-K%dq zOg*~q^!1wV>zi*oOg-N!XD?zawyN&I)?W|a6s~@_F`%^jLe}X^57VwDFZ`vp`RcOm zhs)ZJJAE~CUA@h-p4Yg)Ri<}cSnu|KQS0QmEu)sMjoNFVdQ&MBb@0KVdosQ>@~ delta 891 zcmaFP`Gj+V3ghaHsw|B4cPDN(;Mv0$#I$rr%ajK~8c)3zuy_@4PI2JWbi9AyY0lq> zh?qZTe>hrC7GJ3Fev?_mxA})37FfvewS%cX=E9wm_a6CgK1sN6f9SzU>z-ZF<1iI# z;BNiFTV$5=+Ge7+(Whf3>!mX$9^htr^Q*w+*LA*vK0&TY(_ZOD?aSh$=UKFlBY#?$u*|cP7{53rv;ty`v3h#V9LX3c1qUQp09T0 zRNkQ3|8gczi_Vg3t2V9D5P8|_;_~si{9LjFt(oDIHC1Q)Ys?r zdMppRR|^Of&S&i{PZ47}>%_;b>BPrwU^%<7tMy9MsWS;eE^a=4jz?-mzucP`%CRME z`JRhTot!Q^Zt{faEV-uTye~k*C~2xHSK<8^Qzt0T7RV45$=SkXDxz?uhwYSDQ1(K@ z)m*M_Yrailp60BdX?ZneajVd3*O`*MQ*@VHtCtE|z;~pI)ApIjYXj3YR@;}on$TU+ zfAnh3f@4YcPqOzNe|IZJrJwy4cecLr zjqrQ9-+N|1sh`_dEAYL)FS|-sKJ-KF`v)I>`+ZB_QTpKf^f~*NTf>qWIJM3Az|X+& z|NomiXL*|qc-SuFO%SwFQZ8X}d&?1``9zH6YO>~KtV%&w^QC- zvnOGvPTwn6ZdrbJA!Dn}cDW8w%HLbHGGS;6bf{Ho$jO;?yNm&nn!>kA4#GV$Jg zS$^1Mn$ae)`?=}ArcB)W<6X>dhj<@>8Qo$pip014ShrTYzR{=e9oL{=D^AAmj;$^?NjqAg9Y0MVv zN?Ov8!YNs!eOw~SA?J(_1GmF!5#{ zw!9*}rF7Y>-t$UQFV@Q&YDo&Kd|17==&5tl#~1sq9**VFy)nT}y?xh#%iD$PFU~ol zV&ivzf{O5MG+F!BNI05&dNQ> zdhJwW%G!(1|NOmcCCNE0AgG4(EZaR^W5P}LJj9DJ0v|D zqt>)dDuf9$P`%=o2f<#J4tT&-__=@jxm66S0c_YyNDp>`kiTe)7{+ zcS8Sp`KwHt8{S=+WwGk&CdJ97g2k4%?)*My8Pjw2)aI=ZjbC1q`#=BIU;9rJ`(>uo zGcYjx|NrJNBPRm`L&N5aY!et24S7NA1^?E*eZVWt#DD}ouz*y*ofs%0)}a6N{xVnD Row8jZc~4hAmvv4FO#p-~yHEfC delta 970 zcmZ1}xJGb-3S;p`RRiYwhZBtzIrj3cYnf7@8gXjchNUr_nu@9sho%)Q{c%=x=b8hD z8KnQF&H3>9*#(C8jfPs+{i_{0Q+)&u8U{ElXyMs6buo{w#^$F_POa|U`Dy009@Fr* zYRrpf?O9ekPjJ;Vl|4!U63VP9-X|VJTb{VUWF+mo_AZmnoO4}mAA%d6`FyVzPg_$G zXr?T3)PwP)kkOB@KEo{yXWE22<5vXjGmD6o=6SL3<_~@j>xfw@ds2)AoLN=8KxQ2Q zndQH@PqBEuWX8D?x$7whx3KH#ND3b@;1Qjmq1c?Sdt#o^77piEDz7B^G`M@WsoXJb z;1ZAV5c1f;&zZQUp@LJgCR?rkt8klGrpMev6aG2QFOS{Y@vPlCP0h>(08sI8EF@&r|JAs71rp@O3RG4CL-^&)PD1 zA*<11}kUZ1bN z$gaLe`nC_PVg@k6i>ELghrT1-fjp4MT*3;$FMt?QKz-mWcD zcb@jqq$Zg|Lh{l5d#4QU_KAhBGQIWjk8iVOea9j<-A(n(Z_4K1**PJ_hWEY&i(1>1 zyP?_MdK<#{RI+>Ro_~LT?WD(smPwDK8{T;?G7VPju8`Wt$-~3LS=S|@bj0IA``iWF z<_2)4zL9o%_b^C7cv8pmhqu?Td}YfEQS#Y+5{&AFwMFZ?}iJ&#R(-Psds zZoheV`+i2f*qtN$=Fb1pp2cEwpZ`RH(3QejbprlXSA-qUm$L|;YK%SO?o})|Z5l(( zIp;GTTvw88r_BAl>!F_Qdd5GJ4o9oj8a{2k>ueRbWT$XL_s67yQ`+x6I4jpL;uo9n zGpqKPyYfV#BKv0&UoBFPrH6yq?NVR&WFKnpo+$LmUPGq-t3%PD<@-ULld@lrRO~zc zd7==PO_uyukNSPzCVOzQ-g0u=x^o3@%*iw7C$p`qR@i>vw|PO~ifYra-Yk*J(F*(H zFEjnD{a+ozz2v~I;Os{$!cMpOTyr_|G^6;v_VWTmGwt)&+RiVDuKrV>^}YUd^u3~h ze~b(a|Np-^%*e?wc{ii@=0|Kbj1z;T3?FcU#NQq^u(o75>a6tUI V>@rpPysjb;&(qb