diff --git a/docs/architecture.md b/docs/architecture.md index fdac98a..6c86b34 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi 8. Calls `renderer.render` for the scene, then `ui.render` for overlays, then presents. 9. Sleeps based on idle/active frame targets (~16ms active, ~50ms idle). -`SessionState.dirty` is set on terminal updates and cleared after a successful render (cached grid or full view). When collapsing from full view to grid, the focused session is marked dirty so its cache refreshes before idle throttling resumes. +`SessionState.render_epoch` increments on terminal updates, and the renderer cache tracks the last presented/cache epochs. When collapsing from full view to grid, the focused session’s render epoch is bumped so its cache refreshes before idle throttling resumes. **Terminal resizing** - `applyTerminalResize` updates the PTY size first, then resizes the `ghostty-vt` terminal. @@ -232,16 +232,21 @@ struct { theme: *const Theme, // Active color theme } ``` +`UiHost` is rebuilt on demand using a shared, preallocated `SessionUiInfo` buffer; treat it as a transient snapshot and do not retain its slices across calls. +`focused_has_foreground_process` is populated from a short-lived cache in `main.zig` to avoid per-frame syscalls; UI code should treat it as a hint and action handlers should recheck directly when needed. ## Data & State Boundaries | Layer | State Location | What it contains | |-------|----------------|------------------| -| Scene | `src/session/state.zig` | PTY, terminal buffer, scroll position, CWD, cache texture | +| Scene | `src/session/state.zig` | PTY, terminal buffer, scroll position, CWD, render epoch | | Scene | `src/app/app_state.zig` | ViewMode, animation rects, focused session index | +| Renderer | `src/render/renderer.zig` (`RenderCache`) | Per-session cache textures and last presented/cache epochs | | UI | Component structs | Visibility flags, animation timers, cached textures | | Shared | `UiHost` | Read-only snapshot passed each frame | +`SessionState` bumps its render epoch on content changes; the renderer compares this against `RenderCache` epochs to decide when to redraw and when to refresh cached textures. + **Key rule**: Scene code must not own UI state; UI state lives inside components. ## Input Routing @@ -283,7 +288,7 @@ Each `SessionState` contains: - `terminal`: ghostty-vt terminal state machine - `stream`: VT stream wrapper for output processing - `process_watcher`: xev-based async process exit detection -- `cache_texture`: Cached render for grid view (dirty flag optimization) +- `render_epoch`: Monotonic counter for renderer invalidation - `pending_write`: Buffered stdin for non-blocking writes Sessions are lazily spawned: only session 0 starts on launch; others spawn on first click/navigation. @@ -363,7 +368,7 @@ Coordinates multiple pill overlays: 6. **Lazy spawning**: Sessions spawn on demand, not at startup (except session 0). -7. **Cache invalidation**: Set `session.dirty = true` after any terminal content change. +7. **Cache invalidation**: Call `session.markDirty()` after any terminal content change. ## Dependencies diff --git a/docs/engine_plan_correction.md b/docs/engine_plan_correction.md index a6ada05..77ed02e 100644 --- a/docs/engine_plan_correction.md +++ b/docs/engine_plan_correction.md @@ -103,19 +103,11 @@ The UI framework refactor outlined in engine_plan.md has been **fully completed* - Removed unused CWD-related constants and `input` import from `renderer.zig` - Registered `CwdBarComponent` with `UiRoot` in `main.zig` -### 2. Cache Texture Still on SessionState ⚠️ LOW PRIORITY (NOT A DEVIATION) +### 2. Renderer-Owned Cache ✅ RESOLVED -**Location:** `src/session/state.zig` +**Location:** `src/render/renderer.zig` (`RenderCache`) -```zig -cache_texture: ?*c.SDL_Texture = null, -cache_w: c_int = 0, -cache_h: c_int = 0, -``` - -**Analysis:** This is used by `renderGridSessionCached` in renderer.zig for caching terminal content in grid view. This is arguably **scene state**, not UI state, because it caches the terminal cell content itself (not UI overlays). The plan's invariant was about UI state/textures, and this cache is for the terminal scene. - -**Verdict:** Not a deviation - this is scene caching, not UI caching. No action needed. +**Change:** The grid render cache now lives in the renderer as a per-session cache table, and `SessionState` exposes a `render_epoch` counter for invalidation. This keeps render resources owned by the renderer while preserving scene/UI separation. --- @@ -153,7 +145,7 @@ These align with Section 10's prediction: "Once the framework exists, these beco ### Future Considerations 1. **Document the scene vs UI texture distinction** - - `cache_texture` for terminal content = scene (OK on session) + - Terminal content caches = renderer-owned (`RenderCache`) - Text/label textures for bars/overlays = UI (should be in components) 2. **Consider unifying pill overlays** diff --git a/src/main.zig b/src/main.zig index 67fac9a..8a26e8f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -40,6 +40,7 @@ const UI_FONT_SIZE: c_int = 18; const ACTIVE_FRAME_NS: i128 = 16_666_667; const IDLE_FRAME_NS: i128 = 50_000_000; const MAX_IDLE_RENDER_GAP_NS: i128 = 250_000_000; +const FOREGROUND_PROCESS_CACHE_MS: i64 = 150; const SessionStatus = app_state.SessionStatus; const ViewMode = app_state.ViewMode; const Rect = app_state.Rect; @@ -51,6 +52,26 @@ const FontSizeDirection = input.FontSizeDirection; const GridNavDirection = input.GridNavDirection; const CursorKind = enum { arrow, ibeam, pointer }; +const ForegroundProcessCache = struct { + session_idx: ?usize = null, + last_check_ms: i64 = 0, + value: bool = false, + + fn get(self: *ForegroundProcessCache, now_ms: i64, focused_session: usize, sessions: []const SessionState) bool { + if (self.session_idx != focused_session) { + self.session_idx = focused_session; + self.last_check_ms = 0; + } + if (self.last_check_ms == 0 or now_ms < self.last_check_ms or + now_ms - self.last_check_ms >= FOREGROUND_PROCESS_CACHE_MS) + { + self.value = sessions[focused_session].hasForegroundProcess(); + self.last_check_ms = now_ms; + } + return self.value; + } +}; + const ImeComposition = struct { codepoints: usize = 0, @@ -336,6 +357,14 @@ pub fn main() !void { init_count = sessions.len; + const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); + defer allocator.free(session_ui_info); + + var render_cache = try renderer_mod.RenderCache.init(allocator, grid_count); + defer render_cache.deinit(); + + var foreground_cache = ForegroundProcessCache{}; + var running = true; var anim_state = AnimationState{ @@ -419,8 +448,7 @@ pub fn main() !void { } processed_event = true; var scaled_event = scaleEventToRender(&event, scale_x, scale_y); - const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); - defer allocator.free(session_ui_info); + const focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); const ui_host = makeUiHost( now, render_width, @@ -433,6 +461,7 @@ pub fn main() !void { &anim_state, sessions, session_ui_info, + focused_has_foreground_process, &theme, ); @@ -618,7 +647,7 @@ pub fn main() !void { } } session.deinit(allocator); - session.dirty = true; + session.markDirty(); } continue; } @@ -764,7 +793,7 @@ pub fn main() !void { } try navigateGrid(&anim_state, sessions, direction, now, true, false, grid_cols, grid_rows, &loop); const new_session = anim_state.focused_session; - sessions[new_session].dirty = true; + sessions[new_session].markDirty(); std.debug.print("Grid nav to session {d} (with wrapping)\n", .{new_session}); } else if (anim_state.mode == .Full) { if (config.ui.show_hotkey_feedback) { @@ -962,26 +991,26 @@ pub fn main() !void { focused.hovered_link_start = link_match.start_pin; focused.hovered_link_end = link_match.end_pin; allocator.free(link_match.url); - focused.dirty = true; + focused.markDirty(); } else { desired_cursor = .ibeam; focused.hovered_link_start = null; focused.hovered_link_end = null; - focused.dirty = true; + focused.markDirty(); } } else { desired_cursor = .ibeam; if (focused.hovered_link_start != null) { focused.hovered_link_start = null; focused.hovered_link_end = null; - focused.dirty = true; + focused.markDirty(); } } } else { if (focused.hovered_link_start != null) { focused.hovered_link_start = null; focused.hovered_link_end = null; - focused.dirty = true; + focused.markDirty(); } } } @@ -1080,7 +1109,6 @@ pub fn main() !void { try loop.run(.no_wait); - var any_session_dirty = false; var has_scroll_inertia = false; for (sessions) |*session| { session.checkAlive(); @@ -1088,9 +1116,9 @@ pub fn main() !void { try session.flushPendingWrites(); session.updateCwd(now); updateScrollInertia(session, delta_time_s); - any_session_dirty = any_session_dirty or session.dirty; has_scroll_inertia = has_scroll_inertia or (session.scroll_velocity != 0.0); } + const any_session_dirty = render_cache.anyDirty(sessions); var notifications = notify_queue.drainAll(); defer notifications.deinit(allocator); @@ -1109,8 +1137,7 @@ pub fn main() !void { } } - const ui_update_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); - defer allocator.free(ui_update_info); + var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); const ui_update_host = makeUiHost( now, render_width, @@ -1122,7 +1149,8 @@ pub fn main() !void { grid_rows, &anim_state, sessions, - ui_update_info, + session_ui_info, + focused_has_foreground_process, &theme, ); ui.update(&ui_update_host); @@ -1144,7 +1172,7 @@ pub fn main() !void { } } sessions[idx].deinit(allocator); - sessions[idx].dirty = true; + sessions[idx].markDirty(); std.debug.print("UI requested despawn: {d}\n", .{idx}); } }, @@ -1329,7 +1357,7 @@ pub fn main() !void { }; anim_state.mode = next_mode; if (previous_mode == .Collapsing and next_mode == .Grid and anim_state.focused_session < sessions.len) { - sessions[anim_state.focused_session].dirty = true; + sessions[anim_state.focused_session].markDirty(); } std.debug.print("Animation complete, new mode: {s}\n", .{@tagName(anim_state.mode)}); } @@ -1350,8 +1378,7 @@ pub fn main() !void { }); } - const ui_render_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); - defer allocator.free(ui_render_info); + focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); const ui_render_host = makeUiHost( now, render_width, @@ -1363,7 +1390,8 @@ pub fn main() !void { grid_rows, &anim_state, sessions, - ui_render_info, + session_ui_info, + focused_has_foreground_process, &theme, ); @@ -1373,7 +1401,7 @@ pub fn main() !void { const should_render = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or last_render_stale; if (should_render) { - try renderer_mod.render(renderer, sessions, cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale); + try renderer_mod.render(renderer, &render_cache, sessions, cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale); ui.render(&ui_render_host, renderer); _ = c.SDL_RenderPresent(renderer); metrics_mod.increment(.frame_count); @@ -1623,6 +1651,7 @@ fn makeUiHost( anim_state: *const AnimationState, sessions: []const SessionState, buffer: []ui_mod.SessionUiInfo, + focused_has_foreground_process: bool, theme: *const colors_mod.Theme, ) ui_mod.UiHost { for (sessions, 0..) |session, i| { @@ -1636,7 +1665,6 @@ fn makeUiHost( const focused_session = &sessions[anim_state.focused_session]; const focused_cwd = focused_session.cwd_path; - const focused_has_foreground_process = focused_session.hasForegroundProcess(); return .{ .now_ms = now, @@ -1700,7 +1728,7 @@ fn scrollSession(session: *SessionState, delta: isize, now: i64) void { var pages = &terminal.screens.active.pages; pages.scroll(.{ .delta_row = delta }); session.is_viewing_scrollback = (pages.viewport != .active); - session.dirty = true; + session.markDirty(); } const sensitivity: f32 = 0.08; @@ -1734,7 +1762,7 @@ fn updateScrollInertia(session: *SessionState, delta_time_s: f32) void { var pages = &terminal.screens.active.pages; pages.scroll(.{ .delta_row = scroll_lines }); session.is_viewing_scrollback = (pages.viewport != .active); - session.dirty = true; + session.markDirty(); } session.scroll_remainder = scroll_amount - @as(f32, @floatFromInt(scroll_lines)); @@ -1832,7 +1860,7 @@ fn applyTerminalResize( session.stream = vt_stream.initStream(allocator, terminal, shell); } - session.dirty = true; + session.markDirty(); } } } @@ -1948,7 +1976,7 @@ fn beginSelection(session: *SessionState, pin: ghostty_vt.Pin) void { session.selection_anchor = pin; session.selection_pending = true; session.selection_dragging = false; - session.dirty = true; + session.markDirty(); } fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { @@ -1962,7 +1990,7 @@ fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { log.warn("session {d}: failed to start selection: {}", .{ session.id, err }); }; - session.dirty = true; + session.markDirty(); } fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { @@ -1972,7 +2000,7 @@ fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { log.warn("session {d}: failed to update selection: {}", .{ session.id, err }); }; - session.dirty = true; + session.markDirty(); } fn endSelection(session: *SessionState) void { @@ -2066,7 +2094,7 @@ fn selectWord(session: *SessionState, pin: ghostty_vt.Pin, is_viewing_scrollback log.err("failed to select word: {}", .{err}); return; }; - session.dirty = true; + session.markDirty(); } /// Select the entire line at the given pin position. @@ -2102,7 +2130,7 @@ fn selectLine(session: *SessionState, pin: ghostty_vt.Pin, is_viewing_scrollback log.err("failed to select line: {}", .{err}); return; }; - session.dirty = true; + session.markDirty(); } const LinkMatch = struct { @@ -2471,7 +2499,7 @@ fn clearTerminal(session: *SessionState) void { terminal.screens.active.clearSelection(); terminal.eraseDisplay(ghostty_vt.EraseDisplay.scrollback, false); terminal.eraseDisplay(ghostty_vt.EraseDisplay.complete, false); - session.dirty = true; + session.markDirty(); // Trigger shell redraw like Ghostty (FF) so the prompt is repainted at top. session.sendInput(&[_]u8{0x0C}) catch |err| { diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 4de53dc..96cc346 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -24,8 +24,52 @@ const DARK_FALLBACK = c.SDL_Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; pub const RenderError = font_mod.Font.RenderGlyphError; +pub const RenderCache = struct { + allocator: std.mem.Allocator, + entries: []Entry, + + pub const Entry = struct { + texture: ?*c.SDL_Texture = null, + width: c_int = 0, + height: c_int = 0, + cache_epoch: u64 = 0, + presented_epoch: u64 = 0, + }; + + pub fn init(allocator: std.mem.Allocator, session_count: usize) !RenderCache { + const entries = try allocator.alloc(Entry, session_count); + for (entries) |*cache_entry| { + cache_entry.* = .{}; + } + return .{ .allocator = allocator, .entries = entries }; + } + + pub fn deinit(self: *RenderCache) void { + for (self.entries) |cache_entry| { + if (cache_entry.texture) |tex| { + c.SDL_DestroyTexture(tex); + } + } + self.allocator.free(self.entries); + self.entries = &[_]Entry{}; + } + + pub fn entry(self: *RenderCache, idx: usize) *Entry { + return &self.entries[idx]; + } + + pub fn anyDirty(self: *RenderCache, sessions: []const SessionState) bool { + std.debug.assert(sessions.len == self.entries.len); + for (sessions, 0..) |session, i| { + if (session.render_epoch != self.entries[i].presented_epoch) return true; + } + return false; + } +}; + pub fn render( renderer: *c.SDL_Renderer, + render_cache: *RenderCache, sessions: []SessionState, cell_width_pixels: c_int, cell_height_pixels: c_int, @@ -63,12 +107,14 @@ pub fn render( .h = cell_height_pixels, }; - try renderGridSessionCached(renderer, session, cell_rect, grid_scale, i == anim_state.focused_session, true, font, term_cols, term_rows, current_time, theme); + const entry = render_cache.entry(i); + try renderGridSessionCached(renderer, session, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, font, term_cols, term_rows, current_time, theme); } }, .Full => { const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height }; - try renderSession(renderer, &sessions[anim_state.focused_session], full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + const entry = render_cache.entry(anim_state.focused_session); + try renderSession(renderer, &sessions[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .PanningLeft, .PanningRight => { const elapsed = current_time - anim_state.start_time; @@ -79,14 +125,16 @@ pub fn render( const pan_offset = if (anim_state.mode == .PanningLeft) -offset else offset; const prev_rect = Rect{ .x = pan_offset, .y = 0, .w = window_width, .h = window_height }; - try renderSession(renderer, &sessions[anim_state.previous_session], prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); + const prev_entry = render_cache.entry(anim_state.previous_session); + try renderSession(renderer, &sessions[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); const new_offset = if (anim_state.mode == .PanningLeft) window_width - offset else -window_width + offset; const new_rect = Rect{ .x = new_offset, .y = 0, .w = window_width, .h = window_height }; - try renderSession(renderer, &sessions[anim_state.focused_session], new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + const new_entry = render_cache.entry(anim_state.focused_session); + try renderSession(renderer, &sessions[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .PanningUp, .PanningDown => { const elapsed = current_time - anim_state.start_time; @@ -97,14 +145,16 @@ pub fn render( const pan_offset = if (anim_state.mode == .PanningUp) -offset else offset; const prev_rect = Rect{ .x = 0, .y = pan_offset, .w = window_width, .h = window_height }; - try renderSession(renderer, &sessions[anim_state.previous_session], prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); + const prev_entry = render_cache.entry(anim_state.previous_session); + try renderSession(renderer, &sessions[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); const new_offset = if (anim_state.mode == .PanningUp) window_height - offset else -window_height + offset; const new_rect = Rect{ .x = 0, .y = new_offset, .w = window_width, .h = window_height }; - try renderSession(renderer, &sessions[anim_state.focused_session], new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + const new_entry = render_cache.entry(anim_state.focused_session); + try renderSession(renderer, &sessions[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .Expanding, .Collapsing => { const animating_rect = anim_state.getCurrentRect(current_time); @@ -128,12 +178,14 @@ pub fn render( .h = cell_height_pixels, }; - try renderGridSessionCached(renderer, session, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme); + const entry = render_cache.entry(i); + try renderGridSessionCached(renderer, session, entry, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme); } } const apply_effects = anim_scale < 0.999; - try renderSession(renderer, &sessions[anim_state.focused_session], animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, false, theme); + const entry = render_cache.entry(anim_state.focused_session); + try renderSession(renderer, &sessions[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, false, theme); }, } } @@ -141,6 +193,7 @@ pub fn render( fn renderSession( renderer: *c.SDL_Renderer, session: *SessionState, + cache_entry: *RenderCache.Entry, rect: Rect, scale: f32, is_focused: bool, @@ -154,7 +207,7 @@ fn renderSession( ) RenderError!void { try renderSessionContent(renderer, session, rect, scale, is_focused, font, term_cols, term_rows, theme); renderSessionOverlays(renderer, session, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme); - session.dirty = false; + cache_entry.presented_epoch = session.render_epoch; } fn renderSessionContent( @@ -543,14 +596,17 @@ fn renderSessionOverlays( } } -fn ensureCacheTexture(renderer: *c.SDL_Renderer, session: *SessionState, width: c_int, height: c_int) bool { - if (session.cache_texture) |tex| { - if (session.cache_w == width and session.cache_h == height) { +fn ensureCacheTexture(renderer: *c.SDL_Renderer, cache_entry: *RenderCache.Entry, session: *SessionState, width: c_int, height: c_int) bool { + if (cache_entry.texture) |tex| { + if (cache_entry.width == width and cache_entry.height == height) { return true; } log.debug("destroying cache for session {d} (resize)", .{session.id}); c.SDL_DestroyTexture(tex); - session.cache_texture = null; + cache_entry.texture = null; + cache_entry.width = 0; + cache_entry.height = 0; + cache_entry.cache_epoch = 0; } log.debug("creating cache for session {d} spawned={}", .{ session.id, session.spawned }); @@ -559,16 +615,17 @@ fn ensureCacheTexture(renderer: *c.SDL_Renderer, session: *SessionState, width: return false; }; _ = c.SDL_SetTextureBlendMode(tex, c.SDL_BLENDMODE_BLEND); - session.cache_texture = tex; - session.cache_w = width; - session.cache_h = height; - session.dirty = true; + cache_entry.texture = tex; + cache_entry.width = width; + cache_entry.height = height; + cache_entry.cache_epoch = 0; return true; } fn renderGridSessionCached( renderer: *c.SDL_Renderer, session: *SessionState, + cache_entry: *RenderCache.Entry, rect: Rect, scale: f32, is_focused: bool, @@ -579,11 +636,11 @@ fn renderGridSessionCached( current_time_ms: i64, theme: *const colors.Theme, ) RenderError!void { - const can_cache = ensureCacheTexture(renderer, session, rect.w, rect.h); + const can_cache = ensureCacheTexture(renderer, cache_entry, session, rect.w, rect.h); if (can_cache) { - if (session.cache_texture) |tex| { - if (session.dirty) { + if (cache_entry.texture) |tex| { + if (cache_entry.cache_epoch != session.render_epoch) { log.debug("rendering to cache: session={d} spawned={} focused={}", .{ session.id, session.spawned, is_focused }); _ = c.SDL_SetRenderTarget(renderer, tex); _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); @@ -591,7 +648,7 @@ fn renderGridSessionCached( _ = c.SDL_RenderClear(renderer); const local_rect = Rect{ .x = 0, .y = 0, .w = rect.w, .h = rect.h }; try renderSessionContent(renderer, session, local_rect, scale, is_focused, font, term_cols, term_rows, theme); - session.dirty = false; + cache_entry.cache_epoch = session.render_epoch; _ = c.SDL_SetRenderTarget(renderer, null); } @@ -603,11 +660,12 @@ fn renderGridSessionCached( }; _ = c.SDL_RenderTexture(renderer, tex, null, &dest_rect); renderSessionOverlays(renderer, session, rect, is_focused, apply_effects, current_time_ms, true, theme); + cache_entry.presented_epoch = session.render_epoch; return; } } - try renderSession(renderer, session, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme); + try renderSession(renderer, session, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme); } fn applyTvOverlay(renderer: *c.SDL_Renderer, rect: Rect, is_focused: bool, theme: *const colors.Theme) void { diff --git a/src/session/state.zig b/src/session/state.zig index 6d3db28..b26a5f7 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -6,7 +6,6 @@ const ghostty_vt = @import("ghostty-vt"); const shell_mod = @import("../shell.zig"); const pty_mod = @import("../pty.zig"); const app_state = @import("../app/app_state.zig"); -const c = @import("../c.zig"); const fs = std.fs; const cwd_mod = if (builtin.os.tag == .macos) @import("../cwd.zig") else struct {}; const vt_stream = @import("../vt_stream.zig"); @@ -35,10 +34,7 @@ pub const SessionState = struct { status: app_state.SessionStatus = .running, attention: bool = false, is_viewing_scrollback: bool = false, - dirty: bool = true, - cache_texture: ?*c.SDL_Texture = null, - cache_w: c_int = 0, - cache_h: c_int = 0, + render_epoch: u64 = 1, spawned: bool = false, dead: bool = false, shell_path: []const u8, @@ -169,7 +165,7 @@ pub const SessionState = struct { &self.shell.?, ); self.stream = stream; - self.dirty = true; + self.markDirty(); if (loop_opt) |loop| { var process = try xev.Process.init(shell.child_pid); @@ -210,13 +206,6 @@ pub const SessionState = struct { } pub fn deinit(self: *SessionState, allocator: std.mem.Allocator) void { - if (self.cache_texture) |tex| { - c.SDL_DestroyTexture(tex); - self.cache_texture = null; - self.cache_w = 0; - self.cache_h = 0; - } - self.pending_write.deinit(allocator); self.pending_write = .empty; @@ -312,7 +301,7 @@ pub const SessionState = struct { }; self.dead = true; - self.dirty = true; + self.markDirty(); log.info("session {d} process exited with code {d}", .{ self.id, exit_code }); self.allocator.destroy(ctx); @@ -329,7 +318,7 @@ pub const SessionState = struct { const result = std.c.waitpid(shell.child_pid, &status, std.c.W.NOHANG); if (result > 0) { self.dead = true; - self.dirty = true; + self.markDirty(); log.info("session {d} process exited", .{self.id}); } } @@ -384,6 +373,10 @@ pub const SessionState = struct { self.last_scroll_time = 0; } + pub fn markDirty(self: *SessionState) void { + self.render_epoch +%= 1; + } + pub fn clearSelection(self: *SessionState) void { self.selection_anchor = null; self.selection_dragging = false; @@ -391,7 +384,7 @@ pub const SessionState = struct { if (!self.spawned) return; if (self.terminal) |*terminal| { terminal.screens.active.clearSelection(); - self.dirty = true; + self.markDirty(); } } @@ -410,7 +403,7 @@ pub const SessionState = struct { if (n == 0) return; try stream.nextSlice(self.output_buf[0..n]); - self.dirty = true; + self.markDirty(); // Keep draining until the PTY would block to avoid frame-bounded // throttling of bursty output (e.g. startup logos). @@ -477,7 +470,7 @@ pub const SessionState = struct { self.cwd_path = new_path; self.cwd_basename = basenameForDisplay(new_path); - self.dirty = true; + self.markDirty(); } pub fn recordCwd(self: *SessionState, path: []const u8) !void { @@ -502,7 +495,7 @@ pub const SessionState = struct { self.cwd_path = try self.allocator.dupe(u8, path); self.cwd_basename = basenameForDisplay(self.cwd_path.?); - self.dirty = true; + self.markDirty(); } /// Returns true when the PTY's foreground process group differs from the