From 22912aed76a02963493fa9e40f5e3eafdb5e3863 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 20 Jul 2026 15:41:45 +0200 Subject: [PATCH 1/3] fix(render): hold terminal repaints after resize and skip rendering while occluded Issue: Grid/full view toggles with long-history codex sessions showed seconds of scrolling text, the app sporadically froze for about a second mid-frame, and a covered window stalled input and PTY processing. Solution: Codex erases its scrollback and re-prints its transcript tail (twice, paced over seconds) after any PTY resize, so the renderer now keeps the pre-resize content on screen until the repaint settles, then reveals the new layout with a shimmer sweep at the wait band's speed. Rendering is skipped while the window is occluded because macOS stops returning Metal drawables and each render attempt blocks the main thread for the full nextDrawable timeout. Full view no longer presents frames for invisible background sessions, and static UI badges cache their textures instead of forcing a Metal queue flush on every frame. --- CLAUDE.md | 7 +- README.md | 1 + docs/ARCHITECTURE.md | 4 +- docs/perf-debugging.md | 100 ++++++ scripts/perf/fake_codex.py | 93 ++++++ scripts/perf/spawn_session.py | 46 +++ src/app/layout.zig | 26 +- src/app/runtime.zig | 60 +++- src/c.zig | 2 + src/gfx/shimmer.zig | 237 ++++++++++++++ src/render/renderer.zig | 186 ++++++++++- src/session/state.zig | 320 +++++++++++++++++++ src/ui/components/cwd_bar.zig | 92 +++--- src/ui/components/glyph_badge.zig | 72 +++++ src/ui/components/help_overlay.zig | 35 +- src/ui/components/quit_blocking_overlay.zig | 87 +---- src/ui/components/recent_folders_overlay.zig | 35 +- src/ui/components/worktree_overlay.zig | 35 +- 18 files changed, 1206 insertions(+), 232 deletions(-) create mode 100644 docs/perf-debugging.md create mode 100644 scripts/perf/fake_codex.py create mode 100644 scripts/perf/spawn_session.py create mode 100644 src/gfx/shimmer.zig create mode 100644 src/ui/components/glyph_badge.zig diff --git a/CLAUDE.md b/CLAUDE.md index 05d8352..f25bfef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Read these before making any changes: - `docs/ARCHITECTURE.md` — How it's built (layers, modules, dependencies) - `docs/configuration.md` — Config shape for `config.toml` and `persistence.toml` - `docs/development.md` — Developer setup and workflow notes +- `docs/perf-debugging.md` — Read before investigating rendering/terminal performance: headless repro via the control socket (`scripts/perf/`), codex resize behavior, Debug-vs-Release ghostty-vt costs, SDL/Metal pipeline pitfalls ## Agent Rules @@ -125,6 +126,9 @@ If you are executing in a git worktree, stay within that worktree and do not att ### Window-close handling on macOS - When you handle Cmd+W yourself, set `SDL_HINT_QUIT_ON_LAST_WINDOW_CLOSE` to `"0"` so SDL does not emit `SDL_EVENT_QUIT` and bypass your custom close logic. +### Never render while the window is occluded +- macOS stops compositing fully covered windows and `CAMetalLayer` stops returning drawables; any render attempt then blocks the main thread for the full ~1s `nextDrawable` timeout, stalling input and PTY processing. The frame loop gates rendering on the `SDL_WINDOW_OCCLUDED` window flag (`shouldRenderFrame` in `src/app/runtime.zig`); keep any new render/present paths behind the same gate. + ### Adding New SDL3 Key Codes When adding references to SDL3 key codes (SDLK_*) or other SDL constants, always add them to `src/c.zig` first instead of searching the web for their values. SDL3 constants are exposed through the c_import and must be explicitly re-exported in c.zig to be accessible throughout the codebase. @@ -206,9 +210,10 @@ The `<= len` pattern is only correct when `pos` represents a position *after* pr - `just` commands mirror zig builds (`just build`, `just run`, `just test`, `just ci`); use them when adjusting CI scripts or docs. - Shells spawn as login shells (`zsh -l`), so login profiles (`/etc/zprofile`, `~/.zprofile`) are sourced; nix-darwin `environment.shellAliases` end up in the generated `/etc/zprofile`, which is the place to check when aliases or env values are missing inside Architect. - On macOS hosts where `/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk` is arm64e-only, the Nix dev shell auto-applies a Zig 0.15.2 workaround: it points `DEVELOPER_DIR` at a fake developer dir backed by `MacOSX15.4.sdk` and installs an `xcrun` shim inside that fake developer tree so `zig build`, Ghostty's Apple SDK discovery, and unrelated tools that still invoke `/usr/bin/xcrun` keep working. Remove it once Zig's fix for https://codeberg.org/ziglang/zig/issues/31756 is no longer needed here. -- Shared UI/render utilities live in `src/geom.zig` (Rect + point containment), `src/anim/easing.zig` (easing), and `src/gfx/primitives.zig` (rounded/thick borders); reuse them instead of duplicating helpers. +- Shared UI/render utilities live in `src/geom.zig` (Rect + point containment), `src/anim/easing.zig` (easing), `src/gfx/primitives.zig` (rounded/thick borders), and `src/gfx/shimmer.zig` (busy shimmer used by the quit overlay and the resize-settle hold); reuse them instead of duplicating helpers. - The UI overlay pipeline is centralized in `src/ui/`—`UiRoot` receives events before `main`'s switch, runs per-frame `update`, drains `UiAction`s, and renders after the scene; register new components there rather than adding more UI logic to `main.zig`. - Reusable marquee text rendering lives in `src/ui/components/marquee_label.zig`; use it instead of re-implementing scroll logic. +- Static text badges (the collapsed `⌘O`/`⌘T`/`⌘?` overlay hints) must use `src/ui/components/glyph_badge.zig`. Never create-render-destroy an SDL texture within a single frame: destroying a texture that was queued for rendering forces SDL's Metal backend to flush the command queue and block on drawable acquisition (up to ~1 s under load). Cache textures and invalidate on font/theme changes. - Cursor rendering: set the cursor's background color during the per-cell background pass and render the glyph on top; avoid drawing a separate cursor rectangle after text rendering, which hides the underlying glyph. - ghostty-vt defaults: `Terminal.Options.max_scrollback` is 10_000 bytes and `0` disables scrollback entirely; set it explicitly when you expect deeper history. Ghostty's app sets 10 MB via `scrollback-limit` in Config.zig; upstream currently doesn't support unlimited scrollback. Use bytes, not lines, when sizing scrollback. diff --git a/README.md b/README.md index bc86c63..cf6ff7d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Agent session persistence** — when you quit Architect, any running Claude, Codex, or Gemini agents are gracefully terminated and their session IDs saved; on next launch the agents resume automatically where they left off - **Dynamic grid** — starts with a single terminal in full view; press ⌘N to add a terminal after the current one, and closing terminals compacts the grid forward - **Grid view** — keep all agents visible simultaneously, expand any one to full screen +- **Calm resize repaints** — agents like codex redraw their whole transcript after any terminal resize; instead of showing that as seconds of scrolling text, view toggles and window resizes keep the previous content on screen until the repaint settles, with a subtle busy shimmer when it takes longer than half a second, and then sweep the new layout in behind the shimmer band instead of snapping - **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches; new worktrees are created outside the repo tree (configurable via `[worktree]` in `config.toml`) with automatic post-create initialization - **Recent folders** (⌘O) — quickly `cd` into recently visited directories with instant search filtering (start typing to narrow the list), substring highlighting, arrow key navigation, and ⌘1–⌘9 quick selection - **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments with multiline wrapping, then send them all to a running agent (or start one) with the "Send to agent" button diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d162ba4..44077dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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. +- 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. On top of the per-batch 2026 hold, every terminal resize starts a **resize-settle hold** (`SessionState.startResizeSettleHold`): agents like codex respond to any resize by erasing the scrollback and re-printing their transcript tail as a paced multi-second stream of small batches, so the renderer keeps showing the pre-resize cached texture — stretched into the new rect, including during expand/collapse animations — until the session's output has been quiet for `resize_settle_quiet_ms` (or `resize_settle_max_ms` at the latest, since an actively streaming agent never goes quiet). Two refinements keep the release honest: while no output at all has arrived since the resize, the quiet release is deferred up to `resize_settle_response_grace_ms` (agents debounce SIGWINCH before their first repaint chunk), and a repaint wave landing during the sweep or within `resize_settle_rearm_window_ms` after the release — detected as a single drain call consuming at least `resize_settle_reemit_chunk_bytes`, which status-line ticks never reach — re-engages the hold (at most `resize_settle_max_rearms` times per resize) so codex's second rebuild wave stays hidden and one final sweep runs when it settles. Holds that outlast `resize_settle_shimmer_after_ms` get the shared busy shimmer (`gfx/shimmer.zig`, same visual as the quit-teardown overlay) drawn over the tile. While a hold is active the session's `presented_epoch` intentionally stays stale so the frame loop keeps rendering (the shimmer animates and the release repaints promptly); fast-redrawing sessions (plain shells, vim) release in under half a second and never see the shimmer. When a hold releases, the old content does not snap to the new layout: a **sweep reveal** runs instead (`gfx/shimmer.zig:drawSweepReveal`), with linear progress at exactly the wait shimmer's band speed — but over the minimal overscan margin rather than the wait shimmer's full-window overscan, so the band enters the tile almost immediately on release instead of spending a third of the cycle travelling off-screen (`resize_settle_transition_ms` matches the shimmer cycle; the visible crossing finishes in the fraction of it that the tile width covers). The renderer takes ownership of the pre-resize cache texture (`RenderCache.Entry.transition_texture`, stolen before `ensureCacheTexture` would destroy it for the new size) and renders the new layout underneath; the shimmer's diagonal light band then makes one eased pass across the tile, with the old content drawn crisply ahead of the moving front (proportionally sliced in 4px strips, so the diagonal boundary staircases by under 2px — hidden by the band's glow) and the new content revealed crisply behind it. The transition texture is destroyed once the sweep finishes (a single Metal command-queue flush per transition, unlike the per-frame destroys that `glyph_badge.zig` exists to prevent); `anyDirty` treats sessions with a running sweep as dirty so frames keep flowing without content changes. - 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. @@ -107,7 +107,7 @@ These patterns are mandatory for all new code. They are derived from the archite 1. **UI components use the vtable interface and communicate via UiAction queue.** Never mutate application state directly from a UI component. Push a `UiAction` to the queue; the main loop drains it after all component updates complete. (See ADR-003.) -2. **Render invalidation uses epoch comparison.** When terminal content changes, increment `render_epoch` on the `SessionState`. The renderer checks whether `presented_epoch` no longer matches `render_epoch` to know whether a session needs to be redrawn this frame, and cached session textures refresh when their stored epoch, overlay composition, or grid/full render mode no longer matches the requested render. This applies to both grid tiles and the steady-state full-screen terminal view. Never force a full re-render. (See ADR-004.) +2. **Render invalidation uses epoch comparison.** When terminal content changes, increment `render_epoch` on the `SessionState`. The renderer checks whether `presented_epoch` no longer matches `render_epoch` to know whether a session needs to be redrawn this frame, and cached session textures refresh when their stored epoch, overlay composition, or grid/full render mode no longer matches the requested render. This applies to both grid tiles and the steady-state full-screen terminal view. Never force a full re-render. (See ADR-004.) The dirty check only counts sessions visible in the current view mode (`RenderCache.sessionVisibleInMode`): in Full view, background sessions keep producing output but are never presented, so counting them would keep the app compositing and presenting full-window frames at the maximum rate for pixels nobody sees. Two related frame-loop rules: rendering is suppressed entirely while the window is occluded (`shouldRenderFrame` in `app/runtime.zig`) because macOS stops handing out `CAMetalLayer` drawables for covered windows and each render attempt would block the main thread — including PTY draining — for the full ~1s `nextDrawable` timeout; and static UI textures must never be created and destroyed within a single frame (see `ui/components/glyph_badge.zig`) because destroying a texture queued for rendering forces SDL's Metal backend to flush its command queue and acquire a drawable mid-frame. 3. **Blocking I/O goes on a background thread with a thread-safe queue.** The frame loop must never block. Any new external I/O source must follow the notification/control socket pattern: background thread + queue + main-loop drain. (See ADR-009.) diff --git a/docs/perf-debugging.md b/docs/perf-debugging.md new file mode 100644 index 0000000..b1c75ca --- /dev/null +++ b/docs/perf-debugging.md @@ -0,0 +1,100 @@ +# Performance Debugging + +Techniques for investigating Architect rendering/terminal performance, distilled +from the grid/full toggle lag investigation (July 2026, follow-up to issue #299). + +## Reproducing without manual UI interaction + +- Run an isolated instance with a **short** fake `HOME` (e.g. `/tmp/arch-ph`): + the control unix-socket path must stay under the 104-char `sockaddr_un` limit + or the control thread dies silently. Create `$HOME/.config/architect` and + seed `persistence.toml` with a realistic `[window]` size so the render target + matches production usage. This isolates config, persistence, logs, and the + control socket from the daily instance. +- Spawn sessions programmatically through the control socket at + `$HOME/Library/Caches/Architect/runtime/architect_control_.sock`: send + one JSON line `{"cwd": "...", "command": "...", "display_name": "..."}`. + The command is typed into the new session's shell, so an inline + `HOME=/Users/ codex ...` prefix restores the real home for child + tools while the app itself stays isolated. +- `scripts/perf/fake_codex.py` emulates a live codex TUI cell: it streams a + large styled backlog, animates a spinner, and on SIGWINCH mimics codex's + resize behavior (debounce, DEC 2026 begin, `ESC[3J`, re-print the last N + transcript lines, DEC 2026 end). +- For deterministic view toggles, temporarily add an env-gated auto-toggle to + the main loop in `runtime.zig` (set `.Expanding`, or call + `grid_nav.startCollapseToGrid`). Grid→Full is Cmd+Return; Full→Grid is the + escape-hold path (`RequestCollapseFocused`). + +## Codex resize behavior (measured on codex-cli 0.144.6) + +On **any** PTY resize (rows-only included) codex debounces, then emits +`ESC[?2026h`, `ESC[3J` (erases the entire scrollback!), clears the screen, and +re-prints the tail of its transcript wrapped in synchronized-output pairs. +The tail length is capped by `[tui.terminal_resize_reflow].max_rows` +(openai/codex PR #18575); Architect's `TERM=xterm-ghostty` maps to the default +fallback cap of 1000 rows. + +**Live vs. resumed sessions differ drastically in pacing.** A freshly resumed +session (`codex resume`) flushes the whole ~200 KB re-emit in under 100 ms. A +long-running **live** session re-emits its reflowed tail **twice** per resize +(the initial rebuild plus a "final source-backed rebuild" — see PR #18575), +paced through codex's streaming path: 80–250 KB trickling in over **3–5 +seconds** in two waves separated by a ~1.5 s lull. Ground-truth measurement +(instrumented ReleaseFast build, real 6-cell session, July 2026): Architect's +frames stayed at 3–20 ms and VT resizes at 0–1 ms throughout — the +seconds-long "scrolling text" after a grid/full toggle is entirely +producer-side codex pacing, not Architect's consumption. Resumed-session +probes therefore *cannot* reproduce the toggle lag; only live sessions with +real transcript history can. Architect hides the in-flight repaint with the +resize-settle hold (`SessionState.startResizeSettleHold`, described in +`docs/ARCHITECTURE.md`): the pre-resize content stays on screen — with the +busy shimmer once the hold outlasts half a second — until the session's +output goes quiet or the hard cap elapses. + +## Measured costs (ghostty-vt 1.3.1, Apple Silicon) + +| Operation | Debug | ReleaseSafe/ReleaseFast | +| --- | --- | --- | +| Feed 180 KB codex resize re-emit | 7–12 s | ~1 ms | +| `Terminal.resize` (cols change, 10 MB styled scrollback) | 1.4–2.4 s | 3–5 ms | +| `Terminal.resize` (rows-only change) | ~0 ms | ~0 ms | + +The Debug numbers are dominated by ghostty's `slow_runtime_safety` page +integrity verification (enabled only for Debug optimize mode, see ghostty's +`src/build/Config.zig`). Never draw performance conclusions from a plain +Debug build; benchmark VT behavior with `-Doptimize=ReleaseFast`. + +## SDL/Metal pipeline pitfalls + +- Destroying an `SDL_Texture` that was queued for rendering in the current + frame forces SDL's Metal backend to flush the command queue + (`SDL_DestroyTextureInternal` → `METAL_RunCommandQueue` → + `METAL_ActivateRenderCommandEncoder` → `CAMetalLayer nextDrawable`), and + `nextDrawable` can block for up to ~1 s when the drawable pool is exhausted. + Never create-render-destroy a texture per frame; cache static textures + (see `src/ui/components/glyph_badge.zig`). +- **Occluded windows stop getting drawables.** When the window is fully + covered (another window on top, Space switch), macOS stops compositing it + and `nextDrawable` blocks for its full ~1 s timeout on every render attempt, + freezing the main thread — input handling and PTY draining included. This + presented as sporadic ~1.1–1.2 s single-frame stalls that survived every + content-side fix; the `SDL_WINDOW_OCCLUDED` window flag at stall time was + the discriminating evidence. The frame loop now skips rendering while the + flag is set (`shouldRenderFrame` in `app/runtime.zig`) and repaints on the + `SDL_EVENT_WINDOW_EXPOSED` event. +- `sample -file out.txt` attributes these waits precisely; look + for `nextDrawable` under the component that happens to flush first. +- A deterministic way to test occlusion behavior: launch a second isolated + Architect instance with the identical `[window]` rect so it exactly covers + the first. + +## Reading the evidence + +- Structured app logs (`~/Library/Logs/Architect/architect.log`) record + grid/full transitions (`event=view_enter_full` etc.) and, at debug level, + `rendering to cache: session=N` lines — enough to correlate user-visible + stalls with render churn, but only with second granularity. +- For millisecond attribution, add temporary timing around + `applyTerminalResize`, `processOutput`, and the render pass, and print + per-frame lines to stderr. diff --git a/scripts/perf/fake_codex.py b/scripts/perf/fake_codex.py new file mode 100644 index 0000000..ee6563c --- /dev/null +++ b/scripts/perf/fake_codex.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Emulate a live codex TUI session for Architect perf testing. + +Streams a large styled backlog (simulating hours of agent output), then +animates a spinner. On SIGWINCH, mimics codex's resize behavior (per +openai/codex PR #18575 + captured PTY traces of codex 0.144.6): debounce, +then DEC 2026 begin, ESC[3J erase scrollback, clear screen, re-print the +last `REEMIT_LINES` transcript lines, DEC 2026 end — twice, with a lull +between the waves. + +Args: [initial_lines] [reemit_lines] [mode]. Mode "active" (default) keeps a +status line ticking through the lull and between resizes; "idle" stays +silent like a codex waiting for input — the case that exercises the +quiet-release/re-arm logic of Architect's resize-settle hold. +""" +import signal +import sys +import time + +INITIAL_LINES = int(sys.argv[1]) if len(sys.argv) > 1 else 30000 +REEMIT_LINES = int(sys.argv[2]) if len(sys.argv) > 2 else 1000 +IDLE = len(sys.argv) > 3 and sys.argv[3] == "idle" + +resize_pending = False + + +def on_winch(_sig, _frame): + global resize_pending + resize_pending = True + + +signal.signal(signal.SIGWINCH, on_winch) + +out = sys.stdout + + +def styled_line(i): + return ( + f"\x1b[38;5;{(i * 7) % 230 + 1}m•\x1b[0m \x1b[1mstep {i}\x1b[0m " + f"\x1b[36mrunning tool\x1b[0m with output \x1b[33mwarning\x1b[0m lorem ipsum " + f"dolor sit amet consectetur \x1b[32m+{i % 97}\x1b[0m \x1b[31m-{i % 13}\x1b[0m " + f"adipiscing elit sed do eiusmod tempor incididunt ut labore\r\n" + ) + + +def emit_block(n, start=0): + buf = [] + for i in range(start, start + n): + buf.append(styled_line(i)) + if len(buf) >= 200: + out.write("".join(buf)) + out.flush() + buf.clear() + out.write("".join(buf)) + out.flush() + + +emit_block(INITIAL_LINES) + +i = INITIAL_LINES +spin = "|/-\\" +k = 0 +while True: + time.sleep(0.1) + if resize_pending: + resize_pending = False + time.sleep(0.15) + # Live codex re-emits its reflowed tail twice per resize (initial + # rebuild + final source-backed rebuild, openai/codex PR #18575), + # paced through its streaming path: two waves with a lull between. + for wave in range(2): + out.write("\x1b[?2026h\x1b[3J\x1b[1;1H\x1b[J") + chunk = 50 + emitted = 0 + while emitted < REEMIT_LINES: + n = min(chunk, REEMIT_LINES - emitted) + emit_block(n, start=max(0, i - REEMIT_LINES) + emitted) + emitted += n + time.sleep(0.06) + out.write("\x1b[?2026l") + out.flush() + if wave == 0: + if IDLE: + time.sleep(1.5) + else: + for tick in range(15): + out.write(f"\r\x1b[K\x1b[36m{spin[tick % 4]}\x1b[0m reflowing...") + out.flush() + time.sleep(0.1) + elif not IDLE: + k += 1 + out.write(f"\r\x1b[K\x1b[36m{spin[k % 4]}\x1b[0m working... \x1b[2m({k}s)\x1b[0m") + out.flush() diff --git a/scripts/perf/spawn_session.py b/scripts/perf/spawn_session.py new file mode 100644 index 0000000..c9b5b27 --- /dev/null +++ b/scripts/perf/spawn_session.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Spawn a session in a running Architect instance via the control socket. + +Usage: + spawn_session.py [command] [display_name] + +Finds the control socket of the newest running instance (honoring $HOME, so +point HOME at the instance's home when testing an isolated instance). +""" +import glob +import json +import os +import sys + + +def find_socket(): + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") + if runtime_dir is None: + runtime_dir = os.path.join( + os.environ["HOME"], "Library", "Caches", "Architect", "runtime" + ) + socks = glob.glob(os.path.join(runtime_dir, "architect_control_*.sock")) + if not socks: + raise SystemExit(f"no control socket found in {runtime_dir}") + return max(socks, key=os.path.getmtime) + + +def main(): + if len(sys.argv) < 2: + raise SystemExit(__doc__) + req = {"cwd": sys.argv[1]} + if len(sys.argv) > 2: + req["command"] = sys.argv[2] + if len(sys.argv) > 3: + req["display_name"] = sys.argv[3] + + import socket + + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(find_socket()) + s.sendall((json.dumps(req) + "\n").encode()) + print(s.recv(4096).decode().strip()) + + +if __name__ == "__main__": + main() diff --git a/src/app/layout.zig b/src/app/layout.zig index 4966723..8338a85 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -180,6 +180,7 @@ pub fn applyTerminalResize( allocator: std.mem.Allocator, sizes: Sizes, full_set: FullSet, + now_ms: i64, ) bool { const grid_size = pty_mod.winsize{ .ws_row = sizes.grid.rows, @@ -229,6 +230,7 @@ pub fn applyTerminalResize( session.stream = vt_stream.initStream(allocator, terminal, shell); } session.resetSynchronizedOutputTracking(); + session.startResizeSettleHold(now_ms); session.markDirty(); terminal_resized = true; } else if (terminal_pixels_changed) { @@ -394,7 +396,7 @@ test "applyTerminalResize preserves DECCOLM width while layout target is unchang terminal.modes.set(.enable_mode_3, true); var sessions = [_]*SessionState{&fixture.session}; - const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }); + const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }, 1000); try std.testing.expect(!changed); try std.testing.expectEqual(@as(u16, 80), terminal.cols); @@ -413,7 +415,7 @@ test "applyTerminalResize preserves DECCOLM width on row-only layout changes" { terminal.modes.set(.in_band_size_reports, true); var sessions = [_]*SessionState{&fixture.session}; - const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 30), .{ .primary = 0 }); + const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 30), .{ .primary = 0 }, 1000); try std.testing.expect(changed); try std.testing.expectEqual(@as(u16, 80), terminal.cols); @@ -441,7 +443,7 @@ test "applyTerminalResize preserves DECCOLM width on pixel-only layout changes" .full = .{ .cols = 100, .rows = 24, .width_px = 1200, .height_px = 480 }, }; var sessions = [_]*SessionState{&fixture.session}; - const changed = applyTerminalResize(&sessions, allocator, sizes, .{ .primary = 0 }); + const changed = applyTerminalResize(&sessions, allocator, sizes, .{ .primary = 0 }, 1000); try std.testing.expect(!changed); try std.testing.expectEqual(@as(u16, 80), terminal.cols); @@ -465,7 +467,7 @@ test "applyTerminalResize resets DECCOLM width when layout target changes" { terminal.modes.set(.enable_mode_3, true); var sessions = [_]*SessionState{&fixture.session}; - const changed = applyTerminalResize(&sessions, allocator, testSizes(120, 24), .{ .primary = 0 }); + const changed = applyTerminalResize(&sessions, allocator, testSizes(120, 24), .{ .primary = 0 }, 1000); try std.testing.expect(changed); try std.testing.expectEqual(@as(u16, 120), terminal.cols); @@ -480,12 +482,26 @@ test "applyTerminalResize corrects non-DECCOLM terminal width drift" { defer fixture.deinit(allocator); var sessions = [_]*SessionState{&fixture.session}; - const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }); + const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }, 1000); try std.testing.expect(changed); const terminal = try testTerminal(&fixture.session); try std.testing.expectEqual(@as(u16, 100), terminal.cols); try std.testing.expectEqual(@as(u16, 24), terminal.rows); + try std.testing.expect(fixture.session.resizeSettleHoldActive(1000)); +} + +test "applyTerminalResize does not start a settle hold without a cell change" { + const allocator = std.testing.allocator; + const target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 }; + var fixture = try initSpawnedTestSession(allocator, target, 100, 24); + defer fixture.deinit(allocator); + + var sessions = [_]*SessionState{&fixture.session}; + const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }, 1000); + + try std.testing.expect(!changed); + try std.testing.expect(!fixture.session.resizeSettleHoldActive(1000)); } test "terminal resize preserves prompt contents when shell does not redraw" { diff --git a/src/app/runtime.zig b/src/app/runtime.zig index b953cce..6f9647f 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -116,6 +116,17 @@ fn waitTimeoutMsFromNs(remaining_ns: u64) c_int { return @intCast(@min(timeout_ms, max_timeout_ms)); } +/// Rendering is suppressed while the window is fully occluded: macOS stops +/// compositing covered windows and `CAMetalLayer` stops handing out +/// drawables, so any render attempt blocks the main thread for the full +/// ~1s `nextDrawable` timeout — freezing input handling and PTY processing +/// with it. PTY output keeps being consumed while occluded; the +/// `SDL_EVENT_WINDOW_EXPOSED` event that fires on un-covering counts as a +/// processed event and triggers an immediate repaint. +fn shouldRenderFrame(window_occluded: bool, wants_render: bool) bool { + return wants_render and !window_occluded; +} + fn computeFrameWaitDecision(is_idle: bool, vsync_enabled: bool, frame_ns: i128) FrameWaitDecision { if (is_idle) { const timeout_ms = waitTimeoutMsFromNs(remainingFrameBudgetNs(idle_frame_ns, frame_ns)); @@ -378,12 +389,13 @@ fn applyTerminalLayout( grid_font_scale: f32, full_cols: *u16, full_rows: *u16, + now: i64, ) void { 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); + _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set, now); } fn applyTerminalLayoutIfSizeChanged( @@ -399,12 +411,13 @@ fn applyTerminalLayoutIfSizeChanged( grid_font_scale: f32, full_cols: *u16, full_rows: *u16, + now: i64, ) bool { 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); + return layout.applyTerminalResize(sessions, allocator, sizes, full_set, now); } /// Computes both terminal sizes from the raw render dimensions. grid_size @@ -772,6 +785,7 @@ fn handleExternalSpawnRequest( grid_font_scale, full_cols, full_rows, + now, ); pending.completion.complete(.{ .success = .{ @@ -930,6 +944,7 @@ const RuntimeScaleChangeContext = struct { grid_font_scale: f32, full_cols: *u16, full_rows: *u16, + now: i64, }; fn reloadRuntimeFontsForScaleChange(ctx: *RuntimeScaleChangeContext) font_mod.Font.InitError!void { @@ -957,6 +972,7 @@ fn applyRuntimeResizeForScaleChange(ctx: *RuntimeScaleChangeContext) void { ctx.allocator, sizes, full_set, + ctx.now, ); } @@ -1733,6 +1749,7 @@ pub fn run() !void { .grid_font_scale = config.grid.font_scale, .full_cols = &full_cols, .full_rows = &full_rows, + .now = now, }; try applyScaleChangeAndResize( RuntimeScaleChangeContext, @@ -1955,7 +1972,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, 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, now); } else if (remaining_count == 1) { // Only 1 terminal remains - go directly to Full mode, no resize animation grid.cols = 1; @@ -1971,7 +1988,7 @@ pub fn run() !void { } } anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, 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, now); } else { const new_dims = GridLayout.calculateDimensions(required_slots); const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; @@ -2011,7 +2028,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, 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, now); // Update focus to a valid session if (!sessions[anim_state.focused_session].spawned) { @@ -2089,7 +2106,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, 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, now); std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); persistence.font_size = font_size; @@ -2153,7 +2170,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, 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, now); session_interaction_component.clearSelection(anim_state.focused_session); session_interaction_component.clearSelection(new_idx); @@ -2383,6 +2400,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.expireResizeSettleHold(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; @@ -2411,7 +2429,13 @@ pub fn run() !void { if (quit_teardown.isFinished()) { running = false; } - var any_session_dirty = render_cache.anyDirty(sessions); + var any_session_dirty = render_cache.anyDirty( + sessions, + anim_state.mode, + anim_state.focused_session, + anim_state.previous_session, + now, + ); var control_requests = control_queue.drainAll(); defer control_requests.deinit(allocator); @@ -2588,7 +2612,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, 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, now); } else if (remaining_count == 1) { // Only 1 terminal remains - go directly to Full mode, no resize animation grid.cols = 1; @@ -2604,7 +2628,7 @@ pub fn run() !void { } } anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, 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, now); } else { const new_dims = GridLayout.calculateDimensions(required_slots); const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; @@ -2643,7 +2667,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, 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, now); if (!sessions[anim_state.focused_session].spawned) { var new_focus: usize = 0; @@ -3031,6 +3055,7 @@ pub fn run() !void { config.grid.font_scale, &full_cols, &full_rows, + now, ); if (terminal_layout_changed) { any_session_dirty = true; @@ -3069,7 +3094,11 @@ pub fn run() !void { const animating = anim_state.mode != .Grid and anim_state.mode != .Full; 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 = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or had_control_requests or last_render_stale; + const window_occluded = (c.SDL_GetWindowFlags(sdl.window) & c.SDL_WINDOW_OCCLUDED) != 0; + const should_render = shouldRenderFrame( + window_occluded, + 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) { @@ -3288,6 +3317,13 @@ test "computeFrameWaitDecision returns idle wait while idle" { } } +test "shouldRenderFrame suppresses rendering while the window is occluded" { + try std.testing.expect(shouldRenderFrame(false, true)); + try std.testing.expect(!shouldRenderFrame(true, true)); + try std.testing.expect(!shouldRenderFrame(false, false)); + try std.testing.expect(!shouldRenderFrame(true, false)); +} + test "computeFrameWaitDecision keeps active pacing without vsync, rounded up to whole ms" { const decision = computeFrameWaitDecision(false, false, 5 * std.time.ns_per_ms); const expected_ms = waitTimeoutMsFromNs(@intCast(active_frame_ns - (5 * std.time.ns_per_ms))); diff --git a/src/c.zig b/src/c.zig index f393393..c8d9f3e 100644 --- a/src/c.zig +++ b/src/c.zig @@ -14,6 +14,8 @@ pub const SDL_DestroyWindow = c_import.SDL_DestroyWindow; pub const SDL_SetWindowPosition = c_import.SDL_SetWindowPosition; pub const SDL_GetWindowSizeInPixels = c_import.SDL_GetWindowSizeInPixels; pub const SDL_GetWindowSize = c_import.SDL_GetWindowSize; +pub const SDL_GetWindowFlags = c_import.SDL_GetWindowFlags; +pub const SDL_WINDOW_OCCLUDED = c_import.SDL_WINDOW_OCCLUDED; pub const SDL_GetWindowDisplayScale = c_import.SDL_GetWindowDisplayScale; pub const SDL_WINDOW_HIGH_PIXEL_DENSITY = c_import.SDL_WINDOW_HIGH_PIXEL_DENSITY; pub const SDL_CreateRenderer = c_import.SDL_CreateRenderer; diff --git a/src/gfx/shimmer.zig b/src/gfx/shimmer.zig new file mode 100644 index 0000000..0be810e --- /dev/null +++ b/src/gfx/shimmer.zig @@ -0,0 +1,237 @@ +const std = @import("std"); +const c = @import("../c.zig"); +const geom = @import("../geom.zig"); + +/// Animated "busy" shimmer: dims a rectangle and sweeps a soft diagonal light +/// band across it. Shared by the quit-teardown overlay and the resize-settle +/// hold so both read as the same visual language. +pub const Options = struct { + /// Alpha of the dimming layer drawn under the band. + base_alpha: u8, + /// Peak alpha of the sweeping band. + band_alpha: u8, + /// Duration of one full sweep across the rect. + cycle_ms: i64 = 1400, + /// Band width as a fraction of the rect width. + width_divisor: c_int = 12, + min_width: c_int = 30, + gradient_steps: usize = 4, + /// Horizontal drift of the band per vertical pixel (negative leans left). + slope: f32 = -0.42, +}; + +const SweepGeometry = struct { + center_x: f32, + center_y: f32, + half_core_w: f32, + half_total_w: f32, +}; + +fn halfBandWidth(rect: geom.Rect, opts: Options) f32 { + const band_w: c_int = @max(@divFloor(rect.w, opts.width_divisor), opts.min_width); + return @as(f32, @floatFromInt(band_w)) * 0.5; +} + +/// Overscan for the cyclic wait shimmer: a generous off-rect stretch so the +/// band reads as a periodic sparkle with a pause between passes. +fn waitMargin(rect: geom.Rect, opts: Options) f32 { + return @as(f32, @floatFromInt(@max(rect.w, rect.h))) + halfBandWidth(rect, opts); +} + +/// Minimal overscan for the one-shot reveal: just enough that the band's +/// glow is fully off-rect at progress 0 and 1 on every row (assumes a +/// non-positive slope, where the extremes sit at the top-left and +/// bottom-right corners). Anything larger is dead time in the animation. +fn revealMargin(rect: geom.Rect, opts: Options) f32 { + return halfBandWidth(rect, opts) / (1.0 - @min(opts.slope, 0.0)); +} + +/// Position of the diagonal band for a sweep progress in [0, 1] with the +/// given off-rect overscan margin. Progress 0 leaves the whole rect +/// un-swept and progress 1 leaves it fully swept. +fn sweepGeometry(rect: geom.Rect, progress: f32, margin: f32, opts: Options) SweepGeometry { + const half_total_w = halfBandWidth(rect, opts); + return .{ + .center_x = -margin + progress * (@as(f32, @floatFromInt(rect.w)) + margin * 2.0), + .center_y = -margin + progress * (@as(f32, @floatFromInt(rect.h)) + margin * 2.0), + .half_core_w = half_total_w * 0.38, + .half_total_w = half_total_w, + }; +} + +/// Horizontal position of the sweep front for a row (local coordinates). +fn sweepFrontX(geometry: SweepGeometry, y_local: f32, opts: Options) f32 { + return geometry.center_x + opts.slope * (y_local - geometry.center_y); +} + +fn drawBand(renderer: *c.SDL_Renderer, rect: geom.Rect, geometry: SweepGeometry, opts: Options) void { + const rows: usize = @intCast(rect.h); + for (0..rows) |row| { + const y_local: c_int = @intCast(row); + const center = sweepFrontX(geometry, @floatFromInt(y_local), opts); + drawBandRow(renderer, rect, y_local, center, geometry.half_core_w, geometry.half_total_w, opts); + } +} + +pub fn draw(renderer: *c.SDL_Renderer, rect: geom.Rect, now_ms: i64, opts: Options) void { + if (rect.w <= 0 or rect.h <= 0) return; + + const cycle_ms = @max(opts.cycle_ms, 1); + const phase_ms = @mod(now_ms, cycle_ms); + const progress = @as(f32, @floatFromInt(phase_ms)) / @as(f32, @floatFromInt(cycle_ms)); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, 85, 85, 85, opts.base_alpha); + const dim_rect = c.SDL_FRect{ + .x = @floatFromInt(rect.x), + .y = @floatFromInt(rect.y), + .w = @floatFromInt(rect.w), + .h = @floatFromInt(rect.h), + }; + _ = c.SDL_RenderFillRect(renderer, &dim_rect); + + drawBand(renderer, rect, sweepGeometry(rect, progress, waitMargin(rect, opts), opts), opts); +} + +/// Height of the horizontal strips approximating the diagonal front in +/// `drawSweepReveal`. At slope ~0.4 a 4px strip staircases by under 2px, +/// which the glow band riding the front fully hides. +const sweep_strip_h: c_int = 4; + +/// One-shot sweep that reveals whatever is already rendered underneath: +/// ahead of the moving diagonal front the pre-transition `old` texture is +/// drawn (proportionally sliced, so differently-sized content stretches the +/// same way the settle hold stretched it), behind the front the underlying +/// content shows through, and the shimmer band rides the front itself. +/// +/// The reveal travels with the minimal overscan but at the SAME pixel speed +/// as the cyclic wait shimmer (whose long off-rect stretch would otherwise +/// turn into dead time here): the caller's progress spans the wait +/// shimmer's travel distance, and the reveal finishes as soon as its own, +/// much shorter distance is covered. +pub fn drawSweepReveal( + renderer: *c.SDL_Renderer, + rect: geom.Rect, + old: *c.SDL_Texture, + old_w: f32, + old_h: f32, + progress: f32, + opts: Options, +) void { + if (rect.w <= 0 or rect.h <= 0) return; + if (old_w <= 0 or old_h <= 0) return; + + const margin = revealMargin(rect, opts); + const travel = @as(f32, @floatFromInt(rect.w)) + margin * 2.0; + const wait_travel = @as(f32, @floatFromInt(rect.w)) + waitMargin(rect, opts) * 2.0; + const t = std.math.clamp(progress * wait_travel / travel, 0.0, 1.0); + const geometry = sweepGeometry(rect, t, margin, opts); + const rect_w_f = @as(f32, @floatFromInt(rect.w)); + const rect_h_f = @as(f32, @floatFromInt(rect.h)); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + var y: c_int = 0; + while (y < rect.h) : (y += sweep_strip_h) { + const strip_h = @min(sweep_strip_h, rect.h - y); + const strip_center_y = @as(f32, @floatFromInt(y)) + @as(f32, @floatFromInt(strip_h)) * 0.5; + const front = sweepFrontX(geometry, strip_center_y, opts); + + const left_f = std.math.clamp(front, 0.0, rect_w_f); + if (left_f >= rect_w_f) continue; + + const src = c.SDL_FRect{ + .x = left_f / rect_w_f * old_w, + .y = @as(f32, @floatFromInt(y)) / rect_h_f * old_h, + .w = (rect_w_f - left_f) / rect_w_f * old_w, + .h = @as(f32, @floatFromInt(strip_h)) / rect_h_f * old_h, + }; + const dst = c.SDL_FRect{ + .x = @as(f32, @floatFromInt(rect.x)) + left_f, + .y = @floatFromInt(rect.y + y), + .w = rect_w_f - left_f, + .h = @floatFromInt(strip_h), + }; + _ = c.SDL_RenderTexture(renderer, old, &src, &dst); + } + + drawBand(renderer, rect, geometry, opts); +} + +test "sweep front covers the whole rect at progress 0 and none at progress 1" { + const opts = Options{ .base_alpha = 0, .band_alpha = 0 }; + const rect = geom.Rect{ .x = 0, .y = 0, .w = 1000, .h = 500 }; + + // Both margin variants must keep the band's glow fully off-rect at the + // start and end of a sweep, for every row. + const margins = [_]f32{ waitMargin(rect, opts), revealMargin(rect, opts) }; + for (margins) |margin| { + const start = sweepGeometry(rect, 0.0, margin, opts); + const done = sweepGeometry(rect, 1.0, margin, opts); + var y: f32 = 0; + while (y <= 500) : (y += 100) { + try std.testing.expect(sweepFrontX(start, y, opts) + start.half_total_w <= 0); + try std.testing.expect(sweepFrontX(done, y, opts) - done.half_total_w >= 1000); + } + } +} + +test "reveal margin is a small fraction of the wait margin" { + const opts = Options{ .base_alpha = 0, .band_alpha = 0 }; + const rect = geom.Rect{ .x = 0, .y = 0, .w = 1000, .h = 500 }; + // The wait shimmer overscans by a full window dimension; the reveal only + // needs to hide the band's glow, so its dead travel stays negligible. + try std.testing.expect(revealMargin(rect, opts) < halfBandWidth(rect, opts)); + try std.testing.expect(revealMargin(rect, opts) * 10 < waitMargin(rect, opts)); +} + +fn drawBandRow( + renderer: *c.SDL_Renderer, + rect: geom.Rect, + y_local: c_int, + center: f32, + half_core_w: f32, + half_total_w: f32, + opts: Options, +) void { + drawSpan(renderer, rect, y_local, center - half_core_w, center + half_core_w, opts.band_alpha); + + const fade_width = half_total_w - half_core_w; + if (fade_width <= 0) return; + + const steps_f = @as(f32, @floatFromInt(opts.gradient_steps)); + for (0..opts.gradient_steps) |step| { + const t0 = @as(f32, @floatFromInt(step)) / steps_f; + const t1 = @as(f32, @floatFromInt(step + 1)) / steps_f; + const inner = half_core_w + fade_width * t0; + const outer = half_core_w + fade_width * t1; + const alpha_f = @as(f32, @floatFromInt(opts.band_alpha)) * (1.0 - t0) * (1.0 - t0); + const alpha: u8 = @intFromFloat(@max(0.0, @min(alpha_f, 255.0))); + if (alpha == 0) continue; + + drawSpan(renderer, rect, y_local, center - outer, center - inner, alpha); + drawSpan(renderer, rect, y_local, center + inner, center + outer, alpha); + } +} + +fn drawSpan(renderer: *c.SDL_Renderer, rect: geom.Rect, y_local: c_int, left_f: f32, right_f: f32, alpha: u8) void { + if (alpha == 0 or rect.w <= 0) return; + + var left: c_int = @intFromFloat(@floor(left_f)); + var right: c_int = @intFromFloat(@ceil(right_f)); + if (right <= 0 or left >= rect.w) return; + + left = std.math.clamp(left, 0, rect.w); + right = std.math.clamp(right, 0, rect.w); + const span_w = right - left; + if (span_w <= 0) return; + + _ = c.SDL_SetRenderDrawColor(renderer, 170, 170, 170, alpha); + const span_rect = c.SDL_FRect{ + .x = @floatFromInt(rect.x + left), + .y = @floatFromInt(rect.y + y_local), + .w = @floatFromInt(span_w), + .h = 1, + }; + _ = c.SDL_RenderFillRect(renderer, &span_rect); +} diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 6f480c8..2ecc028 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -16,6 +16,7 @@ const box_drawing = @import("../gfx/box_drawing.zig"); const session_interaction = @import("../ui/components/session_interaction.zig"); const scrollbar = @import("../ui/components/scrollbar.zig"); const cwd_bar_metrics = @import("../ui/components/cwd_bar_metrics.zig"); +const shimmer = @import("../gfx/shimmer.zig"); const log = std.log.scoped(.render); @@ -56,6 +57,11 @@ pub const RenderCache = struct { presented_epoch: u64 = 0, cache_composition: CacheComposition = .content_only, cache_render_mode: CacheRenderMode = .full, + /// Pre-resize content kept alive for the settle sweep reveal, with + /// its original pixel dimensions for proportional slicing. + transition_texture: ?*c.SDL_Texture = null, + transition_w: c_int = 0, + transition_h: c_int = 0, }; pub fn init(allocator: std.mem.Allocator, session_count: usize) !RenderCache { @@ -67,10 +73,11 @@ pub const RenderCache = struct { } pub fn deinit(self: *RenderCache) void { - for (self.entries) |cache_entry| { + for (self.entries) |*cache_entry| { if (cache_entry.texture) |tex| { c.SDL_DestroyTexture(tex); } + releaseTransitionTextures(cache_entry); } self.allocator.free(self.entries); self.entries = &[_]Entry{}; @@ -80,16 +87,53 @@ pub const RenderCache = struct { return &self.entries[idx]; } - pub fn anyDirty(self: *RenderCache, sessions: []const *SessionState) bool { + /// True when a session at `idx` contributes visible pixels in the given + /// view mode. Sessions that are not visible must not trigger renders: + /// in Full view, background sessions keep producing output (their + /// `render_epoch` advances) but are never presented, so counting them + /// would keep the app rendering and presenting full-window frames at the + /// maximum rate for content nobody sees. + pub fn sessionVisibleInMode(mode: app_state.ViewMode, idx: usize, focused: usize, previous: usize) bool { + return switch (mode) { + .Grid, .GridResizing, .Expanding, .Collapsing => true, + .Full => idx == focused, + .PanningLeft, .PanningRight, .PanningUp, .PanningDown => idx == focused or idx == previous, + }; + } + + pub fn anyDirty( + self: *RenderCache, + sessions: []const *SessionState, + mode: app_state.ViewMode, + focused: usize, + previous: usize, + now_ms: i64, + ) bool { std.debug.assert(sessions.len == self.entries.len); for (sessions, 0..) |session, i| { if (!session.spawned) continue; + if (!sessionVisibleInMode(mode, i, focused, previous)) continue; if (session.render_epoch != self.entries[i].presented_epoch) return true; + // A settle dissolve animates without content changes; keep frames + // flowing until it finishes. + if (session.resizeSettleTransitionActive(now_ms)) return true; } return false; } }; +test "sessionVisibleInMode gates background sessions per view mode" { + try std.testing.expect(RenderCache.sessionVisibleInMode(.Grid, 3, 0, 0)); + try std.testing.expect(RenderCache.sessionVisibleInMode(.GridResizing, 3, 0, 0)); + try std.testing.expect(RenderCache.sessionVisibleInMode(.Expanding, 3, 0, 0)); + try std.testing.expect(RenderCache.sessionVisibleInMode(.Collapsing, 3, 0, 0)); + try std.testing.expect(RenderCache.sessionVisibleInMode(.Full, 2, 2, 0)); + try std.testing.expect(!RenderCache.sessionVisibleInMode(.Full, 3, 2, 0)); + try std.testing.expect(RenderCache.sessionVisibleInMode(.PanningLeft, 2, 2, 5)); + try std.testing.expect(RenderCache.sessionVisibleInMode(.PanningLeft, 5, 2, 5)); + try std.testing.expect(!RenderCache.sessionVisibleInMode(.PanningLeft, 3, 2, 5)); +} + pub fn render( renderer: *c.SDL_Renderer, render_cache: *RenderCache, @@ -252,7 +296,11 @@ pub fn render( const entry = render_cache.entry(anim_state.focused_session); 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); + if (resizeSettleHoldsCache(focused_session, entry, current_time)) { + renderResizeSettleHold(renderer, focused_session, &views[anim_state.focused_session], entry, animating_rect, true, apply_effects, true, current_time, true, theme, ui_scale); + } else { + 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. @@ -836,6 +884,18 @@ fn releaseCacheTexture(cache_entry: *RenderCache.Entry) void { cache_entry.cache_render_mode = .full; } +/// Deliberately separate from `releaseCacheTexture`: that one runs whenever +/// the cache is recreated for a new rect size, and the transition textures +/// hold exactly the pre-resize content the dissolve still needs. +fn releaseTransitionTextures(cache_entry: *RenderCache.Entry) void { + if (cache_entry.transition_texture) |tex| { + c.SDL_DestroyTexture(tex); + cache_entry.transition_texture = null; + } + cache_entry.transition_w = 0; + cache_entry.transition_h = 0; +} + /// 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 @@ -849,6 +909,7 @@ fn releaseNonFocusedCaches(render_cache: *RenderCache, focused_session: usize) v for (render_cache.entries, 0..) |*cache_entry, idx| { if (idx == focused_session) continue; releaseCacheTexture(cache_entry); + releaseTransitionTextures(cache_entry); } } @@ -951,6 +1012,116 @@ fn synchronizedOutputHoldsCache( return terminal.modes.get(.synchronized_output); } +/// Resize-settle hold: after a terminal resize, agents like codex erase the +/// scrollback and re-print their transcript tail as a paced multi-second +/// stream (see `SessionState.startResizeSettleHold`). While the hold is +/// active the pre-resize cached texture keeps being shown — stretched into +/// the new rect when the size changed — instead of the in-progress repaint. +/// The check runs before `ensureCacheTexture` on purpose: recreating the +/// cache texture for the new size would destroy the only copy of the +/// pre-resize content. `presented_epoch` is intentionally left stale while +/// holding so the frame loop keeps rendering (the shimmer animates and the +/// release repaints promptly). +fn resizeSettleHoldsCache( + session: *const SessionState, + cache_entry: *const RenderCache.Entry, + current_time_ms: i64, +) bool { + // A hold re-entered mid-sweep displays the transition texture (the + // original pre-resize content); a fresh hold displays the cache texture. + if (cache_entry.transition_texture == null) { + if (cache_entry.texture == null) return false; + if (cache_entry.cache_epoch == 0) return false; + } + return session.resizeSettleHoldActive(current_time_ms); +} + +const settle_shimmer_options = shimmer.Options{ + .base_alpha = 90, + .band_alpha = 60, +}; + +/// Takes ownership of the pre-resize cache texture at the start of a settle +/// transition. The cache slot is left empty so the regular path recreates it +/// and renders the new layout underneath the sweep. Also drops leftover +/// transition textures once no transition is running (the destroy forces a +/// Metal command-queue flush, but only once per transition). +fn maybeBeginSettleTransition( + session: *const SessionState, + cache_entry: *RenderCache.Entry, + current_time_ms: i64, +) void { + if (!session.resizeSettleTransitionActive(current_time_ms)) { + // Keep the textures while a hold owns them (a sweep interrupted by a + // repaint wave re-enters the hold and still displays the original + // pre-resize content); drop them once nothing is running. + if (!session.resizeSettleHoldActive(current_time_ms)) { + releaseTransitionTextures(cache_entry); + } + return; + } + if (cache_entry.transition_texture != null) return; + const old_tex = cache_entry.texture orelse return; + if (cache_entry.cache_epoch == 0) return; + + _ = c.SDL_SetTextureScaleMode(old_tex, c.SDL_SCALEMODE_LINEAR); + cache_entry.transition_texture = old_tex; + cache_entry.transition_w = cache_entry.width; + cache_entry.transition_h = cache_entry.height; + cache_entry.texture = null; + releaseCacheTexture(cache_entry); +} + +/// Sweep-reveals the freshly laid-out content: the pre-resize texture stays +/// ahead of a diagonal shimmer band while the new content (already rendered +/// underneath) appears crisply behind it. +fn renderSettleTransition( + renderer: *c.SDL_Renderer, + session: *const SessionState, + cache_entry: *RenderCache.Entry, + rect: Rect, + current_time_ms: i64, +) void { + const old_tex = cache_entry.transition_texture orelse return; + if (!session.resizeSettleTransitionActive(current_time_ms)) return; + + // Linear progress: the reveal pass moves at the same constant speed as + // the wait shimmer's band (same options, same cycle duration). + shimmer.drawSweepReveal( + renderer, + rect, + old_tex, + @floatFromInt(cache_entry.transition_w), + @floatFromInt(cache_entry.transition_h), + session.resizeSettleTransitionProgress(current_time_ms), + settle_shimmer_options, + ); +} + +fn renderResizeSettleHold( + renderer: *c.SDL_Renderer, + session: *SessionState, + view: *SessionViewState, + cache_entry: *RenderCache.Entry, + rect: Rect, + is_focused: bool, + apply_effects: bool, + render_overlays: bool, + current_time_ms: i64, + is_grid_view: bool, + theme: *const colors.Theme, + ui_scale: f32, +) void { + const tex = cache_entry.transition_texture orelse cache_entry.texture orelse return; + renderCachedTexture(renderer, tex, rect); + if (render_overlays) { + renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme, ui_scale); + } + if (session.resizeSettleShimmerVisible(current_time_ms)) { + shimmer.draw(renderer, rect, current_time_ms, settle_shimmer_options); + } +} + fn refreshSessionCacheTexture( renderer: *c.SDL_Renderer, session: *SessionState, @@ -1029,6 +1200,13 @@ fn renderSessionCached( const composition = cacheComposition(cache_overlays); const render_mode = cacheRenderMode(is_grid_view); + if (resizeSettleHoldsCache(session, cache_entry, current_time_ms)) { + renderResizeSettleHold(renderer, session, view, cache_entry, rect, is_focused, apply_effects, render_overlays, current_time_ms, is_grid_view, theme, ui_scale); + return; + } + + maybeBeginSettleTransition(session, cache_entry, current_time_ms); + const can_cache = ensureCacheTexture(renderer, cache_entry, session, rect.w, rect.h); if (can_cache) { if (cache_entry.texture) |tex| { @@ -1043,6 +1221,8 @@ fn renderSessionCached( renderCachedTexture(renderer, tex, rect); } + renderSettleTransition(renderer, session, cache_entry, rect, current_time_ms); + if (render_overlays and composition == .content_only) { renderSessionOverlays(renderer, session, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme, ui_scale); } diff --git a/src/session/state.zig b/src/session/state.zig index e41ae7d..b34d187 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -77,6 +77,37 @@ const session_id_buf_len: usize = 32; const synchronized_output_timeout_ms: i64 = 1000; const synchronized_output_quiet_ms: i64 = 100; const synchronized_output_max_timeout_ms: i64 = 5000; +/// Resize-settle hold: after a terminal resize, agents like codex erase the +/// scrollback and re-print their transcript tail as a paced multi-second +/// stream. The renderer keeps showing the pre-resize content while that +/// repaint is in flight; the hold releases once output has been quiet for +/// `resize_settle_quiet_ms` or after `resize_settle_max_ms` at the latest +/// (an actively streaming session never goes quiet). +pub const resize_settle_quiet_ms: i64 = 400; +pub const resize_settle_max_ms: i64 = 5000; +/// Holds longer than this get the busy shimmer drawn on top. +pub const resize_settle_shimmer_after_ms: i64 = 500; +/// When a settle hold releases, the held (pre-resize) content sweeps into +/// the freshly laid-out content over this duration instead of snapping. +/// Matches the wait shimmer's cycle so the reveal pass moves at the same +/// speed as the band the user was just watching. +pub const resize_settle_transition_ms: i64 = 1400; +/// Grace period for the app to react to the resize: while no output has +/// arrived since the resize, the quiet release is deferred up to this long +/// (agents debounce SIGWINCH and pace their first repaint chunk, so a purely +/// quiet-based release can fire just before the repaint starts). +pub const resize_settle_response_grace_ms: i64 = 700; +/// A single output chunk at least this large during a running sweep — or +/// within `resize_settle_rearm_window_ms` after the release — is treated as +/// another repaint wave (codex re-emits its transcript twice per resize, +/// with a lull between the waves): the hold re-engages so the wave stays +/// hidden and one more sweep runs once it settles. Status-line ticks are +/// tens of bytes and never reach this threshold. +pub const resize_settle_reemit_chunk_bytes: usize = 2048; +pub const resize_settle_rearm_window_ms: i64 = 3000; +/// At most this many re-engagements per resize, so sustained heavy output +/// (unrelated to the resize) cannot keep freezing the session. +pub const resize_settle_max_rearms: u8 = 2; var next_session_id = std.atomic.Value(usize).init(0); pub const SessionState = struct { @@ -128,6 +159,12 @@ pub const SessionState = struct { quit_capture_active: bool = false, synchronized_output_started_ms: i64 = 0, synchronized_output_last_output_ms: i64 = 0, + resize_settle_started_ms: i64 = 0, + resize_settle_last_output_ms: i64 = 0, + resize_settle_output_seen: bool = false, + resize_settle_released_ms: i64 = 0, + resize_settle_rearms: u8 = 0, + resize_settle_transition_started_ms: i64 = 0, const WaitContext = struct { session: *SessionState, @@ -563,6 +600,98 @@ pub const SessionState = struct { return current_time_ms - quiet_started_ms; } + /// Begin holding the rendered content after a terminal resize. See + /// `resize_settle_quiet_ms` for the release conditions. + pub fn startResizeSettleHold(self: *SessionState, current_time_ms: i64) void { + self.resize_settle_started_ms = current_time_ms; + self.resize_settle_last_output_ms = current_time_ms; + self.resize_settle_output_seen = false; + self.resize_settle_released_ms = 0; + self.resize_settle_rearms = 0; + // A new hold supersedes any sweep still running from the previous + // release; the held frame becomes the next transition's "old" content. + self.resize_settle_transition_started_ms = 0; + } + + fn noteResizeSettleOutput(self: *SessionState, current_time_ms: i64, bytes: usize) void { + if (self.resize_settle_started_ms != 0) { + self.resize_settle_last_output_ms = current_time_ms; + self.resize_settle_output_seen = true; + return; + } + // A repaint wave landing during the sweep — or shortly after it — + // would visibly redraw the session (codex re-emits its transcript + // twice per resize, with a lull between the waves). Re-engage the + // hold so the wave stays hidden and one more sweep runs once it + // settles. + if (bytes < resize_settle_reemit_chunk_bytes) return; + if (self.resize_settle_rearms >= resize_settle_max_rearms) return; + const in_transition = self.resizeSettleTransitionActive(current_time_ms); + const in_rearm_window = self.resize_settle_released_ms != 0 and + current_time_ms >= self.resize_settle_released_ms and + current_time_ms - self.resize_settle_released_ms < resize_settle_rearm_window_ms; + if (!in_transition and !in_rearm_window) return; + + self.resize_settle_started_ms = current_time_ms; + self.resize_settle_last_output_ms = current_time_ms; + self.resize_settle_output_seen = true; + self.resize_settle_rearms += 1; + self.resize_settle_transition_started_ms = 0; + } + + pub fn resizeSettleHoldActive(self: *const SessionState, current_time_ms: i64) bool { + if (self.resize_settle_started_ms == 0) return false; + if (!self.spawned or self.dead) return false; + // A hold can be started from a timestamp taken later in the same + // frame than the caller's clock (output processing re-arms with a + // fresh reading); treat it as just-started rather than expired. + if (current_time_ms < self.resize_settle_started_ms) return true; + const elapsed_ms = current_time_ms - self.resize_settle_started_ms; + if (elapsed_ms >= resize_settle_max_ms) return false; + const quiet_ms = quietDurationMs( + current_time_ms, + self.resize_settle_started_ms, + self.resize_settle_last_output_ms, + ); + if (quiet_ms < resize_settle_quiet_ms) return true; + // Quiet, but the app may not have reacted to the resize yet. + return !self.resize_settle_output_seen and elapsed_ms < resize_settle_response_grace_ms; + } + + /// True once the hold has lasted long enough to warrant the busy shimmer. + pub fn resizeSettleShimmerVisible(self: *const SessionState, current_time_ms: i64) bool { + if (self.resize_settle_started_ms == 0) return false; + return current_time_ms - self.resize_settle_started_ms >= resize_settle_shimmer_after_ms; + } + + /// Clears finished holds. Called once per frame; marks the session dirty + /// on release so the settled content repaints immediately, and starts the + /// dissolve transition from the held content to the new layout. + pub fn expireResizeSettleHold(self: *SessionState, current_time_ms: i64) void { + if (self.resize_settle_started_ms == 0) return; + if (self.resizeSettleHoldActive(current_time_ms)) return; + self.resize_settle_started_ms = 0; + self.resize_settle_last_output_ms = 0; + self.resize_settle_released_ms = current_time_ms; + self.resize_settle_transition_started_ms = current_time_ms; + self.markDirty(); + } + + pub fn resizeSettleTransitionActive(self: *const SessionState, current_time_ms: i64) bool { + if (self.resize_settle_transition_started_ms == 0) return false; + if (!self.spawned or self.dead) return false; + if (current_time_ms < self.resize_settle_transition_started_ms) return false; + return current_time_ms - self.resize_settle_transition_started_ms < resize_settle_transition_ms; + } + + /// Dissolve progress in [0, 1]; 1 once the transition has finished. + pub fn resizeSettleTransitionProgress(self: *const SessionState, current_time_ms: i64) f32 { + if (self.resize_settle_transition_started_ms == 0) return 1.0; + if (current_time_ms <= self.resize_settle_transition_started_ms) return 0.0; + const elapsed: f32 = @floatFromInt(current_time_ms - self.resize_settle_transition_started_ms); + return @min(1.0, elapsed / @as(f32, @floatFromInt(resize_settle_transition_ms))); + } + fn clearTerminalSelection(self: *SessionState) void { if (!self.spawned) return; if (self.terminal) |*terminal| { @@ -612,6 +741,10 @@ pub const SessionState = struct { try stream.nextSlice(self.output_buf[0..n]); const processed_at_ms = std.time.milliTimestamp(); self.updateSynchronizedOutputState(was_synchronized_output, processed_at_ms); + // Pass the running total for this drain call, not the single read: + // macOS PTYs deliver data in ~1 KB quanta, so an individual read + // never reaches the repaint-wave threshold on its own. + self.noteResizeSettleOutput(processed_at_ms, bytes_consumed); self.markDirty(); // Keep draining until the PTY would block (or the byte budget is @@ -1070,6 +1203,193 @@ test "synchronized output keeps future last-output sample" { try std.testing.expectEqual(@as(i64, 1101), session.synchronized_output_last_output_ms); } +test "resize settle hold stays active while output keeps arriving" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + session.resize_settle_started_ms = 0; + session.resize_settle_last_output_ms = 0; + session.resize_settle_output_seen = false; + session.resize_settle_transition_started_ms = 0; + + try std.testing.expect(!session.resizeSettleHoldActive(1000)); + + session.startResizeSettleHold(1000); + try std.testing.expect(session.resizeSettleHoldActive(1000)); + try std.testing.expect(session.resizeSettleHoldActive(1399)); + + // Output keeps the hold alive past the quiet window. + session.noteResizeSettleOutput(1300, 64); + try std.testing.expect(session.resizeSettleHoldActive(1699)); + try std.testing.expect(!session.resizeSettleHoldActive(1700)); +} + +test "resize settle hold defers the quiet release until output was seen" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + // No output at all after the resize: the quiet window alone must not + // release the hold while the app may still be debouncing the SIGWINCH. + session.startResizeSettleHold(1000); + try std.testing.expect(session.resizeSettleHoldActive(1000 + resize_settle_quiet_ms)); + try std.testing.expect(session.resizeSettleHoldActive(1000 + resize_settle_response_grace_ms - 1)); + try std.testing.expect(!session.resizeSettleHoldActive(1000 + resize_settle_response_grace_ms)); + + // Once output was seen, the plain quiet release applies. + session.startResizeSettleHold(2000); + session.noteResizeSettleOutput(2050, 64); + try std.testing.expect(session.resizeSettleHoldActive(2050 + resize_settle_quiet_ms - 1)); + try std.testing.expect(!session.resizeSettleHoldActive(2050 + resize_settle_quiet_ms)); +} + +test "resize settle hold releases at the maximum duration" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + session.startResizeSettleHold(1000); + // Continuous output cannot extend the hold beyond the hard cap. + session.noteResizeSettleOutput(5999, 64); + try std.testing.expect(session.resizeSettleHoldActive(5999)); + try std.testing.expect(!session.resizeSettleHoldActive(6000)); +} + +test "a repaint wave during the sweep re-enters the hold" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + session.startResizeSettleHold(1000); + session.noteResizeSettleOutput(1100, 4096); + session.expireResizeSettleHold(1500); + try std.testing.expect(session.resizeSettleTransitionActive(1600)); + + // Small status ticks must not interrupt the sweep... + session.noteResizeSettleOutput(1650, 64); + try std.testing.expect(session.resizeSettleTransitionActive(1650)); + try std.testing.expect(!session.resizeSettleHoldActive(1650)); + + // ...but a re-emit-sized chunk rolls the sweep back into the hold. + session.noteResizeSettleOutput(1700, resize_settle_reemit_chunk_bytes); + try std.testing.expect(!session.resizeSettleTransitionActive(1700)); + try std.testing.expect(session.resizeSettleHoldActive(1700)); + + // An expire call with a slightly older frame clock in the same frame + // must not release the just-re-armed hold. + session.expireResizeSettleHold(1699); + try std.testing.expect(session.resizeSettleHoldActive(1700)); +} + +test "resize settle hold expiry clears state and marks the session dirty" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + session.startResizeSettleHold(1000); + session.noteResizeSettleOutput(1000, 64); + session.expireResizeSettleHold(1100); + try std.testing.expectEqual(@as(i64, 1000), session.resize_settle_started_ms); + try std.testing.expectEqual(@as(u64, 1), session.render_epoch); + + session.expireResizeSettleHold(1400); + try std.testing.expectEqual(@as(i64, 0), session.resize_settle_started_ms); + try std.testing.expectEqual(@as(u64, 2), session.render_epoch); + try std.testing.expect(!session.resizeSettleHoldActive(1400)); +} + +test "resize settle shimmer appears only after the grace period" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + session.resize_settle_started_ms = 0; + + try std.testing.expect(!session.resizeSettleShimmerVisible(1000)); + session.startResizeSettleHold(1000); + try std.testing.expect(!session.resizeSettleShimmerVisible(1499)); + try std.testing.expect(session.resizeSettleShimmerVisible(1500)); +} + +test "resize settle release starts the dissolve transition" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + session.resize_settle_transition_started_ms = 0; + + session.startResizeSettleHold(1000); + session.noteResizeSettleOutput(1000, 64); + try std.testing.expect(!session.resizeSettleTransitionActive(1000)); + + session.expireResizeSettleHold(1400); + try std.testing.expect(session.resizeSettleTransitionActive(1400)); + try std.testing.expect(session.resizeSettleTransitionActive(1400 + resize_settle_transition_ms - 1)); + try std.testing.expect(!session.resizeSettleTransitionActive(1400 + resize_settle_transition_ms)); + + try std.testing.expectEqual(@as(f32, 0.0), session.resizeSettleTransitionProgress(1400)); + try std.testing.expectApproxEqAbs(@as(f32, 0.5), session.resizeSettleTransitionProgress(1400 + @divTrunc(resize_settle_transition_ms, 2)), 0.001); + try std.testing.expectEqual(@as(f32, 1.0), session.resizeSettleTransitionProgress(1400 + resize_settle_transition_ms * 2)); +} + +test "a new resize settle hold cancels a running transition" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + session.startResizeSettleHold(1000); + session.noteResizeSettleOutput(1000, 64); + session.expireResizeSettleHold(1400); + try std.testing.expect(session.resizeSettleTransitionActive(1500)); + + session.startResizeSettleHold(1600); + try std.testing.expect(!session.resizeSettleTransitionActive(1600)); +} + +test "a repaint wave shortly after the sweep re-engages the hold at most twice" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + session.startResizeSettleHold(1000); + session.noteResizeSettleOutput(1000, 64); + session.expireResizeSettleHold(1400); + try std.testing.expect(!session.resizeSettleTransitionActive(2500)); + + // First wave after the sweep finished but within the re-arm window. + session.noteResizeSettleOutput(2500, 4096); + try std.testing.expect(session.resizeSettleHoldActive(2500)); + session.expireResizeSettleHold(2900); + try std.testing.expect(session.resizeSettleTransitionActive(2900)); + + // Second wave: still re-engages. + session.noteResizeSettleOutput(4000, 4096); + try std.testing.expect(session.resizeSettleHoldActive(4000)); + session.expireResizeSettleHold(4400); + + // Third burst: the re-arm budget is spent; output renders live. + session.noteResizeSettleOutput(5000, 4096); + try std.testing.expect(!session.resizeSettleHoldActive(5000)); +} + +test "resize settle hold ignores dead sessions" { + var session: SessionState = undefined; + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + + session.startResizeSettleHold(1000); + session.dead = true; + try std.testing.expect(!session.resizeSettleHoldActive(1100)); +} + test "synchronized output hard timeout clears chatty sessions" { const allocator = std.testing.allocator; diff --git a/src/ui/components/cwd_bar.zig b/src/ui/components/cwd_bar.zig index 98c0059..d9e7405 100644 --- a/src/ui/components/cwd_bar.zig +++ b/src/ui/components/cwd_bar.zig @@ -43,24 +43,17 @@ pub const CwdBarComponent = struct { parent_h: c_int = 0, cached_path: ?[]const u8 = null, font_size: c_int = 0, + // Hotkey label texture is cached separately: recreating it per frame + // forces SDL's Metal backend to flush the command queue on every + // destroy, which can block on drawable acquisition under load. + hotkey_tex: ?*c.SDL_Texture = null, + hotkey_w: c_int = 0, + hotkey_h: c_int = 0, + hotkey_grid_index: ?usize = null, + hotkey_font_size: c_int = 0, fn deinit(self: *SessionCache, allocator: std.mem.Allocator) void { - if (self.basename_tex) |tex| { - c.SDL_DestroyTexture(tex); - self.basename_tex = null; - } - if (self.parent_tex) |tex| { - c.SDL_DestroyTexture(tex); - self.parent_tex = null; - } - if (self.cached_path) |path| { - allocator.free(path); - self.cached_path = null; - } - self.basename_w = 0; - self.basename_h = 0; - self.parent_w = 0; - self.parent_h = 0; + self.invalidate(allocator); self.font_size = 0; } @@ -81,6 +74,18 @@ pub const CwdBarComponent = struct { self.basename_h = 0; self.parent_w = 0; self.parent_h = 0; + self.invalidateHotkey(); + } + + fn invalidateHotkey(self: *SessionCache) void { + if (self.hotkey_tex) |tex| { + c.SDL_DestroyTexture(tex); + self.hotkey_tex = null; + } + self.hotkey_w = 0; + self.hotkey_h = 0; + self.hotkey_grid_index = null; + self.hotkey_font_size = 0; } }; @@ -205,30 +210,41 @@ pub const CwdBarComponent = struct { const fg = host.theme.foreground; const dimmed_fg = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 180 }; + var sc = &self.session_caches[session_idx]; + var hotkey_width: c_int = 0; if (grid_index) |gi| { if (input.terminalHotkeyLabel(gi)) |hotkey_str| { - const hotkey_surface = c.TTF_RenderText_Blended(cwd_font, hotkey_str.ptr, hotkey_str.len, dimmed_fg) orelse return; - defer c.SDL_DestroySurface(hotkey_surface); - - const hotkey_texture = c.SDL_CreateTextureFromSurface(renderer, hotkey_surface) orelse return; - defer c.SDL_DestroyTexture(hotkey_texture); - - var hotkey_w_f: f32 = 0; - var hotkey_h_f: f32 = 0; - _ = c.SDL_GetTextureSize(hotkey_texture, &hotkey_w_f, &hotkey_h_f); - hotkey_width = @intFromFloat(hotkey_w_f); - const hotkey_height: c_int = @intFromFloat(hotkey_h_f); - - const hotkey_x = bar_rect.x + bar_rect.w - hotkey_width - padding; - const hotkey_y = bar_rect.y + @divFloor(bar_rect.h - hotkey_height, 2); - - _ = c.SDL_RenderTexture(renderer, hotkey_texture, null, &c.SDL_FRect{ - .x = @floatFromInt(hotkey_x), - .y = @floatFromInt(hotkey_y), - .w = hotkey_w_f, - .h = hotkey_h_f, - }); + if (sc.hotkey_tex == null or sc.hotkey_grid_index != gi or sc.hotkey_font_size != font_px) { + sc.invalidateHotkey(); + + const hotkey_surface = c.TTF_RenderText_Blended(cwd_font, hotkey_str.ptr, hotkey_str.len, dimmed_fg) orelse return; + defer c.SDL_DestroySurface(hotkey_surface); + + const hotkey_texture = c.SDL_CreateTextureFromSurface(renderer, hotkey_surface) orelse return; + + var hotkey_w_f: f32 = 0; + var hotkey_h_f: f32 = 0; + _ = c.SDL_GetTextureSize(hotkey_texture, &hotkey_w_f, &hotkey_h_f); + sc.hotkey_tex = hotkey_texture; + sc.hotkey_w = @intFromFloat(hotkey_w_f); + sc.hotkey_h = @intFromFloat(hotkey_h_f); + sc.hotkey_grid_index = gi; + sc.hotkey_font_size = font_px; + } + + if (sc.hotkey_tex) |hotkey_texture| { + hotkey_width = sc.hotkey_w; + const hotkey_x = bar_rect.x + bar_rect.w - hotkey_width - padding; + const hotkey_y = bar_rect.y + @divFloor(bar_rect.h - sc.hotkey_h, 2); + + _ = c.SDL_RenderTexture(renderer, hotkey_texture, null, &c.SDL_FRect{ + .x = @floatFromInt(hotkey_x), + .y = @floatFromInt(hotkey_y), + .w = @floatFromInt(sc.hotkey_w), + .h = @floatFromInt(sc.hotkey_h), + }); + } } } @@ -249,8 +265,6 @@ pub const CwdBarComponent = struct { break :blk basename_with_slash_buf[0 .. cwd_basename.len + 1]; }; - var sc = &self.session_caches[session_idx]; - const path_changed = if (sc.cached_path) |cp| !std.mem.eql(u8, cp, cwd_path) else true; const font_changed = sc.font_size != font_px; diff --git a/src/ui/components/glyph_badge.zig b/src/ui/components/glyph_badge.zig new file mode 100644 index 0000000..e3c144b --- /dev/null +++ b/src/ui/components/glyph_badge.zig @@ -0,0 +1,72 @@ +const c = @import("../../c.zig"); +const colors = @import("../../colors.zig"); +const dpi = @import("../../dpi.zig"); +const geom = @import("../../geom.zig"); +const types = @import("../types.zig"); + +/// Cached texture for a small static text badge (e.g. the "⌘O" hint shown on +/// collapsed overlays). Rasterizing the text and destroying the texture every +/// frame forces SDL's Metal backend to flush its command queue on each +/// destroy, which can block on drawable acquisition for hundreds of +/// milliseconds while the app is rendering continuously. +pub const GlyphBadge = struct { + text: [:0]const u8, + texture: ?*c.SDL_Texture = null, + w: f32 = 0, + h: f32 = 0, + font_size: c_int = 0, + font_generation: u64 = 0, + fg: c.SDL_Color = .{ .r = 0, .g = 0, .b = 0, .a = 0 }, + + pub fn deinit(self: *GlyphBadge) void { + if (self.texture) |tex| { + c.SDL_DestroyTexture(tex); + self.texture = null; + } + } + + /// Renders the badge centered in `rect`, rebuilding the cached texture + /// only when the font size, font generation, or theme color changes. + pub fn render( + self: *GlyphBadge, + renderer: *c.SDL_Renderer, + rect: geom.Rect, + ui_scale: f32, + assets: *types.UiAssets, + theme: *const colors.Theme, + ) void { + const cache = assets.font_cache orelse return; + const font_size = dpi.scale(@max(12, @min(20, @divFloor(rect.h, 2))), ui_scale); + const fg = theme.foreground; + const fg_color = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 255 }; + + const stale = self.texture == null or + self.font_size != font_size or + self.font_generation != cache.generation or + !colors.colorsEqual(self.fg, fg_color); + if (stale) { + self.deinit(); + + const fonts = cache.get(font_size) catch return; + const surface = c.TTF_RenderText_Blended(fonts.regular, self.text.ptr, @intCast(self.text.len), fg_color) orelse return; + defer c.SDL_DestroySurface(surface); + + const texture = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return; + _ = c.SDL_GetTextureSize(texture, &self.w, &self.h); + self.texture = texture; + self.font_size = font_size; + self.font_generation = cache.generation; + self.fg = fg_color; + } + + const texture = self.texture orelse return; + const text_x = rect.x + @divFloor(rect.w - @as(c_int, @intFromFloat(self.w)), 2); + const text_y = rect.y + @divFloor(rect.h - @as(c_int, @intFromFloat(self.h)), 2); + _ = c.SDL_RenderTexture(renderer, texture, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = self.w, + .h = self.h, + }); + } +}; diff --git a/src/ui/components/help_overlay.zig b/src/ui/components/help_overlay.zig index a74a7b8..a08ab4b 100644 --- a/src/ui/components/help_overlay.zig +++ b/src/ui/components/help_overlay.zig @@ -8,6 +8,7 @@ const UiComponent = @import("../component.zig").UiComponent; const dpi = @import("../../dpi.zig"); const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; const ExpandingOverlay = @import("expanding_overlay.zig").ExpandingOverlay; +const GlyphBadge = @import("glyph_badge.zig").GlyphBadge; const Shortcut = struct { key: []const u8, desc: []const u8 }; const shortcuts = [_]Shortcut{ @@ -57,6 +58,7 @@ pub const HelpOverlayComponent = struct { overlay: ExpandingOverlay = ExpandingOverlay.init(0, help_button_margin, help_button_size_small, help_button_size_large, help_button_animation_duration_ms), cache: ?*Cache = null, first_frame: FirstFrameGuard = .{}, + badge: GlyphBadge = .{ .text = "⌘?" }, const help_button_size_small: c_int = 40; const help_button_size_large: c_int = 440; const help_button_margin: c_int = 20; @@ -75,6 +77,7 @@ pub const HelpOverlayComponent = struct { } fn deinit(self: *HelpOverlayComponent, _: *c.SDL_Renderer) void { + self.badge.deinit(); self.destroyCache(); self.allocator.destroy(self); } @@ -162,41 +165,11 @@ pub const HelpOverlayComponent = struct { } switch (self.overlay.state) { - .Closed, .Collapsing, .Expanding => self.renderQuestionMark(renderer, rect, host.ui_scale, assets, host.theme), + .Closed, .Collapsing, .Expanding => self.badge.render(renderer, rect, host.ui_scale, assets, host.theme), .Open => self.renderHelpOverlay(renderer, rect, host.ui_scale, assets, host.theme), } } - fn renderQuestionMark(_: *HelpOverlayComponent, renderer: *c.SDL_Renderer, rect: geom.Rect, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) void { - const cache = assets.font_cache orelse return; - const font_size = dpi.scale(@max(12, @min(20, @divFloor(rect.h, 2))), ui_scale); - const fonts = cache.get(font_size) catch return; - - const question_mark = "⌘?"; - const fg = theme.foreground; - const fg_color = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 255 }; - const surface = c.TTF_RenderText_Blended(fonts.regular, question_mark.ptr, @intCast(question_mark.len), fg_color) orelse return; - defer c.SDL_DestroySurface(surface); - - const texture = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return; - defer c.SDL_DestroyTexture(texture); - - var text_width_f: f32 = 0; - var text_height_f: f32 = 0; - _ = c.SDL_GetTextureSize(texture, &text_width_f, &text_height_f); - - const text_x = rect.x + @divFloor(rect.w - @as(c_int, @intFromFloat(text_width_f)), 2); - const text_y = rect.y + @divFloor(rect.h - @as(c_int, @intFromFloat(text_height_f)), 2); - - const dest_rect = c.SDL_FRect{ - .x = @floatFromInt(text_x), - .y = @floatFromInt(text_y), - .w = text_width_f, - .h = text_height_f, - }; - _ = c.SDL_RenderTexture(renderer, texture, null, &dest_rect); - } - fn renderHelpOverlay(self: *HelpOverlayComponent, renderer: *c.SDL_Renderer, rect: geom.Rect, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) void { const cache = self.ensureCache(renderer, ui_scale, assets, theme) orelse return; const scaled_margin: c_int = dpi.scale(help_button_margin, ui_scale); diff --git a/src/ui/components/quit_blocking_overlay.zig b/src/ui/components/quit_blocking_overlay.zig index cf44884..7b3d5ba 100644 --- a/src/ui/components/quit_blocking_overlay.zig +++ b/src/ui/components/quit_blocking_overlay.zig @@ -1,5 +1,7 @@ const std = @import("std"); const c = @import("../../c.zig"); +const geom = @import("../../geom.zig"); +const shimmer = @import("../../gfx/shimmer.zig"); const UiComponent = @import("../component.zig").UiComponent; const types = @import("../types.zig"); @@ -7,13 +9,10 @@ pub const QuitBlockingOverlayComponent = struct { allocator: std.mem.Allocator, active: bool = false, - const base_alpha: u8 = 145; - const shimmer_alpha: u8 = 78; - const shimmer_cycle_ms: i64 = 1400; - const shimmer_width_divisor: c_int = 12; - const shimmer_min_width: c_int = 30; - const shimmer_gradient_steps: usize = 4; - const shimmer_slope: f32 = -0.42; + const shimmer_options = shimmer.Options{ + .base_alpha = 145, + .band_alpha = 78, + }; pub fn init(allocator: std.mem.Allocator) !*QuitBlockingOverlayComponent { const self = try allocator.create(QuitBlockingOverlayComponent); @@ -83,78 +82,12 @@ pub const QuitBlockingOverlayComponent = struct { if (!self.active) return; if (host.window_w <= 0 or host.window_h <= 0) return; - const cycle_ms = @max(shimmer_cycle_ms, 1); - const phase_ms = @mod(host.now_ms, cycle_ms); - const progress = @as(f32, @floatFromInt(phase_ms)) / @as(f32, @floatFromInt(cycle_ms)); - - const window_rect = c.SDL_FRect{ + shimmer.draw(renderer, geom.Rect{ .x = 0, .y = 0, - .w = @floatFromInt(host.window_w), - .h = @floatFromInt(host.window_h), - }; - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - _ = c.SDL_SetRenderDrawColor(renderer, 85, 85, 85, base_alpha); - _ = c.SDL_RenderFillRect(renderer, &window_rect); - - const shimmer_w: c_int = @max(@divFloor(host.window_w, shimmer_width_divisor), shimmer_min_width); - const half_total_w = @as(f32, @floatFromInt(shimmer_w)) * 0.5; - const half_core_w = half_total_w * 0.38; - const margin = @as(f32, @floatFromInt(@max(host.window_w, host.window_h))); - - const center_x = -margin + progress * (@as(f32, @floatFromInt(host.window_w)) + margin * 2.0); - const center_y = -margin + progress * (@as(f32, @floatFromInt(host.window_h)) + margin * 2.0); - - const window_h: usize = @intCast(host.window_h); - for (0..window_h) |row| { - const y: c_int = @intCast(row); - const y_f = @as(f32, @floatFromInt(y)); - const center = center_x + shimmer_slope * (y_f - center_y); - drawBandRow(renderer, y, host.window_w, center, half_core_w, half_total_w); - } - } - - fn drawBandRow(renderer: *c.SDL_Renderer, y: c_int, window_w: c_int, center: f32, half_core_w: f32, half_total_w: f32) void { - drawSpan(renderer, y, window_w, center - half_core_w, center + half_core_w, shimmer_alpha); - - const fade_width = half_total_w - half_core_w; - if (fade_width <= 0) return; - - const steps_f = @as(f32, @floatFromInt(shimmer_gradient_steps)); - for (0..shimmer_gradient_steps) |step| { - const t0 = @as(f32, @floatFromInt(step)) / steps_f; - const t1 = @as(f32, @floatFromInt(step + 1)) / steps_f; - const inner = half_core_w + fade_width * t0; - const outer = half_core_w + fade_width * t1; - const alpha_f = @as(f32, @floatFromInt(shimmer_alpha)) * (1.0 - t0) * (1.0 - t0); - const alpha: u8 = @intFromFloat(@max(0.0, @min(alpha_f, 255.0))); - if (alpha == 0) continue; - - drawSpan(renderer, y, window_w, center - outer, center - inner, alpha); - drawSpan(renderer, y, window_w, center + inner, center + outer, alpha); - } - } - - fn drawSpan(renderer: *c.SDL_Renderer, y: c_int, window_w: c_int, left_f: f32, right_f: f32, alpha: u8) void { - if (alpha == 0 or window_w <= 0) return; - - var left: c_int = @intFromFloat(@floor(left_f)); - var right: c_int = @intFromFloat(@ceil(right_f)); - if (right <= 0 or left >= window_w) return; - - left = std.math.clamp(left, 0, window_w); - right = std.math.clamp(right, 0, window_w); - const span_w = right - left; - if (span_w <= 0) return; - - _ = c.SDL_SetRenderDrawColor(renderer, 170, 170, 170, alpha); - const rect = c.SDL_FRect{ - .x = @floatFromInt(left), - .y = @floatFromInt(y), - .w = @floatFromInt(span_w), - .h = 1, - }; - _ = c.SDL_RenderFillRect(renderer, &rect); + .w = host.window_w, + .h = host.window_h, + }, host.now_ms, shimmer_options); } const vtable = UiComponent.VTable{ diff --git a/src/ui/components/recent_folders_overlay.zig b/src/ui/components/recent_folders_overlay.zig index a5f59eb..0f1f177 100644 --- a/src/ui/components/recent_folders_overlay.zig +++ b/src/ui/components/recent_folders_overlay.zig @@ -9,6 +9,7 @@ const UiComponent = @import("../component.zig").UiComponent; const dpi = @import("../../dpi.zig"); const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; const ExpandingOverlay = @import("expanding_overlay.zig").ExpandingOverlay; +const GlyphBadge = @import("glyph_badge.zig").GlyphBadge; const flowing_line = @import("flowing_line.zig"); const search_utils = @import("search_utils.zig"); const font_cache_mod = @import("../../font_cache.zig"); @@ -19,6 +20,7 @@ pub const RecentFoldersOverlayComponent = struct { allocator: std.mem.Allocator, overlay: ExpandingOverlay = ExpandingOverlay.init(1, button_margin, button_size_small, button_size_large, button_animation_duration_ms), first_frame: FirstFrameGuard = .{}, + badge: GlyphBadge = .{ .text = "⌘O" }, all_folders: std.ArrayList(Folder) = .{}, filtered_indices: std.ArrayList(usize) = .{}, @@ -78,6 +80,7 @@ pub const RecentFoldersOverlayComponent = struct { fn deinit(self_ptr: *anyopaque, _: *c.SDL_Renderer) void { const self: *RecentFoldersOverlayComponent = @ptrCast(@alignCast(self_ptr)); + self.badge.deinit(); self.destroyCache(); self.clearFolders(); self.all_folders.deinit(self.allocator); @@ -364,43 +367,13 @@ pub const RecentFoldersOverlayComponent = struct { } switch (self.overlay.state) { - .Closed, .Collapsing, .Expanding => self.renderGlyph(renderer, rect, ui_host.ui_scale, assets, ui_host.theme), + .Closed, .Collapsing, .Expanding => self.badge.render(renderer, rect, ui_host.ui_scale, assets, ui_host.theme), .Open => self.renderOverlay(renderer, ui_host, rect, ui_host.ui_scale, assets, ui_host.theme), } self.first_frame.markDrawn(); } - fn renderGlyph(_: *RecentFoldersOverlayComponent, renderer: *c.SDL_Renderer, rect: geom.Rect, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) void { - const cache = assets.font_cache orelse return; - const font_size = dpi.scale(@max(12, @min(20, @divFloor(rect.h, 2))), ui_scale); - const fonts = cache.get(font_size) catch return; - - const glyph = "⌘O"; - const fg = theme.foreground; - const fg_color = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 255 }; - const surface = c.TTF_RenderText_Blended(fonts.regular, glyph.ptr, @intCast(glyph.len), fg_color) orelse return; - defer c.SDL_DestroySurface(surface); - - const texture = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return; - defer c.SDL_DestroyTexture(texture); - - var text_width_f: f32 = 0; - var text_height_f: f32 = 0; - _ = c.SDL_GetTextureSize(texture, &text_width_f, &text_height_f); - - const text_x = rect.x + @divFloor(rect.w - @as(c_int, @intFromFloat(text_width_f)), 2); - const text_y = rect.y + @divFloor(rect.h - @as(c_int, @intFromFloat(text_height_f)), 2); - - const dest_rect = c.SDL_FRect{ - .x = @floatFromInt(text_x), - .y = @floatFromInt(text_y), - .w = text_width_f, - .h = text_height_f, - }; - _ = c.SDL_RenderTexture(renderer, texture, null, &dest_rect); - } - fn renderOverlay(self: *RecentFoldersOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, rect: geom.Rect, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) void { const cache = self.ensureCache(renderer, ui_scale, assets, theme) orelse return; diff --git a/src/ui/components/worktree_overlay.zig b/src/ui/components/worktree_overlay.zig index 513590e..b027281 100644 --- a/src/ui/components/worktree_overlay.zig +++ b/src/ui/components/worktree_overlay.zig @@ -8,6 +8,7 @@ const UiComponent = @import("../component.zig").UiComponent; const dpi = @import("../../dpi.zig"); const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; const ExpandingOverlay = @import("expanding_overlay.zig").ExpandingOverlay; +const GlyphBadge = @import("glyph_badge.zig").GlyphBadge; const button = @import("button.zig"); const flowing_line = @import("flowing_line.zig"); @@ -17,6 +18,7 @@ pub const WorktreeOverlayComponent = struct { allocator: std.mem.Allocator, overlay: ExpandingOverlay = ExpandingOverlay.init(2, button_margin, button_size_small, button_size_large, button_animation_duration_ms), first_frame: FirstFrameGuard = .{}, + badge: GlyphBadge = .{ .text = "⌘T" }, worktrees: std.ArrayList(Worktree) = .{}, last_cwd: ?[]const u8 = null, @@ -108,6 +110,7 @@ pub const WorktreeOverlayComponent = struct { fn deinit(self_ptr: *anyopaque, _: *c.SDL_Renderer) void { const self: *WorktreeOverlayComponent = @ptrCast(@alignCast(self_ptr)); + self.badge.deinit(); self.destroyCache(); self.clearWorktrees(); self.clearCreateInput(); @@ -372,41 +375,11 @@ pub const WorktreeOverlayComponent = struct { } switch (self.overlay.state) { - .Closed, .Collapsing, .Expanding => self.renderGlyph(renderer, rect, ui_host.ui_scale, assets, ui_host.theme), + .Closed, .Collapsing, .Expanding => self.badge.render(renderer, rect, ui_host.ui_scale, assets, ui_host.theme), .Open => self.renderOverlay(renderer, ui_host, rect, ui_host.ui_scale, assets, ui_host.theme), } } - fn renderGlyph(_: *WorktreeOverlayComponent, renderer: *c.SDL_Renderer, rect: geom.Rect, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) void { - const cache = assets.font_cache orelse return; - const font_size = dpi.scale(@max(12, @min(20, @divFloor(rect.h, 2))), ui_scale); - const fonts = cache.get(font_size) catch return; - - const glyph = "⌘T"; - const fg = theme.foreground; - const fg_color = c.SDL_Color{ .r = fg.r, .g = fg.g, .b = fg.b, .a = 255 }; - const surface = c.TTF_RenderText_Blended(fonts.regular, glyph.ptr, @intCast(glyph.len), fg_color) orelse return; - defer c.SDL_DestroySurface(surface); - - const texture = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return; - defer c.SDL_DestroyTexture(texture); - - var text_width_f: f32 = 0; - var text_height_f: f32 = 0; - _ = c.SDL_GetTextureSize(texture, &text_width_f, &text_height_f); - - const text_x = rect.x + @divFloor(rect.w - @as(c_int, @intFromFloat(text_width_f)), 2); - const text_y = rect.y + @divFloor(rect.h - @as(c_int, @intFromFloat(text_height_f)), 2); - - const dest_rect = c.SDL_FRect{ - .x = @floatFromInt(text_x), - .y = @floatFromInt(text_y), - .w = text_width_f, - .h = text_height_f, - }; - _ = c.SDL_RenderTexture(renderer, texture, null, &dest_rect); - } - fn renderOverlay(self: *WorktreeOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, rect: geom.Rect, ui_scale: f32, assets: *types.UiAssets, theme: *const colors.Theme) void { const cache = self.ensureCache(renderer, ui_scale, assets, theme) orelse return; From dc4c2f1c4f046a78383b67f60546c62b48a90dfb Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 20 Jul 2026 15:48:23 +0200 Subject: [PATCH 2/3] test(session): decouple settle re-arm test from the transition duration The test hardcoded timestamps that assumed a 500ms sweep; when the sweep duration changed to match the wait shimmer's 1400ms cycle, the first assertion started failing. Derive all timestamps from the settle constants like the neighbouring tests do. --- src/session/state.zig | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/session/state.zig b/src/session/state.zig index b34d187..1feb8e7 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -1360,23 +1360,27 @@ test "a repaint wave shortly after the sweep re-engages the hold at most twice" session.startResizeSettleHold(1000); session.noteResizeSettleOutput(1000, 64); - session.expireResizeSettleHold(1400); - try std.testing.expect(!session.resizeSettleTransitionActive(2500)); + session.expireResizeSettleHold(1000 + resize_settle_quiet_ms); + const sweep_done = 1000 + resize_settle_quiet_ms + resize_settle_transition_ms; + try std.testing.expect(!session.resizeSettleTransitionActive(sweep_done)); // First wave after the sweep finished but within the re-arm window. - session.noteResizeSettleOutput(2500, 4096); - try std.testing.expect(session.resizeSettleHoldActive(2500)); - session.expireResizeSettleHold(2900); - try std.testing.expect(session.resizeSettleTransitionActive(2900)); + const wave2 = sweep_done + 100; + session.noteResizeSettleOutput(wave2, 4096); + try std.testing.expect(session.resizeSettleHoldActive(wave2)); + session.expireResizeSettleHold(wave2 + resize_settle_quiet_ms); + try std.testing.expect(session.resizeSettleTransitionActive(wave2 + resize_settle_quiet_ms)); // Second wave: still re-engages. - session.noteResizeSettleOutput(4000, 4096); - try std.testing.expect(session.resizeSettleHoldActive(4000)); - session.expireResizeSettleHold(4400); + const wave3 = wave2 + resize_settle_quiet_ms + resize_settle_transition_ms + 100; + session.noteResizeSettleOutput(wave3, 4096); + try std.testing.expect(session.resizeSettleHoldActive(wave3)); + session.expireResizeSettleHold(wave3 + resize_settle_quiet_ms); // Third burst: the re-arm budget is spent; output renders live. - session.noteResizeSettleOutput(5000, 4096); - try std.testing.expect(!session.resizeSettleHoldActive(5000)); + const burst = wave3 + resize_settle_quiet_ms + 100; + session.noteResizeSettleOutput(burst, 4096); + try std.testing.expect(!session.resizeSettleHoldActive(burst)); } test "resize settle hold ignores dead sessions" { From b1a0bd61ddd4c5ca5efbb521ce478f5406908337 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 20 Jul 2026 16:04:11 +0200 Subject: [PATCH 3/3] fix(session): show the settle shimmer only once repaint output arrived Addresses the review note on PR #346: a session whose foreground process never reacts to the SIGWINCH held past the 500ms shimmer threshold into the 700ms response grace, flashing the shimmer for ~200ms on every resize. The shimmer signals a repaint in flight, so it is now gated on output having been seen since the resize; silent sessions hold and release without any flash. --- src/session/state.zig | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/session/state.zig b/src/session/state.zig index 1feb8e7..562b94d 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -659,8 +659,13 @@ pub const SessionState = struct { } /// True once the hold has lasted long enough to warrant the busy shimmer. + /// Gated on output having arrived since the resize: the shimmer means "a + /// repaint is in flight", and a session whose foreground process never + /// reacts to the SIGWINCH would otherwise flash the shimmer during the + /// response-grace tail of the hold. pub fn resizeSettleShimmerVisible(self: *const SessionState, current_time_ms: i64) bool { if (self.resize_settle_started_ms == 0) return false; + if (!self.resize_settle_output_seen) return false; return current_time_ms - self.resize_settle_started_ms >= resize_settle_shimmer_after_ms; } @@ -1303,7 +1308,7 @@ test "resize settle hold expiry clears state and marks the session dirty" { try std.testing.expect(!session.resizeSettleHoldActive(1400)); } -test "resize settle shimmer appears only after the grace period" { +test "resize settle shimmer appears only after the grace period and once output arrived" { var session: SessionState = undefined; session.spawned = true; session.dead = false; @@ -1313,6 +1318,12 @@ test "resize settle shimmer appears only after the grace period" { try std.testing.expect(!session.resizeSettleShimmerVisible(1000)); session.startResizeSettleHold(1000); try std.testing.expect(!session.resizeSettleShimmerVisible(1499)); + // No output since the resize: the session is idle, not repainting, so + // the shimmer stays hidden through the response-grace tail of the hold. + try std.testing.expect(!session.resizeSettleShimmerVisible(1500)); + + session.noteResizeSettleOutput(1200, 64); + try std.testing.expect(!session.resizeSettleShimmerVisible(1499)); try std.testing.expect(session.resizeSettleShimmerVisible(1500)); }