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 @@ -97,7 +97,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. Every change site only marks a dirty flag and records the time it first became dirty (`markPersistenceDirty`); the actual TOML write happens at most once per frame and only once the dirty state is at least 500ms old (`shouldSavePersistenceNow`), so a window drag or resize does not trigger a synchronous file write per mouse tick. The dirty timestamp is set once per dirty period (not refreshed by later changes), which caps the deferral at the debounce window even under continuous events.
- 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. 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. When an application enables DEC mode 40 and switches DECCOLM (`\e[?3h`/`\e[?3l`), `applyTerminalResize` preserves the ghostty-vt logical 80/132-column width while the Architect layout target is unchanged; a real Architect layout change resets the terminal model to the computed grid/full size. 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.
- 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. When an application enables DEC mode 40 and switches DECCOLM (`\e[?3h`/`\e[?3l`), `applyTerminalResize` preserves the ghostty-vt logical 80/132-column width while the Architect layout target column count is unchanged; row and pixel-size changes still update the terminal model, and DEC 2048 in-band size reports use that logical model size with the latest pixel fields. A target column-count change resets the model to the computed grid/full size. 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
94 changes: 83 additions & 11 deletions src/app/layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,11 @@ pub fn applyTerminalResize(
const terminal = &(session.terminal orelse continue);

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;
const preserve_deccolm_width = !winsize_changed and isDeccolmWidthOverride(terminal, target);
const layout_cols_changed = session.pty_size.ws_col != target.ws_col;
const preserve_deccolm_width = !layout_cols_changed and isDeccolmWidthOverride(terminal, target);
const terminal_cols = if (preserve_deccolm_width) terminal.cols else target.ws_col;
Comment thread
forketyfork marked this conversation as resolved.
const terminal_cells_changed = terminal.cols != terminal_cols or terminal.rows != target.ws_row;
const terminal_pixels_changed = terminal.width_px != @as(u32, target.ws_xpixel) or terminal.height_px != @as(u32, target.ws_ypixel);

if (winsize_changed) {
shell.pty.setSize(target) catch |err| {
Expand All @@ -216,9 +219,9 @@ pub fn applyTerminalResize(
};
}

if (terminal_cells_changed and !preserve_deccolm_width) {
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 });
if (terminal_cells_changed) {
resizeTerminal(allocator, terminal, terminal_cols, target.ws_row, target) catch |err| {
log.warn("failed to resize VT session={d} target={d}x{d}: {}", .{ session.id, terminal_cols, target.ws_row, err });
continue;
};

Expand All @@ -228,12 +231,15 @@ pub fn applyTerminalResize(
session.resetSynchronizedOutputTracking();
session.markDirty();
terminal_resized = true;
} else if (terminal_pixels_changed) {
updateTerminalPixelSize(terminal, target);
}

// DEC 2048 reports carry pixel fields, so apps tracking pixel
// geometry need them even when the cell count is unchanged.
// DEC 2048 reports describe the VT model's logical cells plus pixel
// geometry. Under DECCOLM, the logical columns can intentionally differ
// from the PTY winsize columns.
if (winsize_changed and terminal.modes.get(.in_band_size_reports)) {
sendInBandSizeReport(shell, target);
sendInBandSizeReport(shell, terminal);
}

session.pty_size = target;
Expand All @@ -243,7 +249,6 @@ pub fn applyTerminalResize(

fn isDeccolmWidthOverride(terminal: *const ghostty_vt.Terminal, target: pty_mod.winsize) bool {
if (!terminal.modes.get(.enable_mode_3)) return false;
if (terminal.rows != target.ws_row) return false;
return (terminal.cols == 80 or terminal.cols == 132) and terminal.cols != target.ws_col;
}

Expand All @@ -262,18 +267,30 @@ fn resizeTerminal(
terminal.modes.set(.synchronized_output, false);
}

fn updateTerminalPixelSize(
terminal: *ghostty_vt.Terminal,
size: pty_mod.winsize,
) void {
terminal.width_px = @intCast(size.ws_xpixel);
terminal.height_px = @intCast(size.ws_ypixel);
}

/// Write a DEC mode 2048 in-band size report to the shell. Apps that opt into
/// mode 2048 (nvim does) detect resizes via this report rather than SIGWINCH;
/// without it, they keep drawing at the pre-resize dimensions. Matches
/// ghostty's `src/termio/Termio.zig:sizeReportLocked` mode_2048 branch.
fn sendInBandSizeReport(shell: *shell_mod.Shell, size: pty_mod.winsize) void {
fn sendInBandSizeReport(shell: *shell_mod.Shell, terminal: *const ghostty_vt.Terminal) void {
var buf: [64]u8 = undefined;
const report = vt_stream.formatInBandSizeReport(&buf, size.ws_row, size.ws_col, size.ws_ypixel, size.ws_xpixel) catch return;
const report = formatTerminalInBandSizeReport(&buf, terminal) catch return;
_ = shell.write(report) catch |err| {
log.warn("failed to write in-band size report: {}", .{err});
};
}

fn formatTerminalInBandSizeReport(buf: []u8, terminal: *const ghostty_vt.Terminal) error{NoSpaceLeft}![]u8 {
return vt_stream.formatInBandSizeReport(buf, terminal.rows, terminal.cols, terminal.height_px, terminal.width_px);
}

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;
Expand Down Expand Up @@ -338,6 +355,8 @@ fn initSpawnedTestSession(
.max_scrollback = 5,
});
errdefer terminal.deinit(allocator);
terminal.width_px = @intCast(pty_size.ws_xpixel);
terminal.height_px = @intCast(pty_size.ws_ypixel);

var session = try SessionState.init(allocator, 0, "/bin/zsh", pty_size, "sock", colors_mod.Theme.default());
session.shell = .{
Expand Down Expand Up @@ -383,6 +402,59 @@ test "applyTerminalResize preserves DECCOLM width while layout target is unchang
try std.testing.expectEqual(@as(u16, 100), fixture.session.pty_size.ws_col);
}

test "applyTerminalResize preserves DECCOLM width on row-only layout changes" {
const allocator = std.testing.allocator;
const old_target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 };
var fixture = try initSpawnedTestSession(allocator, old_target, 80, 24);
defer fixture.deinit(allocator);

const terminal = try testTerminal(&fixture.session);
terminal.modes.set(.enable_mode_3, true);
terminal.modes.set(.in_band_size_reports, true);

var sessions = [_]*SessionState{&fixture.session};
const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 30), .{ .primary = 0 });

try std.testing.expect(changed);
try std.testing.expectEqual(@as(u16, 80), terminal.cols);
try std.testing.expectEqual(@as(u16, 30), terminal.rows);
try std.testing.expectEqual(@as(u16, 100), fixture.session.pty_size.ws_col);
try std.testing.expectEqual(@as(u16, 30), fixture.session.pty_size.ws_row);

var report_buf: [64]u8 = undefined;
const report = try formatTerminalInBandSizeReport(&report_buf, terminal);
try std.testing.expectEqualSlices(u8, "\x1b[48;30;80;600;1000t", report);
}

test "applyTerminalResize preserves DECCOLM width on pixel-only layout changes" {
const allocator = std.testing.allocator;
const old_target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 };
var fixture = try initSpawnedTestSession(allocator, old_target, 80, 24);
defer fixture.deinit(allocator);

const terminal = try testTerminal(&fixture.session);
terminal.modes.set(.enable_mode_3, true);
terminal.modes.set(.in_band_size_reports, true);

const sizes = Sizes{
.grid = .{ .cols = 100, .rows = 24, .width_px = 1200, .height_px = 480 },
.full = .{ .cols = 100, .rows = 24, .width_px = 1200, .height_px = 480 },
};
var sessions = [_]*SessionState{&fixture.session};
const changed = applyTerminalResize(&sessions, allocator, sizes, .{ .primary = 0 });

try std.testing.expect(!changed);
try std.testing.expectEqual(@as(u16, 80), terminal.cols);
try std.testing.expectEqual(@as(u16, 24), terminal.rows);
try std.testing.expectEqual(@as(u32, 1200), terminal.width_px);
try std.testing.expectEqual(@as(u32, 480), terminal.height_px);
try std.testing.expectEqual(@as(u16, 100), fixture.session.pty_size.ws_col);

var report_buf: [64]u8 = undefined;
const report = try formatTerminalInBandSizeReport(&report_buf, terminal);
try std.testing.expectEqualSlices(u8, "\x1b[48;24;80;480;1200t", report);
}

test "applyTerminalResize resets DECCOLM width when layout target changes" {
const allocator = std.testing.allocator;
const old_target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 };
Expand Down
Loading