Skip to content

Observe an animated reveal when deciding the viewport moved - #207

Open
lamdor wants to merge 2 commits into
apphane-dev:mainfrom
lamdor:la/drive-animated-reveal-scroll
Open

Observe an animated reveal when deciding the viewport moved#207
lamdor wants to merge 2 commits into
apphane-dev:mainfrom
lamdor:la/drive-animated-reveal-scroll

Conversation

@lamdor

@lamdor lamdor commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Symptom

A window moved to another workspace could stay parked off the destination viewport while still holding keyboard focus — so the user saw a different window than the one they moved, and keystrokes went somewhere invisible.

Mechanism

ensureSelectionVisible rebases the viewport offset so retargeting activeColumnIndex does not shift what is on screen, then calls scrollToReveal to bring the newly selected column into view. The rebase is view-neutral by design, so that reveal is the only step that can move the viewport onto the moved window's column.

How it moves depends on the motion snapshot:

Motion animateToOffset behavior
disabled writes a static offset — the current offset changes immediately
enabled installs a spring — the target moves, the current offset stays put for the display-link driver to interpolate

MotionPolicy.snapshot() returns .enabled unconditionally, so the runtime move path always takes the spring branch.

The plan-build step decided whether the viewport had moved by comparing only the current offset against its pre-reveal value:

if abs(state.viewOffsetPixels.current() - offsetBefore) > 1 {
    viewportNeedsRecalc = true
}

For an animated reveal that comparison is false. So viewportNeedsRecalc stayed unset, the .startNiriScroll directive gated on it was never emitted, startScrollAnimation was never called, nothing registered in scrollAnimationByDisplay, and nothing ever drove the spring — leaving the target pointing at the revealed column while the viewport sat where it started. The layout pass then correctly parked the window offscreen and wrote no frame for it, while the focus handoff still granted it focus.

Change

Compare both the current and target offsets against their pre-reveal values, through one shared helper used by both gate sites so they cannot drift apart. A static reveal is still observed through the current-offset delta; an animated one is now observed through the target delta.

Evidence

Both motion configurations are pinned by tests on the geometry from the reproduction (four 1150pt columns, 20pt gap, 2560pt viewport, moved window landing in the last column):

  • revealWithMotionEnabledSchedulesAnimationWithoutMovingCurrentOffset — the spring path moves the target, leaves the current offset, and reports isAnimating
  • revealWithMotionDisabledLandsStaticallyOnTheSnap — the static path lands both on the snap
  • animatedRevealIsObservableFromTheOffsetDeltas / staticRevealIsObservableFromTheOffsetDeltas — the combined check observes both

Six earlier candidate mechanisms for this symptom were investigated and eliminated; they are recorded in the discovery document in #206.

Status

Candidate fix, not confirmed. The mismatch it removes is measured by tests, but the runtime defect is timing-dependent — it does not reproduce under runtime trace capture — so it has not been confirmed fixed in a real reproduction. Draft until it has been.

Note this supersedes #203 as the candidate fix for this symptom. Instrumentation showed #203's z-order raise never executes in this scenario; that PR closes a real but separate gap.

Greptile Summary

The PR broadens viewport-movement detection to observe both current and animated target offsets, allowing animated reveals to schedule the scroll driver after moving a window between workspaces.

  • Adds a shared current/target offset-delta helper at both viewport recalculation gates.
  • Adds focused geometry and motion-policy tests for static and animated reveals.
  • Adds release-note and provenance metadata for the fix.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete changed-code failure established.

The shared predicate preserves static reveal detection while adding the target-offset delta required to observe spring-based reveals, and no accepted blocking or non-blocking defect remains.

Important Files Changed

Filename Overview
Sources/Nehir/Core/Controller/NiriLayoutHandler.swift Captures the pre-reveal target offset and uses a shared helper to detect either immediate current-offset movement or an animated target change at both recalculation gates.
Tests/NehirTests/MovedWindowRevealAfterWorkspaceTransferTests.swift Adds focused tests covering the transfer geometry, view-neutral rebasing, parked-column reveal, scroll locking, and static versus animated offset behavior.
.changeset/20260820100000-drive-the-viewport-scroll-that-reveals-a-window-mo.md Documents the user-visible offscreen focused-window defect addressed by the patch.
.provenance.json Registers the newly added test file in the repository provenance manifest.

Sequence Diagram

sequenceDiagram
    participant Move as Window transfer
    participant Reveal as ensureSelectionVisible
    participant Offset as View offset
    participant Plan as Layout plan
    participant Driver as Display-link driver
    Move->>Reveal: Select moved window's column
    Reveal->>Offset: Rebase current and target
    Reveal->>Offset: Animate target toward visible snap
    Offset-->>Plan: Current unchanged, target changed
    Plan->>Plan: viewportMoved() observes target delta
    Plan->>Driver: Emit startNiriScroll
    Driver->>Offset: Interpolate spring to target
Loading

Reviews (1): Last reviewed commit: "Observe an animated reveal when deciding..." | Re-trigger Greptile

lamdor added 2 commits August 20, 2026 08:25
…workspace

A window moved to an adjacent workspace can land selected in a column outside the destination viewport: parked offscreen, given no frame write, yet holding keyboard focus. These tests record the viewport state observed in that reproduction and the reveal behavior around it.

The fixture is the destination geometry from the reproduction: four 1150pt columns with a 20pt gap at x = 0, 1170, 2340, 3510, seen through a 2560pt viewport, with the moved window landing in the last column. In that state the rebase step of ensureSelectionVisible leaves viewOffsetPixels at -3530 with viewStart at -20, so column 3 starts roughly 970pt beyond the right viewport edge.

Five tests assert the reveal contract under disabled motion: the rebase is view-neutral, the moved column is parked, a parked destination column is revealed, a scroll is scheduled, and scroll lock does not exempt a fully parked target.

Three further tests separate the two motion configurations, because they behave differently in a way that matters for the surrounding code. With motion disabled, animateToOffset takes its static fallback and both current and target land on the snap. With motion enabled — the value MotionPolicy.snapshot() returns, so the one the runtime move path uses — it installs a spring: the target moves while the current offset stays put for the display-link driver to interpolate. The plan-build step in NiriLayoutHandler tests abs(viewOffsetPixels.current() - offsetBefore) > 1 to decide whether to emit a .startNiriScroll directive, so it observes the former but not the latter.

These are diagnostic rather than a regression guard for a landed fix. The runtime defect is timing-dependent and does not reproduce under trace capture.
A window moved to another workspace could stay parked off the destination viewport while still holding keyboard focus, so the user saw a different window than the one they moved.

ensureSelectionVisible rebases the viewport offset so retargeting activeColumnIndex does not shift what is on screen, then calls scrollToReveal to bring the newly selected column into view. The rebase is view-neutral by design, so that reveal is the only step that can move the viewport onto the moved window's column.

How it moves depends on the motion snapshot. With animations disabled animateToOffset writes a static offset and the current offset changes immediately. With animations enabled — the value MotionPolicy.snapshot() returns, so the one this path uses — it installs a spring: the target moves and the current offset stays put for the display-link driver to interpolate.

The plan-build step decided whether the viewport had moved by comparing only the current offset against its pre-reveal value. For an animated reveal that comparison is false, so viewportNeedsRecalc stayed unset and the .startNiriScroll directive gated on it was never emitted. Nothing called startScrollAnimation, nothing registered in scrollAnimationByDisplay, and nothing ever drove the spring — leaving the target pointing at the revealed column while the viewport sat where it started.

Compare both the current and target offsets against their pre-reveal values, via one shared helper used by both gate sites so they cannot drift apart. A static reveal is still observed through the current-offset delta; an animated one is now observed through the target delta.

This is a candidate fix: the mismatch it removes is measured by tests, but the runtime defect is timing-dependent and has not yet been confirmed fixed in a real reproduction.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@lamdor, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d540e85c-6e36-4b78-8265-5da2b9ce58c7

📥 Commits

Reviewing files that changed from the base of the PR and between f097f35 and 7acf5de.

📒 Files selected for processing (4)
  • .changeset/20260820100000-drive-the-viewport-scroll-that-reveals-a-window-mo.md
  • .provenance.json
  • Sources/Nehir/Core/Controller/NiriLayoutHandler.swift
  • Tests/NehirTests/MovedWindowRevealAfterWorkspaceTransferTests.swift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lamdor
lamdor marked this pull request as ready for review August 20, 2026 16:18
@lamdor

lamdor commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Context after the investigation that produced this PR concluded.

This does not fix the moved-window visibility bug it was written for. That bug is fixed by #209: the AX focus-confirmation path was skipping the reveal entirely on a token-only re-confirmation test. Since the reveal never ran, there was no scheduled scroll for the gate fixed here to miss — this change is downstream of the actual cause.

The mismatch it removes is real and measured. animateToOffset moves the animation target and leaves the current offset for the display-link driver to interpolate, while the plan-build gate compared only the current offset. Tests on the branch pin both motion configurations:

  • revealWithMotionEnabledSchedulesAnimationWithoutMovingCurrentOffset
  • revealWithMotionDisabledLandsStaticallyOnTheSnap
  • animatedRevealIsObservableFromTheOffsetDeltas / staticRevealIsObservableFromTheOffsetDeltas

But I have no runtime evidence of the gate's blindness costing anything in practice — every observed instance reached .startNiriScroll through another condition.

Marking ready so the reasoning is reviewable. Fine to close if the maintainers would rather not carry a change whose effect has never been observed.

@lamdor

lamdor commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Note on the commit list: this PR shows two commits because it builds on #204 — it modifies the test file #204 adds (MovedWindowRevealAfterWorkspaceTransferTests.swift), so it cannot be rebased onto main standalone. GitHub cannot retarget the base to a fork-only branch, so the dependency shows up as an extra commit in the list rather than as a base.

The commit belonging to this PR is "Observe an animated reveal when deciding the viewport moved"; the diagnostic-tests commit belongs to #204.

If #204 is closed rather than merged, this PR needs rebasing to carry the test-file changes itself.

@lamdor

lamdor commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Merge order: #204 before this PR.

This PR modifies the test file #204 adds (Tests/NehirTests/MovedWindowRevealAfterWorkspaceTransferTests.swift), so merging this one first would fail — the file would not exist on main yet.

If #204 is closed rather than merged, this PR needs rebasing to carry those test-file changes itself before it can merge.

The other PRs from this investigation are independent and can merge in any order: #209 (the actual fix), #202, #203, #206.

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