Skip to content

fix: stale UI frames, empty diff text, and offset mouse selection - #415

Merged
forketyfork merged 6 commits into
mainfrom
fix/stale-frames-fonts-and-mouse
Oct 26, 2026
Merged

forketyfork merged 6 commits into
mainfrom
fix/stale-frames-fonts-and-mouse

Conversation

@forketyfork

@forketyfork forketyfork commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

TLDR

This PR fixes four problems in the Architect window.

  1. Some hints and panels stayed on the screen after they closed. Now the app draws one more frame when a hint or panel closes.
  2. The diff view sometimes opened with no text. The font cache gave out a pointer that became invalid. Now the cache gives out a copy.
  3. The diff view made images for all lines when it opened. Now it makes an image for a line only when the line shows on the screen.
  4. On macOS 26 and later, the mouse selected text about two lines below the pointer after the displays changed (for example, after sleep). This is a bug in SDL, the library that Architect uses for windows. This PR adds a temporary workaround: when SDL has the wrong window position, the app makes SDL calculate the position again. It also updates SDL to the latest release.

Issue

  • After Cmd+Enter, the blue circle of the hotkey hint faded out, but the "⌘↵" text stayed until the mouse moved. Expired toasts and expand/collapse overlays that finished animating or closed immediately had the same problem: the frame loop has no periodic repaint, so their last drawn frame stayed on screen.
  • The diff overlay (Cmd+D) sometimes showed its line backgrounds without any text.
  • Opening a large diff, and every comment edit, rebuilt textures for every line of the diff.
  • At some point the mouse started selecting text about two rows below the pointer in every session. Double-clicks hit the wrong word too. Leaving and re-entering fullscreen fixed it; otherwise it lasted until restart.

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 in update() 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.get returned a pointer into its std.HashMap; looking up a new size could grow the map and relocate the entry, leaving the diff overlay holding a dangling FontSet. get now returns the FontSet by 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, posts NSWindowDidMoveNotification so SDL's own windowDidMove: 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 new FontCache test 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.pc passes an rpath for SDL3 but sdl3-ttf.pc passes none, so build.zig now adds the SDL3_ttf library directory as an rpath on non-macOS targets. The Linux app binary had the same latent problem.

Context

  • The SDL behavior is in mouseMoved: (src/video/cocoa/SDL_cocoawindow.m) and Cocoa_DisplayReconfigurationCallback (src/video/cocoa/SDL_cocoamodes.m) in SDL 3.4.18. The workaround's verified_sdl_version test fails on the next SDL bump on purpose, so the workaround gets re-checked and removed once SDL fixes this upstream.
  • The stale state was confirmed on the affected machine: the window sits at y = -1080, while the last position SDL reported (and Architect persisted) was y = -1117.
  • CLAUDE.md gains rules for arming the first-frame guard on exit transitions, a pitfall note about std.HashMap pointers, and a note on the mouse workaround.
  • The Nix SDL overlay is renamed to overlays/sdl3-3-4-18.nix; the first CI run may build SDL from source before it is pushed to Cachix.

Test plan

  • On an Intel Mac build, check that mouse selection lands under the pointer. The workaround reads the window frame through objc_msgSend_stret there, and CI only runs tests on Apple Silicon.
  • On macOS 26+ with two displays of different heights, sleep and wake the machine (or reconnect the external display), then drag-select and double-click text: the selection should start under the pointer. If the stale state occurred, architect.log shows "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.

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.
@forketyfork
forketyfork marked this pull request as ready for review October 26, 2026 12:53
@forketyfork
forketyfork requested a balanced review from Copilot October 26, 2026 12:53
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/platform/sdl_mouse_workaround.zig Outdated
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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/platform/sdl_mouse_workaround.zig Outdated
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);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@forketyfork
forketyfork merged commit 95dc156 into main Oct 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants