fix(render): content invalidation propagates again; clear hover on focus loss

closes #368
closes #367

#368 -- Frame(cache_subtree=True) silently froze its subtree's CONTENT.

markContentDirty() gated its walk up the parent chain on
(!was_dirty || !p->render_dirty). That is sound only if render_dirty is reliably
cleared once a drawable has been drawn. It is not: clearDirty() had exactly one
call site in the whole engine (UIGridView, added by #364 on this branch). UIFrame's
non-render-texture path, UICaption, UISprite, UILine, UICircle and UIArc never
cleared it, so render_dirty was a write-once latch -- true from an object's first
mutation until it died.

With the flag stuck true, was_dirty was always true AND the parent's render_dirty
was always true, so the guard was always false and content invalidation propagated
nowhere. A caching ancestor went on re-blitting a stale composite: text never
updated, colours never changed, sprite indices never changed.

Only movement survived, because x/y route through markCompositeDirty, whose walk
was already unconditional. That asymmetry is why the bug read as intermittent
rather than total, and it is why my original issue writeup got the shape wrong --
its repro's third step was a move, so it looked like the latch cleared. It does
not. Measured on master: 4/4 recolours and 3/3 caption edits under a cache were
ALL dropped, including the first.

The fix makes markContentDirty's walk unconditional, matching markCompositeDirty.
The alternative -- repair the guard by having every drawable clear the flag
honestly -- buys a shorter walk at the price of an invariant that silently breaks
the day someone adds a drawable and forgets. Not worth it here: parent chains are
shallow, and this is the exact cost markCompositeDirty has always paid on every
position change without anyone noticing.

A/B on the changed path (200k content mutations, best of 3):

    depth        1      4      8     16
    before     82ns   86ns   82ns   87ns    <- flat because it did nothing
    after      76ns  114ns  175ns  295ns

The old build's flatness IS the defect. Entity movement -- the actual hot path --
is unchanged (164ns/move either way), since it was already on the unconditional
composite path. Crucible shows no regression (step_swarm 12.9 -> 10.7ms,
entity_churn 36.3 -> 31.5ms, within noise).

Regression test asserts every consecutive content mutation repaints through a
cache at depths 1 and 5, AND that idle frames stay byte-identical -- without that
last assertion, "mark everything dirty always" would pass and the cache would be
worthless.

#367 -- Losing window focus stranded hover exactly as a window-leave did (#363).

An unfocused window is delivered no MouseMoved, so the hover walk never runs and
whatever was hovered when focus left stays lit while the game sits in the
background: on_exit/on_cell_exit never fire and hovered_cell keeps naming a cell
the user is no longer pointing at. sf::Event::LostFocus was already produced by
every backend -- SDL2Renderer even translates SDL_WINDOWEVENT_FOCUS_LOST into it --
and, like MouseLeft before #363, had nobody listening.

Routed to the same PyScene::do_mouse_leave() that #363 added. Once unfocused the
engine genuinely does not know where the pointer is, so "hovering nothing" is the
only truthful state to hold; the next MouseMoved after focus returns restores it.

automation.loseFocus() injects the event, via a new injectWindowEvent() rather than
injectMouseEvent -- a focus event has no coordinates and no button, and should not
borrow a signature that implies it does. api_surface golden re-baselined: +1 line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-13 07:40:20 -04:00
commit 9d67e33370
8 changed files with 379 additions and 5 deletions

File diff suppressed because one or more lines are too long