Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Platform Session Rendering UI Overlay
- Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins.
- Font reload paths are transactional: acquire both replacement fonts first, then swap and destroy old fonts, so a partial reload failure cannot leave deinit hooks pointing at already-freed font resources.
- Window-resize scale handling follows a single ordered path (`reload-if-needed`, then `resize`) to keep behavior consistent between changed-scale and unchanged-scale events.
- Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Architect resizes the PTY and the ghostty-vt model whenever the effective terminal cell count changes — that includes both window resize and grid↔full view toggle, since grid tiles run at a smaller cell count than full view so content reflows into the visible area. The resize call is skipped when the cell count is unchanged. Grid view also accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. Synchronized-output mode is tracked per session purely as a stuck-mode safety net: if a session leaves `\e[?2026h` set without ever ending the sync, Architect force-clears the mode after a timeout. There is no output-hold or texture-reuse machinery beyond the standard render-epoch / view-mode cache invalidation.
- Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Each session is sized independently. The focused session in `.Full`/`.Expanding`/`.Collapsing` mode (and additionally the previous session during a panning transition) is sized to the full-window cell count; every other session stays at grid-cell size. Grid↔full view toggle therefore reflows exactly one session — the one the user actually zoomed — instead of every session in the workspace. Window resize, font size change, and grid layout change all flow through the same per-session dispatch (`fullSetForMode` in `app/runtime.zig`, `applyTerminalResize` with `Sizes`/`FullSet` in `app/layout.zig`). Grid sizing accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. While DEC mode 2026 (`\e[?2026h`) is active for a session, the renderer reuses the last cached texture for that session via `synchronizedOutputHoldsCache` in `render/renderer.zig` instead of refreshing from the in-progress vt model, so reflows from agents like Codex appear as one atomic frame change rather than a top-to-bottom rescroll. The hold is dropped when the cached composition mismatches the requested one (an overlay or wave needs to bake into the next frame) or when the app sends the closing `\e[?2026l`; the next frame after the close refreshes once and snaps to the final state. A timeout-based safety net in `session/state.zig` force-clears the mode if a session leaves `\e[?2026h` set without ever closing it.
- Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, etc.) may be imported by any layer but never import from layers above them.
- **Exception:** `app/*` modules may import `c.zig` directly for SDL type definitions used in input handling. This is a pragmatic shortcut for FFI constants, not a general license to depend on the Platform layer.

Expand Down
140 changes: 96 additions & 44 deletions src/app/layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ const SessionState = session_state.SessionState;
pub const TerminalSize = struct {
cols: u16,
rows: u16,
/// Pixel area the cells actually occupy (cols * cell_width, rows * cell_height).
/// Mirrored into `ws_xpixel`/`ws_ypixel` and DEC 2048 reports so sessions get the
/// pixel dimensions of their own rendered area (matters for grid-tile sessions,
/// which draw into a small tile rather than the whole window).
width_px: u16,
height_px: u16,
};

pub fn updateRenderSizes(
Expand Down Expand Up @@ -106,6 +112,8 @@ pub fn calculateTerminalSize(font: *const font_mod.Font, window_width: c_int, wi
return .{
.cols = @intCast(cols),
.rows = @intCast(rows),
.width_px = @intCast(cols * scaled_cell_w),
.height_px = @intCast(rows * scaled_cell_h),
};
}

Expand All @@ -115,27 +123,52 @@ pub fn calculateGridCellTerminalSize(font: *const font_mod.Font, window_width: c
return calculateTerminalSize(font, cell_width, cell_height, grid_font_scale, ui_scale);
}

pub fn calculateTerminalSizeForMode(
pub const Sizes = struct {
grid: TerminalSize,
full: TerminalSize,
};

/// `grid_window_height` should be the render height with the Grid-mode CWD-bar
/// reservation applied (and should NOT vary across view modes — unfocused
/// sessions stay at grid size permanently, so the grid dims must be stable).
/// `full_window_height` is the raw render height (the focused session uses the
/// whole window when at full size).
pub fn calculateTerminalSizes(
font: *const font_mod.Font,
window_width: c_int,
window_height: c_int,
mode: app_state.ViewMode,
grid_window_height: c_int,
full_window_height: c_int,
grid_font_scale: f32,
grid_cols: usize,
grid_rows: usize,
ui_scale: f32,
) TerminalSize {
return switch (mode) {
.Grid => {
const grid_dim = @max(grid_cols, grid_rows);
const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim));
const effective_scale: f32 = base_grid_scale * grid_font_scale;
return calculateGridCellTerminalSize(font, window_width, window_height, effective_scale, grid_cols, grid_rows, ui_scale);
},
.Collapsing, .GridResizing, .Expanding, .Full, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => calculateTerminalSize(font, window_width, window_height, 1.0, ui_scale),
) Sizes {
const grid_dim = @max(grid_cols, grid_rows);
const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim));
const effective_scale: f32 = base_grid_scale * grid_font_scale;
return .{
.grid = calculateGridCellTerminalSize(font, window_width, grid_window_height, effective_scale, grid_cols, grid_rows, ui_scale),
.full = calculateTerminalSize(font, window_width, full_window_height, 1.0, ui_scale),
};
}

/// Describes which sessions need full-window cell dimensions. All other
/// sessions remain at grid-cell size. Only the focused session in stable Full
/// mode (and the previous focused session during a panning transition) is at
/// full size; the rest are invisible in Full mode and rendered at grid-cell
/// scale in Grid mode, so paying for full-size PTYs would just force them to
/// redraw their content on every view toggle.
pub const FullSet = struct {
primary: ?usize = null,
secondary: ?usize = null,

pub fn contains(self: FullSet, idx: usize) bool {
if (self.primary) |p| if (p == idx) return true;
if (self.secondary) |s| if (s == idx) return true;
return false;
}
};

pub fn scaledFontSize(points: c_int, scale: f32) c_int {
const scaled = std.math.round(@as(f32, @floatFromInt(points)) * scale);
return @max(1, @as(c_int, @intFromFloat(scaled)));
Expand All @@ -144,45 +177,46 @@ pub fn scaledFontSize(points: c_int, scale: f32) c_int {
pub fn applyTerminalResize(
sessions: []const *SessionState,
allocator: std.mem.Allocator,
cols: u16,
rows: u16,
render_width: c_int,
render_height: c_int,
ui_scale: f32,
sizes: Sizes,
full_set: FullSet,
) bool {
const usable_width = @max(0, render_width - dpi.scale(renderer_mod.terminal_padding, ui_scale) * 2);
const usable_height = @max(0, render_height - dpi.scale(renderer_mod.terminal_padding, ui_scale) * 2);

const new_size = pty_mod.winsize{
.ws_row = rows,
.ws_col = cols,
.ws_xpixel = @intCast(usable_width),
.ws_ypixel = @intCast(usable_height),
const grid_size = pty_mod.winsize{
.ws_row = sizes.grid.rows,
.ws_col = sizes.grid.cols,
.ws_xpixel = sizes.grid.width_px,
.ws_ypixel = sizes.grid.height_px,
};
const full_size = pty_mod.winsize{
.ws_row = sizes.full.rows,
.ws_col = sizes.full.cols,
.ws_xpixel = sizes.full.width_px,
.ws_ypixel = sizes.full.height_px,
};

var terminal_resized = false;
for (sessions) |session| {
for (sessions, 0..) |session, idx| {
const target = if (full_set.contains(idx)) full_size else grid_size;
if (!session.spawned) {
session.pty_size = new_size;
session.pty_size = target;
continue;
}

const shell = &(session.shell orelse continue);
const terminal = &(session.terminal orelse continue);

const winsize_changed = !std.meta.eql(session.pty_size, new_size);
const terminal_cells_changed = terminal.cols != cols or terminal.rows != rows;
const winsize_changed = !std.meta.eql(session.pty_size, target);
const terminal_cells_changed = terminal.cols != target.ws_col or terminal.rows != target.ws_row;

if (winsize_changed) {
shell.pty.setSize(new_size) catch |err| {
log.warn("failed to resize PTY session={d} target={d}x{d}: {}", .{ session.id, cols, rows, err });
shell.pty.setSize(target) catch |err| {
log.warn("failed to resize PTY session={d} target={d}x{d}: {}", .{ session.id, target.ws_col, target.ws_row, err });
continue;
};
}

if (terminal_cells_changed) {
resizeTerminal(allocator, terminal, cols, rows, new_size) catch |err| {
log.warn("failed to resize VT session={d} target={d}x{d}: {}", .{ session.id, cols, rows, err });
resizeTerminal(allocator, terminal, target.ws_col, target.ws_row, target) catch |err| {
log.warn("failed to resize VT session={d} target={d}x{d}: {}", .{ session.id, target.ws_col, target.ws_row, err });
continue;
};

Expand All @@ -197,10 +231,10 @@ pub fn applyTerminalResize(
// DEC 2048 reports carry pixel fields, so apps tracking pixel
// geometry need them even when the cell count is unchanged.
if (winsize_changed and terminal.modes.get(.in_band_size_reports)) {
sendInBandSizeReport(shell, new_size);
sendInBandSizeReport(shell, target);
}

session.pty_size = new_size;
session.pty_size = target;
}
return terminal_resized;
}
Expand Down Expand Up @@ -232,20 +266,38 @@ fn sendInBandSizeReport(shell: *shell_mod.Shell, size: pty_mod.winsize) void {
};
}

test "grid mode sizes terminals to the rendered tile area" {
test "calculateTerminalSizes returns smaller grid than full and shrinks grid further when font scale shrinks" {
var font: font_mod.Font = undefined;
font.cell_width = 10;
font.cell_height = 20;

const normal = calculateTerminalSizes(&font, 1200, 800, 800, 1.0, 2, 1, 1.0);
const enlarged = calculateTerminalSizes(&font, 1200, 800, 800, 2.0, 2, 1, 1.0);

try std.testing.expect(normal.grid.cols < normal.full.cols);
try std.testing.expect(enlarged.grid.cols < normal.grid.cols);
try std.testing.expectEqual(normal.full, enlarged.full);
}

test "calculateTerminalSizes grid dims stay stable when only full height changes" {
var font: font_mod.Font = undefined;
font.cell_width = 10;
font.cell_height = 20;

const full = calculateTerminalSizeForMode(&font, 1200, 800, .Full, 1.0, 2, 1, 1.0);
const normal_grid = calculateTerminalSizeForMode(&font, 1200, 800, .Grid, 1.0, 2, 1, 1.0);
const enlarged_grid = calculateTerminalSizeForMode(&font, 1200, 800, .Grid, 2.0, 2, 1, 1.0);
// grid_window_height held constant; full_window_height varies (the typical
// case across view-mode toggles where the CWD-bar reservation only changes
// when the actual grid layout changes).
const a = calculateTerminalSizes(&font, 1200, 700, 800, 1.0, 2, 1, 1.0);
const b = calculateTerminalSizes(&font, 1200, 700, 750, 1.0, 2, 1, 1.0);
try std.testing.expectEqual(a.grid, b.grid);
}

try std.testing.expect(normal_grid.cols < full.cols);
try std.testing.expect(enlarged_grid.cols < normal_grid.cols);
try std.testing.expectEqual(full, calculateTerminalSizeForMode(&font, 1200, 800, .Expanding, 1.0, 2, 1, 1.0));
try std.testing.expectEqual(full, calculateTerminalSizeForMode(&font, 1200, 800, .Collapsing, 1.0, 2, 1, 1.0));
try std.testing.expectEqual(full, calculateTerminalSizeForMode(&font, 1200, 800, .GridResizing, 1.0, 2, 1, 1.0));
test "FullSet.contains identifies primary and secondary indices" {
try std.testing.expect(!(FullSet{}).contains(0));
try std.testing.expect((FullSet{ .primary = 3 }).contains(3));
try std.testing.expect(!(FullSet{ .primary = 3 }).contains(2));
try std.testing.expect((FullSet{ .primary = 3, .secondary = 5 }).contains(5));
try std.testing.expect(!(FullSet{ .primary = 3, .secondary = 5 }).contains(4));
}

test "terminal resize preserves prompt contents when shell does not redraw" {
Expand Down
Loading