Skip to content
189 changes: 189 additions & 0 deletions cmux-linux/src/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const std = @import("std");
const c = @import("c_api.zig");
const window = @import("window.zig");
const main_mod = @import("main.zig");
const split_tree = @import("split_tree.zig");

const log = std.log.scoped(.app);

Expand Down Expand Up @@ -40,6 +41,11 @@ pub fn onAction(
c.ghostty.GHOSTTY_ACTION_COLOR_CHANGE => handleColorChange(action.action.color_change),
c.ghostty.GHOSTTY_ACTION_RELOAD_CONFIG => handleReloadConfig(action.action.reload_config),
c.ghostty.GHOSTTY_ACTION_CONFIG_CHANGE => handleConfigChange(action.action.config_change),
c.ghostty.GHOSTTY_ACTION_NEW_SPLIT => handleNewSplit(action.action.new_split),
c.ghostty.GHOSTTY_ACTION_GOTO_SPLIT => handleGotoSplit(action.action.goto_split),
c.ghostty.GHOSTTY_ACTION_RESIZE_SPLIT => handleResizeSplit(action.action.resize_split),
c.ghostty.GHOSTTY_ACTION_EQUALIZE_SPLITS => handleEqualizeSplits(),
c.ghostty.GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM => handleToggleSplitZoom(),
else => false,
};
}
Expand Down Expand Up @@ -362,6 +368,189 @@ fn handleConfigChange(change: c.ghostty.ghostty_action_config_change_s) bool {
return true;
}

// ── Split management ───────────────────────────────────────────────

/// Create a new split pane in the focused workspace.
fn handleNewSplit(direction: c.ghostty.ghostty_action_split_direction_e) bool {
const tm = window.getTabManager() orelse return false;
const ws = tm.selectedWorkspace() orelse return false;
const root = ws.root_node orelse return false;
const focused_id = ws.focused_panel_id orelse return false;

// Determine orientation from ghostty direction
const orientation: split_tree.Orientation = switch (direction) {
c.ghostty.GHOSTTY_SPLIT_DIRECTION_RIGHT,
c.ghostty.GHOSTTY_SPLIT_DIRECTION_LEFT,
=> .horizontal,
c.ghostty.GHOSTTY_SPLIT_DIRECTION_DOWN,
c.ghostty.GHOSTTY_SPLIT_DIRECTION_UP,
=> .vertical,
else => .horizontal,
};

// Find the leaf node for the focused panel
const leaf = split_tree.findLeaf(root, focused_id) orelse return false;
_ = leaf;

// Create a new terminal panel
const panel = ws.createTerminalPanel(tm.ghostty_app) catch return false;

// Find and split the node containing the focused panel
const target = findNodeByPanel(root, focused_id) orelse return false;
_ = split_tree.splitPane(
ws.alloc,
target,
orientation,
panel.id,
panel.widget,
) catch return false;

// Rebuild the GTK widget tree
rebuildWorkspaceWidget(tm, ws);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return true;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

/// Navigate to an adjacent split pane.
fn handleGotoSplit(goto: c.ghostty.ghostty_action_goto_split_e) bool {
const tm = window.getTabManager() orelse return false;
const ws = tm.selectedWorkspace() orelse return false;
const root = ws.root_node orelse return false;
const focused_id = ws.focused_panel_id orelse return false;

const direction: split_tree.TraversalDirection = switch (goto) {
c.ghostty.GHOSTTY_GOTO_SPLIT_NEXT,
c.ghostty.GHOSTTY_GOTO_SPLIT_RIGHT,
c.ghostty.GHOSTTY_GOTO_SPLIT_DOWN,
=> .next,
c.ghostty.GHOSTTY_GOTO_SPLIT_PREVIOUS,
c.ghostty.GHOSTTY_GOTO_SPLIT_LEFT,
c.ghostty.GHOSTTY_GOTO_SPLIT_UP,
=> .previous,
else => .next,
};

const target_leaf = split_tree.adjacentLeaf(root, focused_id, direction, ws.alloc) orelse return false;
ws.focused_panel_id = target_leaf.panel_id;

// Focus the target widget
if (target_leaf.widget) |w| {
_ = c.gtk.gtk_widget_grab_focus(w);
}
return true;
}

/// Resize the focused split pane.
fn handleResizeSplit(resize: c.ghostty.ghostty_action_resize_split_s) bool {
const tm = window.getTabManager() orelse return false;
const ws = tm.selectedWorkspace() orelse return false;
const root = ws.root_node orelse return false;
const focused_id = ws.focused_panel_id orelse return false;

// Map ghostty resize direction to split_tree orientation and side
const orientation: split_tree.Orientation = switch (resize.direction) {
c.ghostty.GHOSTTY_RESIZE_SPLIT_LEFT,
c.ghostty.GHOSTTY_RESIZE_SPLIT_RIGHT,
=> .horizontal,
c.ghostty.GHOSTTY_RESIZE_SPLIT_UP,
c.ghostty.GHOSTTY_RESIZE_SPLIT_DOWN,
=> .vertical,
else => return false,
};

// Growing right/down means the panel is in the first child,
// growing left/up means it's in the second child.
const in_first = switch (resize.direction) {
c.ghostty.GHOSTTY_RESIZE_SPLIT_RIGHT,
c.ghostty.GHOSTTY_RESIZE_SPLIT_DOWN,
=> true,
else => false,
};

const split = split_tree.findResizeSplit(root, focused_id, orientation, in_first) orelse return false;

// Adjust ratio by the amount (ghostty sends pixels, we convert to fraction)
const delta: f64 = @as(f64, @floatFromInt(resize.amount)) / 1000.0;
const new_ratio = if (in_first) split.ratio + delta else split.ratio - delta;
split.ratio = std.math.clamp(new_ratio, 0.1, 0.9);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

// Apply the new ratio to the GtkPaned widget
split_tree.applyRatios(root);
return true;
}

/// Equalize all split ratios in the focused workspace.
fn handleEqualizeSplits() bool {
const tm = window.getTabManager() orelse return false;
const ws = tm.selectedWorkspace() orelse return false;
const root = ws.root_node orelse return false;

split_tree.equalize(root);
split_tree.applyRatios(root);
return true;
}

/// Toggle split zoom (maximize focused pane / restore).
/// Currently a no-op placeholder — needs show/hide logic for sibling panes.
fn handleToggleSplitZoom() bool {
// TODO: implement zoom by hiding sibling panes and restoring them
log.info("toggle_split_zoom: not yet implemented", .{});
return true;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// Find the Node (leaf or split) containing a panel by its ID.
fn findNodeByPanel(node: *split_tree.Node, panel_id: u128) ?*split_tree.Node {
switch (node.*) {
.leaf => |leaf| {
if (leaf.panel_id == panel_id) return node;
return null;
},
.split => |split| {
if (findNodeByPanel(split.first, panel_id)) |n| return n;
if (findNodeByPanel(split.second, panel_id)) |n| return n;
return null;
},
}
}

/// Rebuild the workspace's GTK widget tree after a split tree mutation.
fn rebuildWorkspaceWidget(tm: *@import("tab_manager.zig").TabManager, ws: *@import("workspace.zig").Workspace) void {
const root = ws.root_node orelse return;
const old_widget = ws.content_widget;

// Build new widget tree
const new_widget = split_tree.buildWidget(root) orelse return;
ws.content_widget = new_widget;

// Replace in AdwTabView
if (tm.tab_view) |tv| {
if (old_widget) |ow| {
const page = c.gtk.adw_tab_view_get_page(tv, ow);
if (page) |p| {
// Remove old page and add new one at the same position
const idx = c.gtk.adw_tab_view_get_page_position(tv, p);
c.gtk.adw_tab_view_close_page(tv, p);
const new_page = c.gtk.adw_tab_view_insert(tv, new_widget, idx);
if (new_page) |np| {
c.gtk.adw_tab_page_set_title(np, ws.displayTitle().ptr);
c.gtk.adw_tab_view_set_selected_page(tv, np);
}
}
}
}

// Apply split ratios after widget is allocated
// Use an idle callback so GTK has time to allocate sizes
_ = c.gtk.g_idle_add(&applyRatiosIdle, root);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

/// GLib idle callback to apply split ratios after allocation.
/// Returns G_SOURCE_REMOVE so it only fires once.
fn applyRatiosIdle(data: ?*anyopaque) callconv(.c) c.gtk.gboolean {
const root: *split_tree.Node = @ptrCast(@alignCast(data orelse return c.gtk.G_SOURCE_REMOVE));
split_tree.applyRatios(root);
return c.gtk.G_SOURCE_REMOVE;
}

/// Extract the surface userdata pointer from a ghostty target.
fn getSurfaceUserdata(target: c.ghostty.ghostty_target_s) ?*anyopaque {
if (target.tag != c.ghostty.GHOSTTY_TARGET_SURFACE) return null;
Expand Down
72 changes: 72 additions & 0 deletions cmux-linux/src/split_tree.zig
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,78 @@ pub fn findResizeSplit(
}
}

/// Collect all leaf nodes in left-to-right / top-to-bottom order.
pub fn collectLeaves(node: *Node, alloc: Allocator, out: *std.ArrayList(*Leaf)) void {
switch (node.*) {
.leaf => |*leaf| out.append(alloc, leaf) catch {},
.split => |split| {
collectLeaves(split.first, alloc, out);
collectLeaves(split.second, alloc, out);
},
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// Direction for adjacent leaf navigation.
pub const TraversalDirection = enum { next, previous };

/// Find the next or previous leaf relative to the one with `panel_id`.
/// `direction`: .next or .previous (wraps around).
pub fn adjacentLeaf(
root: *Node,
panel_id: u128,
direction: TraversalDirection,
alloc: Allocator,
) ?*Leaf {
var leaves: std.ArrayList(*Leaf) = .empty;
defer leaves.deinit(alloc);
collectLeaves(root, alloc, &leaves);
if (leaves.items.len <= 1) return null;

for (leaves.items, 0..) |leaf, i| {
if (leaf.panel_id == panel_id) {
return switch (direction) {
.next => leaves.items[(i + 1) % leaves.items.len],
.previous => leaves.items[if (i == 0) leaves.items.len - 1 else i - 1],
};
}
}
return null;
}

/// Set all split ratios to 0.5 (equalize).
pub fn equalize(node: *Node) void {
switch (node.*) {
.leaf => {},
.split => |*split| {
split.ratio = 0.5;
equalize(split.first);
equalize(split.second);
},
}
}

/// Apply split ratios to GtkPaned widgets (call after widget allocation).
pub fn applyRatios(node: *Node) void {
switch (node.*) {
.leaf => {},
.split => |*split| {
if (split.paned) |paned| {
// Get the allocated size along the split axis
const size: c_int = switch (split.orientation) {
.horizontal => c.gtk.gtk_widget_get_width(paned),
.vertical => c.gtk.gtk_widget_get_height(paned),
};
if (size > 0) {
const pos: c_int = @intFromFloat(@as(f64, @floatFromInt(size)) * split.ratio);
c.gtk.gtk_paned_set_position(@ptrCast(@alignCast(paned)), pos);
}
}
applyRatios(split.first);
applyRatios(split.second);
},
}
}

/// Recursively destroy all nodes.
pub fn destroy(alloc: Allocator, node: *Node) void {
switch (node.*) {
Expand Down
Loading