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 follow Ghostty's ordering: update the PTY winsize first, then resize the ghostty-vt terminal model, allowing ghostty-vt's semantic prompt clearing when the shell opted into prompt redraws. Architect records new terminal row/column sizes only after resize calls succeed and skips PTY resize calls when the terminal row/column count is unchanged. Grid/full transitions update the backing PTY/VT size after the stable view mode is reached, and grid sizing accounts for grid font scaling plus the reserved CWD-bar space so compact tiles wrap to the visible area. Grid rendering follows the active cursor row so compact tiles show short sessions from the top and long sessions near the latest output. While a visible session is in synchronized output mode, Architect holds the previous frame and waits for the app's final repaint, with a timeout to avoid a stuck frozen frame.
- Terminal resizes follow Ghostty's ordering: update the PTY winsize first, then resize the ghostty-vt terminal model, allowing ghostty-vt's semantic prompt clearing when the shell opted into prompt redraws. Architect records new terminal row/column sizes only after resize calls succeed and skips PTY resize calls when the terminal row/column count is unchanged. Grid/full transitions update the backing PTY/VT size after the stable view mode is reached, and grid sizing accounts for grid font scaling plus the reserved CWD-bar space so compact tiles wrap to the visible area. Grid rendering follows the active cursor row so compact tiles show short sessions from the top and long sessions near the latest output. During synchronized output and immediately after terminal resizes, Architect reuses that session's last rendered texture when it matches the current render mode and size, while continuing to present the rest of the scene. Synchronized-output holds wait for output to go quiet with a hard cap; resize holds are short settle windows so continuously streaming sessions do not stay hidden behind stale pre-resize textures.
- 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
2 changes: 2 additions & 0 deletions src/app/layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ pub fn applyTerminalResize(
if (session.stream == null) {
session.stream = vt_stream.initStream(allocator, terminal, shell);
}
session.resetSynchronizedOutputTracking();
session.beginTerminalResizeHold(std.time.milliTimestamp());
session.markDirty();
terminal_resized = true;
}
Expand Down
30 changes: 15 additions & 15 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ fn waitTimeoutMsFromNs(remaining_ns: u64) c_int {
return @intCast(@min(timeout_ms, max_timeout_ms));
}

fn computeFrameWaitDecision(is_idle: bool, vsync_enabled: bool, frame_ns: i128, holding_synchronized_output: bool) FrameWaitDecision {
if (holding_synchronized_output) {
fn computeFrameWaitDecision(is_idle: bool, vsync_enabled: bool, frame_ns: i128, visible_output_hold: bool) FrameWaitDecision {
if (visible_output_hold) {
const sleep_ns = remainingFrameBudgetNs(active_frame_ns, frame_ns);
return if (sleep_ns > 0) .{ .active_sleep_ns = sleep_ns } else .none;
}
Expand Down Expand Up @@ -306,7 +306,7 @@ fn adjustedRenderHeightForMode(mode: app_state.ViewMode, render_height: c_int, u
};
}

fn anyVisibleSessionSynchronizedOutput(
fn anyVisibleSessionOutputHold(
sessions: []const *SessionState,
anim_state: *const AnimationState,
grid_cols: usize,
Expand All @@ -316,18 +316,18 @@ fn anyVisibleSessionSynchronizedOutput(
.Grid, .GridResizing => blk: {
const visible_count = @min(sessions.len, grid_cols * grid_rows);
for (sessions[0..visible_count]) |session| {
if (session.synchronizedOutputActive()) break :blk true;
if (session.outputHoldActive()) break :blk true;
}
break :blk false;
},
.Full => synchronizedOutputActiveAt(sessions, anim_state.focused_session),
.Expanding, .Collapsing, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => synchronizedOutputActiveAt(sessions, anim_state.focused_session) or
synchronizedOutputActiveAt(sessions, anim_state.previous_session),
.Full => outputHoldActiveAt(sessions, anim_state.focused_session),
.Expanding, .Collapsing, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => outputHoldActiveAt(sessions, anim_state.focused_session) or
outputHoldActiveAt(sessions, anim_state.previous_session),
};
}

fn synchronizedOutputActiveAt(sessions: []const *SessionState, idx: usize) bool {
return idx < sessions.len and sessions[idx].synchronizedOutputActive();
fn outputHoldActiveAt(sessions: []const *SessionState, idx: usize) bool {
return idx < sessions.len and sessions[idx].outputHoldActive();
}

fn applyTerminalLayout(
Expand Down Expand Up @@ -2320,6 +2320,7 @@ pub fn run() !void {
const prev_cwd_ptr = if (session.cwd_path) |p| p.ptr else null;
session.updateCwd(now);
_ = session.expireSynchronizedOutput(now);
_ = session.expireTerminalResizeHold(now);
if (session.cwd_path) |new_cwd| {
// Compare pointers: if they differ, cwd changed (and old memory was freed by updateCwd)
const changed = prev_cwd_ptr == null or prev_cwd_ptr != new_cwd.ptr;
Expand Down Expand Up @@ -3001,11 +3002,10 @@ pub fn run() !void {
);

const animating = anim_state.mode != .Grid and anim_state.mode != .Full;
const holding_synchronized_output = anyVisibleSessionSynchronizedOutput(sessions, &anim_state, grid.cols, grid.rows);
const visible_output_hold = anyVisibleSessionOutputHold(sessions, &anim_state, grid.cols, grid.rows);
const ui_needs_frame = ui.needsFrame(&ui_render_host);
const last_render_stale = last_render_ns == 0 or (frame_start_ns - last_render_ns) >= max_idle_render_gap_ns;
const should_render = !holding_synchronized_output and
(animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or had_control_requests or last_render_stale);
const should_render = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or had_control_requests or last_render_stale;

if (should_render) {
if (relaunch_trace_frames > 0) {
Expand Down Expand Up @@ -3056,7 +3056,7 @@ pub fn run() !void {

const frame_end_ns: i128 = std.time.nanoTimestamp();
const frame_ns = frame_end_ns - frame_start_ns;
next_frame_wait = computeFrameWaitDecision(is_idle, sdl.vsync_enabled, frame_ns, holding_synchronized_output);
next_frame_wait = computeFrameWaitDecision(is_idle, sdl.vsync_enabled, frame_ns, visible_output_hold);
}

if (builtin.os.tag == .macos) {
Expand Down Expand Up @@ -3246,9 +3246,9 @@ test "full view synchronized hold ignores previous session" {
.target_rect = rect,
};

try std.testing.expect(!anyVisibleSessionSynchronizedOutput(&sessions, &anim_state, 2, 1));
try std.testing.expect(!anyVisibleSessionOutputHold(&sessions, &anim_state, 2, 1));
anim_state.mode = .Expanding;
try std.testing.expect(anyVisibleSessionSynchronizedOutput(&sessions, &anim_state, 2, 1));
try std.testing.expect(anyVisibleSessionOutputHold(&sessions, &anim_state, 2, 1));
}

test "markTeardownComplete returns true only once" {
Expand Down
77 changes: 76 additions & 1 deletion src/render/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ fn renderSession(
theme: *const colors.Theme,
ui_scale: f32,
) RenderError!void {
if (renderHeldSessionTexture(renderer, session, view, cache_entry, rect, is_focused, apply_effects, true, null, current_time_ms, is_grid_view, theme, ui_scale)) return;

try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, current_time_ms, is_grid_view, theme, ui_scale);
renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme, ui_scale);
cache_entry.presented_epoch = session.render_epoch;
Expand Down Expand Up @@ -909,6 +911,65 @@ fn cacheNeedsRefresh(
return cache_entry.cache_epoch != session_epoch or cache_entry.cache_composition != composition or cache_entry.cache_render_mode != render_mode;
}

fn shouldHoldSessionCacheRefresh(
output_hold_active: bool,
has_texture: bool,
cache_epoch: u64,
cache_width: c_int,
cache_height: c_int,
requested_rect: Rect,
cached_render_mode: RenderCache.CacheRenderMode,
requested_render_mode: RenderCache.CacheRenderMode,
) bool {
return output_hold_active and
has_texture and
cache_epoch != 0 and
cache_width == requested_rect.w and
cache_height == requested_rect.h and
cached_render_mode == requested_render_mode;
}

fn renderHeldSessionTexture(
renderer: *c.SDL_Renderer,
session: *SessionState,
view: *SessionViewState,
cache_entry: *RenderCache.Entry,
rect: Rect,
is_focused: bool,
apply_effects: bool,
render_overlays: bool,
wave_effect: ?WaveEffect,
current_time_ms: i64,
is_grid_view: bool,
theme: *const colors.Theme,
ui_scale: f32,
) bool {
const render_mode = cacheRenderMode(is_grid_view);
if (!shouldHoldSessionCacheRefresh(
session.outputHoldActive(),
cache_entry.texture != null,
cache_entry.cache_epoch,
cache_entry.width,
cache_entry.height,
rect,
cache_entry.cache_render_mode,
render_mode,
)) return false;

const tex = cache_entry.texture orelse return false;
if (wave_effect) |wave| {
renderWaveStrips(renderer, tex, rect, wave.elapsed_ms, wave.amplitude, wave.total_ms);
} else {
renderCachedTexture(renderer, tex, rect);
}

if (render_overlays and cache_entry.cache_composition == .content_only) {
renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme, ui_scale);
}
Comment thread
forketyfork marked this conversation as resolved.
cache_entry.presented_epoch = session.render_epoch;
return true;
}

fn refreshSessionCacheTexture(
renderer: *c.SDL_Renderer,
session: *SessionState,
Expand Down Expand Up @@ -983,11 +1044,13 @@ fn renderSessionCached(
return;
}

const can_cache = ensureCacheTexture(renderer, cache_entry, session, rect.w, rect.h);
const cache_overlays = render_overlays and wave_effect != null;
const composition = cacheComposition(cache_overlays);
const render_mode = cacheRenderMode(is_grid_view);

if (renderHeldSessionTexture(renderer, session, view, cache_entry, rect, is_focused, apply_effects, render_overlays, wave_effect, current_time_ms, is_grid_view, theme, ui_scale)) return;

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)) {
Expand Down Expand Up @@ -1017,6 +1080,18 @@ fn renderSessionCached(
cache_entry.presented_epoch = session.render_epoch;
}

test "synchronized output hold reuses populated cache only" {
const rect = Rect{ .x = 0, .y = 0, .w = 100, .h = 80 };

try std.testing.expect(shouldHoldSessionCacheRefresh(true, true, 1, 100, 80, rect, .grid, .grid));
try std.testing.expect(!shouldHoldSessionCacheRefresh(false, true, 1, 100, 80, rect, .grid, .grid));
try std.testing.expect(!shouldHoldSessionCacheRefresh(true, false, 1, 100, 80, rect, .grid, .grid));
try std.testing.expect(!shouldHoldSessionCacheRefresh(true, true, 0, 100, 80, rect, .grid, .grid));
try std.testing.expect(!shouldHoldSessionCacheRefresh(true, true, 1, 99, 80, rect, .grid, .grid));
try std.testing.expect(!shouldHoldSessionCacheRefresh(true, true, 1, 100, 79, rect, .grid, .grid));
try std.testing.expect(!shouldHoldSessionCacheRefresh(true, true, 1, 100, 80, rect, .grid, .full));
}

/// Render the cached tile texture in horizontal strips with per-strip wave scaling.
/// The wave sweeps from bottom to top: bottom strips animate first, top strips last.
/// Only the width of each strip is scaled (centered horizontally), preserving vertical layout.
Expand Down
Loading