Skip to content

Animate every inspector disclosure, and stop the scroll restore from fighting the reader - #148

Merged
luca-chen198 merged 9 commits into
mainfrom
feat/animate-expanded-header-resize
Aug 11, 2026
Merged

Animate every inspector disclosure, and stop the scroll restore from fighting the reader#148
luca-chen198 merged 9 commits into
mainfrom
feat/animate-expanded-header-resize

Conversation

@luca-chen198

Copy link
Copy Markdown
Member

The inspector's header band only animated when its disclosure crossed between "nothing open" and "something open". Opening a second section while one was already open jumped. This animates every disclosure, then fixes the three things that turned up while making that hold.

Header band

  • Animate on every disclosure, not just the crossing one.
  • Retarget mid-animation. A rapid open/close left the band stuck at the open height: the in-flight animation's target was compared against the settled constant, so the second change was dropped. It now retargets against whatever is actually pending.
  • Adopt a switched document's height without revealing it. Routing to another note animated the band from the old note's height; the switch now assigns the constant directly.
  • Tear the host observer down with the clip, and before the views, so a dismantled controller can't act on a half-removed hierarchy.

Scroll restore

  • Apply the restore from layout(), not from a SwiftUI state tick. A zero-height clip view accepts any offset verbatim, so the old code "succeeded" against no geometry and the first real layout dropped it (measured: saved=201 actual=201 landed=true viewportH=0).
  • Cancel the queued restore when the reader moves the viewport. Only scrollWheel cleared it, but find-match reveal, keyboard paging and drag-select autoscroll all scroll the clip view directly — so the next layout pass replayed the remembered offset and threw the reader back.
  • Give the latch a deadline. A pass budget only ticks when a pass happens, so an offset that never landed stayed armed indefinitely and any later relayout could replay it.

Find

  • Windowless editors stay out of the search. The query is broadcast to every live coordinator (object: nil), and an editor the host has routed away from can outlive its view. It answers for a document nobody is looking at — with an empty buffer, so it reports zero matches and the host resets its match index. Measured in the app: 24 coordinators replying to one ⌘F, 22 of them windowless, leaving next/previous bouncing between the first two matches forever.

Tests

323 pass. Two find tests hosted their text view in no window at all and went red on the window guard — correct, a document being searched is on screen by definition; the fixture now uses a real NSWindow and a new test covers the windowless case. The suite also caught a >= 0 boundary I got wrong on the retarget guard.

Note the animated band itself is not covered: the tests have no NSWindow driving NSAnimationContext, so the animated path can only be verified by hand.

luca-chen198 and others added 9 commits August 10, 2026 23:12
The scrolling header's band was owned by the equality constraint while
expanded (clip.height == host.height), so only a collapsed↔expanded edge
went through applyExpansion and animated. Content disclosed while already
expanded — an embedder opening a second inspector section — resized the
band in a single layout pass: the first disclosure revealed over 0.32s and
every later one snapped.

The band now stays on the animatable constant constraint for the whole
expanded state, and a new observer on the hosted view's frame animates it
to the content's new height on the crossing's own curve. Taking the change
from the HOST rather than the clip is what makes it a reveal: the clip has
not moved yet when we are called, so the animation starts where the reader
last saw the band. Reacting to the clip instead was measurably too late —
it read 782 while the band still showed 913, i.e. the mask stood on the end
height for a frame before jumping back.

Both directions animate. On a shrink the embedder's content is already gone
and the mask closes over blank space, but that is exactly what a crossing
close has always done; animating only growth reads as a fault.

A live resize and an offscreen container track directly instead of
animating — neither is a disclosure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A disclosure while the band was already animating was dropped, not applied:
the observer bailed out whenever an animation was in flight. Toggling a
section twice in quick succession therefore finished on the height of the
OPEN state while the content was already closed, leaving the band standing
in the opened layout.

Nothing corrected it afterwards. The band is held by the constant constraint
for the whole expanded state now, so the equality constraint that used to
re-sync it on the next layout pass is no longer there — a dropped change is
permanent. Every path that declines a height change is a lasting wrong state.

It retargets instead, which `animate(to:)` already supports (the token
invalidates the stale completion). Mid-flight the constant is a moving value,
so the comparison is against the target it is heading for.

The direct-set path (live resize, offscreen) went through the animator at
zero duration for the same reason: a plain property set leaves an in-flight
animation ticking toward its stale target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
remove(from:) removed the clip's frame observer but not the host's, which
was only released in deinit — so a controller that outlives its header kept
observing a view it no longer owns.

Worse than the leak was the order. removeFromSuperview() lays out, and the
host observer is delivered synchronously (queue: nil). Reached while
lastExpanded, constantConstraint and hostingView were still set, it passed
every guard in hostHeightChanged and started an animation — advancing
animationToken right after this method had equalised it with settledToken.
The controller then believed an animation was in flight for the rest of its
life, which silently changes how the next disclosure is retargeted.

Observers first, then the state they guard on, then the views.

Reachable from Nodes: expanding the inspector and opening the Flow overlay
drops the header (showsInspector: overlayState != .flow) and calls remove().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switching documents changes the hosted header's height exactly like a
disclosure does — the controller sees "the content is a different height"
and nothing else — so the band animated, and because every band change
restacks the body, the whole document slid on every switch. Two notes whose
inspectors differ by 6pt were enough to see it.

The embedder now says which is which: reconcileHeader compares the
coordinator's documentId before updateNSView advances it, and arms
snapNextHeightChange().

Applying it turned out to need three separate things, each of which was
measured, not assumed:

1. Do not animate. A deadline rather than a one-shot flag: if the new
   document happens to have the same header height no change ever arrives,
   and a flag would stay armed and swallow the next real disclosure.

2. Assign the constant DIRECTLY. Even at zero duration the animator defers
   the model value to a later transaction — measured 57ms, which reads as
   the text sitting still and then jumping. The token is advanced instead so
   a completion from an in-flight animation cannot settle on top.

3. Resolve the layout in the reconcile pass. Setting the constant is not
   enough: `headerHeight`, and therefore the body's origin, is read from the
   CLIP, which trailed the constant by 42ms. The band has to be right before
   the new document paints, which is what the equality constraint used to do
   for free by resizing the clip in the same pass as the host.

Measured after: the band settles 3ms after the switch is detected and before
NODE_SWITCH_LOADED, where it previously moved 42ms after the new text was
already laid out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Returning to a note after the embedder routed away — Nodes switching to an
image or a PDF and back — reopened it at the top even though the offset was
saved and read back correctly.

The restore ran in updateNSView, where the clip view can still be zero-height.
With no range to clamp against the scroll is accepted verbatim, so the
"landed" check was true for every value: it proved the offset had been SET,
not that it had survived. The latch retired on that, the first real layout
dropped the reader back to the top, and nothing was left to correct it.
Measured: saved=201 actual=201 landed=true viewportH=0.

Requiring a measured viewport there only moved the failure: every update pass
had viewportH=0, the attempts drained against nothing and the latch stayed
armed forever — which also made teardown refuse to save, since a pending
restore means the current offset is the load position.

So the offset is handed to the scroll view instead and applied from its own
`layout()`, the first moment viewport and document heights exist. It retries
while the document keeps growing (measured 427 → 889 → 1327 across passes on
one remount) and stops as soon as it lands. A scroll-wheel event drops it: the
reader taking over must never be yanked back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The restore latch was only cleared by `scrollWheel`, but find-match reveal,
keyboard paging and drag-select autoscroll all scroll the clip view directly
and never pass through it — so the next layout pass replayed the remembered
offset and threw the reader back.

Also give the latch a deadline: a pass budget only ticks when a pass happens,
so an offset that never landed stayed armed indefinitely and any later
relayout could replay it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prints which coordinator answers a `findQuery` broadcast, the requested vs.
clamped match index, and whether the reveal actually scrolled. Diagnosing
next/previous not working while matches exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The find query is broadcast to every live coordinator (`object: nil`), and an
editor the host has routed away from can outlive its view. It answers for a
document nobody is looking at — with an empty buffer, so it reports zero
matches, and the host resets its match index on a zero count.

Measured in the app: 24 coordinators replying to one Cmd+F, 22 of them
windowless, leaving next/previous bouncing between the first two matches
forever.

The two find tests hosted their text view in no window at all, so they went
red on the guard — correct: a document being searched is on screen by
definition. The fixture now puts the view in a real NSWindow, and a new test
covers the case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@luca-chen198
luca-chen198 merged commit 6b011d4 into main Aug 11, 2026
1 check 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.

1 participant