Repository navigation
fix: stale UI frames, empty diff text, and offset mouse selection - #415
Conversation
Issue: After Cmd+Enter the hotkey badge's blue circle faded out but the "⌘↵" text stayed on screen until the mouse moved. Other components had the same class of bug: an expired toast, and expand/collapse overlays that finished animating or closed immediately, left their last drawn frame on screen. Solution: The frame loop has no periodic repaint, so a component that stops requesting frames must request one final frame when it leaves a visible state. Expiry and animation completion now flip state in update() and arm the first-frame guard; ExpandingOverlay.settle() reports each finished animation exactly once so every overlay handles it the same way. Glyph text also applies the color's alpha so fading callers actually fade their text.
Issue: The diff overlay (Cmd+D) sometimes opened with its green/red line backgrounds but no text. Solution: FontCache.get returned a pointer into its hash map, and any later lookup of a new size could grow the map and relocate the entry, leaving callers holding a dangling FontSet pointer whose fonts then fail to render. get now returns the FontSet by value; the fonts it holds stay valid until the cache is reset, so copies are safe and callers take the value.
Issue: The diff overlay rendered a texture for every line of the diff when it opened, and again after every comment edit, even though only one screen of rows is ever visible at a time. Solution: The cache now holds one optional slot per display row and builds a row's textures the first time that row is drawn. Opening a large diff and invalidating the cache after comment changes now cost one screen of rows instead of the whole diff. A row that fails to build is recorded as empty and logged once rather than retried every frame.
Issue: At some point the mouse started selecting text about two lines below the pointer, in every session, until Architect restarted; double-clicks hit the wrong word too, while leaving and re-entering fullscreen fixed it. Solution: On macOS 26+, SDL derives mouse motion positions (and the button and wheel positions built from them) from its cached window position. That cache is refreshed only when the window moves, using the main display height at that moment; after a display reconfiguration it can be left off by the difference between the displays' heights. A clearly marked workaround measures the gap between SDL's cached position and the NSWindow's real one and shifts mouse events by it before anything reads them. A test pins the SDL version it was checked against so the next SDL upgrade forces a review. SDL is also bumped to the latest release, 3.4.18, which still has the bug.
Issue: The Linux CI test binary failed to start with "error while loading shared libraries: libSDL3_ttf.so.0" once a test that runs on Linux started calling SDL3_ttf. Solution: sdl3.pc passes an rpath for SDL3, but sdl3-ttf.pc passes none, so an ELF binary linked against SDL3_ttf from the Nix store could not find it at runtime. The app had the same latent problem on Linux; tests only exposed it now. build.zig adds the SDL3_ttf library directory as an rpath on non-macOS targets; Mach-O binaries record absolute install names instead.
There was a problem hiding this comment.
🟡 Changes recommended
The mouse correction shifts already-correct events and cannot recover motion that SDL clamps or discards before queuing.
2 open issues
What changed in this PR
This PR addresses stale UI frames, missing diff text, large-diff rendering costs, and mouse-selection offsets in Architect.
Changes:
- Requests a final repaint when overlays and indicators finish closing, and applies glyph alpha for fading text.
- Returns font sets by value and builds diff-row textures only when drawn.
- Adds a macOS mouse-coordinate workaround, upgrades SDL to 3.4.18, and updates regression tests and documentation.
Builds and tests were not run in this review environment.
| File | Description |
|---|---|
src/ui/components/worktree_overlay.zig |
Requests final frames after closing; adds tests. |
src/ui/components/toast.zig |
Repaints expired toasts; adds a regression test. |
src/ui/components/story_overlay.zig |
Accepts font sets by value. |
src/ui/components/recent_folders_overlay.zig |
Updates closing transitions and font handling. |
src/ui/components/reader_overlay.zig |
Accepts font sets by value. |
src/ui/components/pr_dropdown.zig |
Repaints completed closing transitions; adds tests. |
src/ui/components/pr_dropdown_view.zig |
Accepts font sets by value. |
src/ui/components/hotkey_indicator.zig |
Requests a repaint on expiry; adds a test. |
src/ui/components/help_overlay.zig |
Repaints settled animations; adds a test. |
src/ui/components/fullscreen_overlay.zig |
Requests final animation frames; adds tests. |
src/ui/components/expanding_overlay.zig |
Centralizes animation completion with settle(). |
src/ui/components/diff_overlay.zig |
Lazily creates row textures; adds coverage. |
src/platform/sdl_mouse_workaround.zig |
Adds macOS coordinate correction and verification tests. |
src/main.zig |
Registers additional test modules. |
src/font.zig |
Applies foreground alpha to glyph textures. |
src/font_cache.zig |
Returns stable font-set copies; adds a regression test. |
src/c.zig |
Exports SDL APIs needed by the workaround and tests. |
src/app/runtime.zig |
Corrects mouse events before render-coordinate scaling. |
overlays/sdl3-3-4-18.nix |
Pins SDL 3.4.18 and its source hash. |
flake.nix |
Uses the renamed SDL overlay. |
docs/development.md |
Documents the SDL upgrade and workaround checks. |
docs/ARCHITECTURE.md |
Documents the workaround and font-cache API. |
CLAUDE.md |
Records repaint, font lifetime, and mouse-routing guidance. |
.air/cloud/startup.sh |
Reads SDL’s version from the renamed overlay. |
Review effort: Balanced
| log.info("SDL window position is off by ({d}, {d}) points; correcting mouse coordinates", .{ offset.x, offset.y }); | ||
| self.logged_offset = offset; | ||
| } | ||
| applyOffset(event, offset); |
There was a problem hiding this comment.
Confirmed against SDL 3.4.18: windowDidBecomeKey: and Cocoa_SendMouseButtonClicks build positions from window-local coordinates, so the per-event shift moved correct input. Fixed in 7f3f71b by repairing the cause instead: when SDL's cached window position differs from the NSWindow's real one, the workaround posts NSWindowDidMoveNotification so SDL's own windowDidMove: recomputes it. Event coordinates are no longer touched, so the window-local paths stay as SDL produced them. A new test moves the NSWindow while SDL's delegate is detached, checks that SDL's position is 37 pt stale, and checks that the repair brings it back in sync.
| log.info("SDL window position is off by ({d}, {d}) points; correcting mouse coordinates", .{ offset.x, offset.y }); | ||
| self.logged_offset = offset; | ||
| } | ||
| applyOffset(event, offset); |
There was a problem hiding this comment.
Confirmed: SDL_UpdateMouseFocus and ConstrainMousePosition run against the stale origin before the event is queued, so a later correction cannot recover clamped or dropped motion. Fixed in 7f3f71b: the workaround now re-syncs SDL's cached window position (via SDL's own windowDidMove:) before SDL pumps new input. It checks once per main-loop pass and again right after display and window-moved events, so the focus and clamping checks run against the real origin. The only input still affected is what SDL already queued in the pass where the displays reconfigured.
…vents Issue: Review on #415 pointed out that correcting mouse coordinates after SDL queues them is wrong in two ways: SDL already delivers correct window-local positions for focus-gain motion and background clicks, which the correction then shifted; and SDL drops or clamps motion against the stale origin before queuing, which no later correction can recover. Solution: The workaround now repairs the cause. When SDL's cached window position differs from the NSWindow's real one, it posts NSWindowDidMoveNotification so SDL's own windowDidMove: recomputes the position with the current main display height. The check runs before each event pump and after display and window-moved events, so the input SDL generates afterwards starts from a correct origin on every code path.

TLDR
This PR fixes four problems in the Architect window.
Issue
Solution
One commit per problem, plus a build fix:
fix(ui): components that leave a visible state (indicator or toast expiry, an expand/collapse settling, an immediate close) now flip state inupdate()and arm their first-frame guard, so the loop renders one final frame.ExpandingOverlay.settle()reports each finished animation exactly once so every overlay handles completion the same way. Glyph textures now apply the color's alpha, so fading text actually fades.fix(font):FontCache.getreturned a pointer into itsstd.HashMap; looking up a new size could grow the map and relocate the entry, leaving the diff overlay holding a danglingFontSet.getnow returns theFontSetby value. The fonts it holds stay valid until the cache is reset.perf(diff): the diff overlay cache holds one optional slot per display row and builds a row's textures the first time the row is drawn.fix(input): on macOS 26+, SDL computes mouse motion positions as the global mouse position minus its cached window position, and builds button and wheel positions from the last motion. The cached position is only refreshed when the window moves, using the main display height at that moment, so after a display reconfiguration it can be off by the difference between the displays' heights (37 pt here, about two terminal rows).src/platform/sdl_mouse_workaround.zig, clearly marked as a temporary workaround, compares SDL's cached position with the NSWindow's real one and, when they differ, postsNSWindowDidMoveNotificationso SDL's ownwindowDidMove:recomputes it. It checks once per main-loop pass before SDL pumps new input, and again after display and window-moved events. Repairing the cache instead of shifting event coordinates keeps SDL's window-local paths (focus-gain motion, background clicks) and its focus and clamping checks correct. SDL is bumped from 3.4.10 to 3.4.18, the latest release; its Cocoa code still has the bug.fix(build): the newFontCachetest is the first Linux-compiled test that calls SDL3_ttf, and the Linux test binary then failed to start (libSDL3_ttf.so.0: cannot open shared object file).sdl3.pcpasses an rpath for SDL3 butsdl3-ttf.pcpasses none, sobuild.zignow adds the SDL3_ttf library directory as an rpath on non-macOS targets. The Linux app binary had the same latent problem.Context
mouseMoved:(src/video/cocoa/SDL_cocoawindow.m) andCocoa_DisplayReconfigurationCallback(src/video/cocoa/SDL_cocoamodes.m) in SDL 3.4.18. The workaround'sverified_sdl_versiontest fails on the next SDL bump on purpose, so the workaround gets re-checked and removed once SDL fixes this upstream.CLAUDE.mdgains rules for arming the first-frame guard on exit transitions, a pitfall note aboutstd.HashMappointers, and a note on the mouse workaround.overlays/sdl3-3-4-18.nix; the first CI run may build SDL from source before it is pushed to Cachix.Test plan
objc_msgSend_stretthere, and CI only runs tests on Apple Silicon.architect.logshows "SDL window position was off by (…) points; re-synced it". Also move the pointer into the bottom ~40 pt of the window right after the wake: hover and selection should work there too.