introduce split-preserve-zoom config to maintain zoomed splits during navigation (#9089)

Closes #8458.

- Adds the split-preserve-zoom config option with a navigation flag.
- GTK and macOS runtimes now respect the flag so zoomed splits stay
zoomed when using split navigation. I've tested this on macOS (video
below), but have not tested on GTK.

This PR was written primarily with Codex CLI, using the
[gh-issue](https://github.com/ghostty-org/ghostty/blob/main/.agents/commands/gh-issue)
command.

Here is a short video of the debug build:


https://github.com/user-attachments/assets/3abea255-98e1-4a4f-9196-7c1b2663b9d2
pull/9633/merge
Mitchell Hashimoto 2025-12-16 11:21:56 -08:00 committed by GitHub
commit a1ffac3c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 81 additions and 2 deletions

View File

@ -621,9 +621,14 @@ class BaseTerminalController: NSWindowController,
return
}
// Remove the zoomed state for this surface tree.
if surfaceTree.zoomed != nil {
surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
if derivedConfig.splitPreserveZoom.contains(.navigation) {
surfaceTree = SplitTree(
root: surfaceTree.root,
zoomed: surfaceTree.root?.node(view: nextSurface))
} else {
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
}
}
// Move focus to the next surface
@ -1188,17 +1193,20 @@ class BaseTerminalController: NSWindowController,
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
let windowStepResize: Bool
let focusFollowsMouse: Bool
let splitPreserveZoom: Ghostty.Config.SplitPreserveZoom
init() {
self.macosTitlebarProxyIcon = .visible
self.windowStepResize = false
self.focusFollowsMouse = false
self.splitPreserveZoom = .init()
}
init(_ config: Ghostty.Config) {
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
self.windowStepResize = config.windowStepResize
self.focusFollowsMouse = config.focusFollowsMouse
self.splitPreserveZoom = config.splitPreserveZoom
}
}
}

View File

@ -124,6 +124,14 @@ extension Ghostty {
return .init(rawValue: v)
}
var splitPreserveZoom: SplitPreserveZoom {
guard let config = self.config else { return .init() }
var v: CUnsignedInt = 0
let key = "split-preserve-zoom"
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() }
return .init(rawValue: v)
}
var initialWindow: Bool {
guard let config = self.config else { return true }
var v = true;
@ -690,6 +698,12 @@ extension Ghostty.Config {
static let border = BellFeatures(rawValue: 1 << 4)
}
struct SplitPreserveZoom: OptionSet {
let rawValue: CUnsignedInt
static let navigation = SplitPreserveZoom(rawValue: 1 << 0)
}
enum MacDockDropBehavior: String {
case new_tab = "new-tab"
case new_window = "new-window"

View File

@ -340,6 +340,35 @@ pub const SplitTree = extern struct {
const surface = tree.nodes[target.idx()].leaf;
surface.grabFocus();
// We also need to setup our last_focused to this because if we
// trigger a tree change like below, the grab focus above never
// actually triggers in time to set this and this ensures we
// grab focus to the right thing.
const old_last_focused = self.private().last_focused.get();
defer if (old_last_focused) |v| v.unref(); // unref strong ref from get
self.private().last_focused.set(surface);
errdefer self.private().last_focused.set(old_last_focused);
if (tree.zoomed != null) {
const app = Application.default();
const config_obj = app.getConfig();
defer config_obj.unref();
const config = config_obj.get();
if (!config.@"split-preserve-zoom".navigation) {
tree.zoomed = null;
} else {
tree.zoom(target);
}
// When the zoom state changes our tree state changes and
// we need to send the proper notifications to trigger
// relayout.
const object = self.as(gobject.Object);
object.notifyByPspec(properties.tree.impl.param_spec);
object.notifyByPspec(properties.@"is-zoomed".impl.param_spec);
}
return true;
}

View File

@ -985,6 +985,14 @@ palette: Palette = .{},
/// Available since: 1.1.0
@"split-divider-color": ?Color = null,
/// Control when Ghostty preserves the zoomed state of a split. This is a packed
/// struct so more options can be added in the future. The `navigation` option
/// keeps the current split zoomed when split navigation (`goto_split`) changes
/// the focused split.
///
/// Example: `split-preserve-zoom = navigation`
@"split-preserve-zoom": SplitPreserveZoom = .{},
/// The foreground and background color for search matches. This only applies
/// to non-focused search matches, also known as candidate matches.
///
@ -7423,6 +7431,10 @@ pub const ShellIntegrationFeatures = packed struct {
path: bool = true,
};
pub const SplitPreserveZoom = packed struct {
navigation: bool = false,
};
pub const RepeatableCommand = struct {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,

View File

@ -222,3 +222,19 @@ test "c_get: background-blur" {
try testing.expectEqual(-2, cval);
}
}
test "c_get: split-preserve-zoom" {
const testing = std.testing;
const alloc = testing.allocator;
var c = try Config.default(alloc);
defer c.deinit();
var bits: c_uint = undefined;
try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits)));
try testing.expectEqual(@as(c_uint, 0), bits);
c.@"split-preserve-zoom".navigation = true;
try testing.expect(get(&c, .@"split-preserve-zoom", @ptrCast(&bits)));
try testing.expectEqual(@as(c_uint, 1), bits);
}