diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b7e880..8b604dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/src/app/layout.zig b/src/app/layout.zig index 7d67416..73f3e12 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -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( @@ -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), }; } @@ -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))); @@ -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; }; @@ -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; } @@ -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" { diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 7d2a65b..8f74c14 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -301,6 +301,17 @@ fn adjustedRenderHeightForMode(mode: app_state.ViewMode, render_height: c_int, u }; } +/// Which sessions need full-window cell dimensions for the given view mode. +/// During Panning the previous session is still visible at full size on its way +/// off-screen, so it stays at full size until the pan completes. +fn fullSetForMode(mode: app_state.ViewMode, focused: usize, previous: usize) layout.FullSet { + return switch (mode) { + .Grid, .GridResizing => .{}, + .Full, .Expanding, .Collapsing => .{ .primary = focused }, + .PanningLeft, .PanningRight, .PanningUp, .PanningDown => .{ .primary = focused, .secondary = previous }, + }; +} + fn applyTerminalLayout( sessions: []const *SessionState, allocator: std.mem.Allocator, @@ -308,18 +319,18 @@ fn applyTerminalLayout( render_width: c_int, render_height: c_int, ui_scale: f32, - mode: app_state.ViewMode, + anim_state: *const AnimationState, grid_cols: usize, grid_rows: usize, grid_font_scale: f32, full_cols: *u16, full_rows: *u16, ) void { - const term_render_height = adjustedRenderHeightForMode(mode, render_height, ui_scale, grid_rows); - const term_size = layout.calculateTerminalSizeForMode(font, render_width, term_render_height, mode, grid_font_scale, grid_cols, grid_rows, ui_scale); - full_cols.* = term_size.cols; - full_rows.* = term_size.rows; - _ = layout.applyTerminalResize(sessions, allocator, full_cols.*, full_rows.*, render_width, term_render_height, ui_scale); + const sizes = computeTerminalSizes(font, render_width, render_height, ui_scale, grid_cols, grid_rows, grid_font_scale); + full_cols.* = sizes.full.cols; + full_rows.* = sizes.full.rows; + const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); + _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set); } fn applyTerminalLayoutIfSizeChanged( @@ -329,20 +340,34 @@ fn applyTerminalLayoutIfSizeChanged( render_width: c_int, render_height: c_int, ui_scale: f32, - mode: app_state.ViewMode, + anim_state: *const AnimationState, grid_cols: usize, grid_rows: usize, grid_font_scale: f32, full_cols: *u16, full_rows: *u16, ) bool { - const term_render_height = adjustedRenderHeightForMode(mode, render_height, ui_scale, grid_rows); - const term_size = layout.calculateTerminalSizeForMode(font, render_width, term_render_height, mode, grid_font_scale, grid_cols, grid_rows, ui_scale); - if (full_cols.* == term_size.cols and full_rows.* == term_size.rows) return false; + const sizes = computeTerminalSizes(font, render_width, render_height, ui_scale, grid_cols, grid_rows, grid_font_scale); + full_cols.* = sizes.full.cols; + full_rows.* = sizes.full.rows; + const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); + return layout.applyTerminalResize(sessions, allocator, sizes, full_set); +} - full_cols.* = term_size.cols; - full_rows.* = term_size.rows; - return layout.applyTerminalResize(sessions, allocator, full_cols.*, full_rows.*, render_width, term_render_height, ui_scale); +/// Computes both terminal sizes from the raw render dimensions. grid_size +/// always uses the Grid-mode CWD-bar reservation so unfocused sessions stay at +/// stable dims across view-mode toggles; full_size uses the raw render height. +fn computeTerminalSizes( + font: *font_mod.Font, + render_width: c_int, + render_height: c_int, + ui_scale: f32, + grid_cols: usize, + grid_rows: usize, + grid_font_scale: f32, +) layout.Sizes { + const grid_render_height = adjustedRenderHeightForMode(.Grid, render_height, ui_scale, grid_rows); + return layout.calculateTerminalSizes(font, render_width, grid_render_height, render_height, grid_font_scale, grid_cols, grid_rows, ui_scale); } const SessionIndexSnapshot = struct { @@ -688,7 +713,7 @@ fn handleExternalSpawnRequest( render_width, render_height, ui_scale, - anim_state.mode, + anim_state, grid.cols, grid.rows, grid_font_scale, @@ -846,7 +871,7 @@ const RuntimeScaleChangeContext = struct { sessions: []const *SessionState, render_width: c_int, render_height: c_int, - mode: app_state.ViewMode, + anim_state: *const AnimationState, grid_cols: usize, grid_rows: usize, grid_font_scale: f32, @@ -870,27 +895,15 @@ fn reloadRuntimeFontsForScaleChange(ctx: *RuntimeScaleChangeContext) font_mod.Fo } fn applyRuntimeResizeForScaleChange(ctx: *RuntimeScaleChangeContext) void { - const term_render_height = adjustedRenderHeightForMode(ctx.mode, ctx.render_height, ctx.ui_scale, ctx.grid_rows); - const new_term_size = layout.calculateTerminalSizeForMode( - ctx.font, - ctx.render_width, - term_render_height, - ctx.mode, - ctx.grid_font_scale, - ctx.grid_cols, - ctx.grid_rows, - ctx.ui_scale, - ); - ctx.full_cols.* = new_term_size.cols; - ctx.full_rows.* = new_term_size.rows; + const sizes = computeTerminalSizes(ctx.font, ctx.render_width, ctx.render_height, ctx.ui_scale, ctx.grid_cols, ctx.grid_rows, ctx.grid_font_scale); + ctx.full_cols.* = sizes.full.cols; + ctx.full_rows.* = sizes.full.rows; + const full_set = fullSetForMode(ctx.anim_state.mode, ctx.anim_state.focused_session, ctx.anim_state.previous_session); _ = layout.applyTerminalResize( ctx.sessions, ctx.allocator, - ctx.full_cols.*, - ctx.full_rows.*, - ctx.render_width, - term_render_height, - ctx.ui_scale, + sizes, + full_set, ); } @@ -1329,11 +1342,11 @@ pub fn run() !void { const initial_view_mode: app_state.ViewMode = if (initial_terminal_count == 1) .Full else .Grid; const initial_term_render_height = adjustedRenderHeightForMode(initial_view_mode, render_height, ui_scale, grid.rows); - const initial_term_size = layout.calculateTerminalSizeForMode(&font, render_width, initial_term_render_height, initial_view_mode, config.grid.font_scale, grid.cols, grid.rows, ui_scale); - var full_cols: u16 = initial_term_size.cols; - var full_rows: u16 = initial_term_size.rows; + const initial_sizes = computeTerminalSizes(&font, render_width, render_height, ui_scale, grid.cols, grid.rows, config.grid.font_scale); + var full_cols: u16 = initial_sizes.full.cols; + var full_rows: u16 = initial_sizes.full.rows; - std.debug.print("Grid cell terminal size: {d}x{d}\n", .{ full_cols, full_rows }); + std.debug.print("Grid cell terminal size: {d}x{d}; full size: {d}x{d}\n", .{ initial_sizes.grid.cols, initial_sizes.grid.rows, full_cols, full_rows }); const shell_path = std.posix.getenv("SHELL") orelse "/bin/zsh"; std.debug.print("Starting with {d}x{d} grid: {s}\n", .{ grid.cols, grid.rows, shell_path }); @@ -1345,9 +1358,11 @@ pub fn run() !void { const usable_width = @max(0, render_width - terminal_padding * 2); const usable_height = @max(0, initial_term_render_height - terminal_padding * 2); + // All sessions seed at grid-cell size. The first applyTerminalLayoutIfSizeChanged + // promotes the focused session to full size if the initial view is Full. const size = pty_mod.winsize{ - .ws_row = full_rows, - .ws_col = full_cols, + .ws_row = initial_sizes.grid.rows, + .ws_col = initial_sizes.grid.cols, .ws_xpixel = @intCast(usable_width), .ws_ypixel = @intCast(usable_height), }; @@ -1643,7 +1658,7 @@ pub fn run() !void { .sessions = sessions, .render_width = render_width, .render_height = render_height, - .mode = anim_state.mode, + .anim_state = &anim_state, .grid_cols = grid.cols, .grid_rows = grid.rows, .grid_font_scale = config.grid.font_scale, @@ -1872,7 +1887,7 @@ pub fn run() !void { cell_width_pixels = render_width; cell_height_pixels = render_height; anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else if (remaining_count == 1) { // Only 1 terminal remains - go directly to Full mode, no resize animation grid.cols = 1; @@ -1888,7 +1903,7 @@ pub fn run() !void { } } anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else { const new_dims = GridLayout.calculateDimensions(required_slots); const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; @@ -1928,7 +1943,7 @@ pub fn run() !void { cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); // Update focus to a valid session if (!sessions[anim_state.focused_session].spawned) { @@ -2006,7 +2021,7 @@ pub fn run() !void { font.metrics = metrics_ptr; font_size = target_size; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); persistence.font_size = font_size; @@ -2068,7 +2083,7 @@ pub fn run() !void { // Update cell dimensions for new grid cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); session_interaction_component.clearSelection(anim_state.focused_session); session_interaction_component.clearSelection(new_idx); @@ -2491,7 +2506,7 @@ pub fn run() !void { cell_width_pixels = render_width; cell_height_pixels = render_height; anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else if (remaining_count == 1) { // Only 1 terminal remains - go directly to Full mode, no resize animation grid.cols = 1; @@ -2507,7 +2522,7 @@ pub fn run() !void { } } anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else { const new_dims = GridLayout.calculateDimensions(required_slots); const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; @@ -2546,7 +2561,7 @@ pub fn run() !void { cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); if (!sessions[anim_state.focused_session].spawned) { var new_focus: usize = 0; @@ -2925,7 +2940,7 @@ pub fn run() !void { render_width, render_height, ui_scale, - anim_state.mode, + &anim_state, grid.cols, grid.rows, config.grid.font_scale, @@ -3167,6 +3182,27 @@ test "computeFrameWaitDecision defers to vsync while active" { } } +test "fullSetForMode promotes focused (and previous during panning) to full size" { + try std.testing.expectEqual(@as(?usize, null), fullSetForMode(.Grid, 2, 7).primary); + try std.testing.expectEqual(@as(?usize, null), fullSetForMode(.GridResizing, 2, 7).primary); + + const full = fullSetForMode(.Full, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), full.primary); + try std.testing.expectEqual(@as(?usize, null), full.secondary); + + const expanding = fullSetForMode(.Expanding, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), expanding.primary); + try std.testing.expectEqual(@as(?usize, null), expanding.secondary); + + const collapsing = fullSetForMode(.Collapsing, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), collapsing.primary); + try std.testing.expectEqual(@as(?usize, null), collapsing.secondary); + + const pan = fullSetForMode(.PanningLeft, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), pan.primary); + try std.testing.expectEqual(@as(?usize, 7), pan.secondary); +} + test "markTeardownComplete returns true only once" { var done = false; try std.testing.expect(markTeardownComplete(&done)); diff --git a/src/render/renderer.zig b/src/render/renderer.zig index caaeb9e..605276b 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -156,7 +156,8 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderSessionCached(renderer, session, view, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, currentWaveEffect(view, current_time), font, term_cols, term_rows, current_time, true, theme, ui_scale); + const session_dims = sessionTermDims(session, term_cols, term_rows); + try renderSessionCached(renderer, session, view, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, currentWaveEffect(view, current_time), font, session_dims.cols, session_dims.rows, current_time, true, theme, ui_scale); } } }, @@ -164,7 +165,9 @@ pub fn render( releaseNonFocusedCaches(render_cache, anim_state.focused_session); const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height }; const entry = render_cache.entry(anim_state.focused_session); - try renderSessionCached(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, true, null, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const focused_session = sessions[anim_state.focused_session]; + const focused_dims = sessionTermDims(focused_session, term_cols, term_rows); + try renderSessionCached(renderer, focused_session, &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, true, null, font, focused_dims.cols, focused_dims.rows, current_time, false, theme, ui_scale); }, .PanningLeft, .PanningRight => { const elapsed = current_time - anim_state.start_time; @@ -176,7 +179,9 @@ pub fn render( const prev_rect = Rect{ .x = pan_offset, .y = 0, .w = window_width, .h = window_height }; const prev_entry = render_cache.entry(anim_state.previous_session); - try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const prev_session = sessions[anim_state.previous_session]; + const prev_dims = sessionTermDims(prev_session, term_cols, term_rows); + try renderSession(renderer, prev_session, &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, prev_dims.cols, prev_dims.rows, current_time, false, theme, ui_scale); const new_offset = if (anim_state.mode == .PanningLeft) window_width - offset @@ -184,7 +189,9 @@ pub fn render( -window_width + offset; const new_rect = Rect{ .x = new_offset, .y = 0, .w = window_width, .h = window_height }; const new_entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const new_session = sessions[anim_state.focused_session]; + const new_dims = sessionTermDims(new_session, term_cols, term_rows); + try renderSession(renderer, new_session, &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, new_dims.cols, new_dims.rows, current_time, false, theme, ui_scale); }, .PanningUp, .PanningDown => { const elapsed = current_time - anim_state.start_time; @@ -196,7 +203,9 @@ pub fn render( const prev_rect = Rect{ .x = 0, .y = pan_offset, .w = window_width, .h = window_height }; const prev_entry = render_cache.entry(anim_state.previous_session); - try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const prev_session = sessions[anim_state.previous_session]; + const prev_dims = sessionTermDims(prev_session, term_cols, term_rows); + try renderSession(renderer, prev_session, &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, prev_dims.cols, prev_dims.rows, current_time, false, theme, ui_scale); const new_offset = if (anim_state.mode == .PanningUp) window_height - offset @@ -204,7 +213,9 @@ pub fn render( -window_height + offset; const new_rect = Rect{ .x = 0, .y = new_offset, .w = window_width, .h = window_height }; const new_entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const new_session = sessions[anim_state.focused_session]; + const new_dims = sessionTermDims(new_session, term_cols, term_rows); + try renderSession(renderer, new_session, &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, new_dims.cols, new_dims.rows, current_time, false, theme, ui_scale); }, .Expanding, .Collapsing => { const animating_rect = anim_state.getCurrentRect(current_time); @@ -232,13 +243,16 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, currentWaveEffect(&views[i], current_time), font, term_cols, term_rows, current_time, true, theme, ui_scale); + const session_dims = sessionTermDims(session, term_cols, term_rows); + try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, currentWaveEffect(&views[i], current_time), font, session_dims.cols, session_dims.rows, current_time, true, theme, ui_scale); } } const apply_effects = anim_scale < 0.999; const entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, true, theme, ui_scale); + const focused_session = sessions[anim_state.focused_session]; + const focused_dims = sessionTermDims(focused_session, term_cols, term_rows); + try renderSession(renderer, focused_session, &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, focused_dims.cols, focused_dims.rows, current_time, true, theme, ui_scale); }, .GridResizing => { // Render session contents first so borders draw on top. @@ -271,7 +285,8 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, currentWaveEffect(&views[i], current_time), font, term_cols, term_rows, current_time, true, theme, ui_scale); + const session_dims = sessionTermDims(session, term_cols, term_rows); + try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, currentWaveEffect(&views[i], current_time), font, session_dims.cols, session_dims.rows, current_time, true, theme, ui_scale); } // Render borders and overlays on top of the animated content. @@ -825,6 +840,15 @@ fn releaseCacheTexture(cache_entry: *RenderCache.Entry) void { cache_entry.cache_render_mode = .full; } +/// Returns the session's own VT dimensions, or the passed-in fallback when the +/// session hasn't been spawned yet (no terminal). Used so each grid tile and +/// panning rect renders at the session's actual cell count instead of the +/// process-global "full size" hint. +fn sessionTermDims(session: *const SessionState, fallback_cols: u16, fallback_rows: u16) struct { cols: u16, rows: u16 } { + if (session.terminal) |t| return .{ .cols = t.cols, .rows = t.rows }; + return .{ .cols = fallback_cols, .rows = fallback_rows }; +} + fn releaseNonFocusedCaches(render_cache: *RenderCache, focused_session: usize) void { for (render_cache.entries, 0..) |*cache_entry, idx| { if (idx == focused_session) continue; @@ -909,6 +933,28 @@ fn cacheNeedsRefresh( return cache_entry.cache_epoch != session_epoch or cache_entry.cache_composition != composition or cache_entry.cache_render_mode != render_mode; } +/// DEC mode 2026 (synchronized output): while a session has the mode set, the +/// app expects the terminal to suppress intermediate frames until it sends the +/// closing `\e[?2026l`. We do that by reusing the last cached texture for that +/// session instead of refreshing from the in-progress vt model. Skipped when +/// the cache has no texture yet (initial render must run), or when the +/// requested composition differs from the cached one — a composition change +/// means an overlay/wave needs to bake into the next frame, and that takes +/// priority over sync atomicity. Render-mode mismatches do NOT drop the hold: +/// holding the previous cache at a different size keeps the display stable +/// across grid↔full toggles that happen mid-sync, and one clean refresh runs +/// when `\e[?2026l` arrives. +fn synchronizedOutputHoldsCache( + session: *const SessionState, + cache_entry: *const RenderCache.Entry, + requested_composition: RenderCache.CacheComposition, +) bool { + if (cache_entry.cache_epoch == 0) return false; + if (cache_entry.cache_composition != requested_composition) return false; + const terminal = session.terminal orelse return false; + return terminal.modes.get(.synchronized_output); +} + fn refreshSessionCacheTexture( renderer: *c.SDL_Renderer, session: *SessionState, @@ -990,7 +1036,8 @@ fn renderSessionCached( const can_cache = ensureCacheTexture(renderer, cache_entry, session, rect.w, rect.h); if (can_cache) { if (cache_entry.texture) |tex| { - if (cacheNeedsRefresh(cache_entry, session.render_epoch, composition, render_mode)) { + const sync_holding = synchronizedOutputHoldsCache(session, cache_entry, composition); + if (!sync_holding and cacheNeedsRefresh(cache_entry, session.render_epoch, composition, render_mode)) { try refreshSessionCacheTexture(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, cache_overlays, composition, is_grid_view, theme, ui_scale); }