Skip to content

Mac: repaint the rows where screen updates actually land while scrolled back - #603

Open
bones7456 wants to merge 2 commits into
migueldeicaza:mainfrom
bones7456:fix/scrollback-dirty-region
Open

Mac: repaint the rows where screen updates actually land while scrolled back#603
bones7456 wants to merge 2 commits into
migueldeicaza:mainfrom
bones7456:fix/scrollback-dirty-region

Conversation

@bones7456

@bones7456 bones7456 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Rebased on top of #620, which fixed the CoreGraphics half of this while the PR was open by invalidating the whole view when yDisp != yBase.

What this changes:

  • CG: invalidate the rows the change actually lands on instead of the whole view. Rows below the viewport invalidate nothing; a full-screen refresh still covers everything visible.
  • Metal: the dirty range used yDisp + row where the absolute row is yBase + row. Now derived from the same view rows as the CG path, so the full-screen rule matches too — it was marking 31...43 dirty for a visible 29...41.
  • Bidi (new since I opened this): renderingDependencyRange is asked about yDisp + rowStart, the same conflation a third time. Now yBase-relative, unioned into the view rows so both paths inherit it.
  • metalDirtyRange was replaced, not merged, so a link-highlight row queued before the renderer drew was dropped. Highlights don't bump the line generation, so the renderer's cache check can't recover it.
  • invalidateLinkHighlightRow no longer routes through updateRange, which can't express a scrollback row at all.

8 tests, 4 per path. The Metal ones attach a bare MTKView(device: nil) instead of calling setUseMetal(true) — the range bookkeeping never draws, so no GPU is needed and they actually run on CI. Gating on a real device makes them silently skip under swift test: the shader bundle sits next to the .xctest bundle, which candidateBundles() doesn't probe, so setUseMetal(true) throws shaderSourceMissing.

#620's test passes unchanged. Full suite (690 tests) passes, iOS builds.

bones7456 added a commit to bones7456/notchy that referenced this pull request Jul 21, 2026
…to 1.3.6

SwiftTerm's updateDisplay invalidated live-screen rows as if the view were
always scrolled to the bottom, so with the view scrolled back the echoed
characters landed on rows that never repainted (the caret, positioned with
the correct offset, kept moving). Fixed in the fork (upstream PR
migueldeicaza/SwiftTerm#603) and pinned Notchy to the new notchy-branch tip.
@migueldeicaza

Copy link
Copy Markdown
Owner

I had codex review the changes, just as a safety check:

[P2] Preserve pending Metal dirty rows. updateDisplay replaces metalDirtyRange. This can discard a link-highlight row queued at lines 923–930 before Metal draws it. Link highlights do not change the buffer-line generation, so the renderer does not recover the lost update. Merge the terminal range with the existing dirty range.

[P2] Apply the full-screen rule to Metal. The CoreGraphics path treats refresh(0, rows - 1) as a visible-viewport refresh. The Metal path instead translates that range from yBase. When scrolled back two rows, my test got 31...43 instead of the visible 29...41. Use visibleStart...visibleEnd for the same full-screen condition and add a Metal version of the regression test.

@bones7456

Copy link
Copy Markdown
Contributor Author

Thanks — both hold up, and the second one is a regression I introduced. Fixed in 5134c24 (added on top rather than squashed, so the delta is reviewable).

Preserving pending Metal dirty rows. Confirmed the mechanism: requestMetalDisplay() only marks the MTKView as needing display, the renderer consumes and clears the range at draw time, and a link highlight doesn't touch the buffer line's generation — so cacheValid stays true and the lost row is unrecoverable. Worth noting this was self-inflicted too: before this PR invalidateLinkHighlightRow went through terminal.updateRange, which merged; writing metalDirtyRange directly is what made replacement lossy. There is now an addMetalDirtyRows helper used by the highlight path, updateDisplay, and the iOS branch (which replaced too — it writes the whole visible range, so nothing was actually lost there, but the semantics are the same now).

I left MacTerminalView.selectionChanged alone: it also replaces, but with the entire visible range, and a queued highlight row is visible by construction, so it's a superset.

Applying the full-screen rule to Metal. Your numbers reproduce exactly. Also worth flagging that this case was right before this PR by accident: yDisp + rowStart happens to equal the visible range for refresh(0, rows-1), and updateFullScreen() sets refreshEnd = rows, which tripped the old rowEnd < terminal.rows guard and fell through to the visible range. My switch to yBase broke the first of those.

Rather than special-casing the full-screen condition twice, the Metal branch now derives its absolute rows from the view rows the CoreGraphics path already computes (yDisp + viewStart ... yDisp + viewEnd). That gets all three cases from one place — partial update lands at yBase + row, full-screen refresh equals the visible range, rows pushed below the viewport add nothing — and it removed the nested out-of-range fallbacks, so the branch is net smaller.

Tests. Four Metal cases; three of them fail against 4335830 (the full-screen one with precisely your 31...43 vs 29...41).

One thing worth knowing if you write Metal tests later: my first attempt used try view.setUseMetal(true) and skipped when Metal was unavailable — and it passed against the buggy code, because it was silently skipping. Under swift test the device is fine but shader loading isn't: SwiftTerm_SwiftTerm.bundle sits in .build/debug/ as a sibling of the .xctest bundle, and candidateBundles() probes Bundle.main's two locations and Bundle(for:) itself, but not the directory containing the code bundle — so setUseMetal(true) throws shaderSourceMissing and any GPU-gated test is quietly green. The tests now attach a bare MTKView(frame:device: nil) instead: the dirty-range bookkeeping never draws, so no device, renderer or shader library is involved and the tests actually run on CI.

That candidateBundles() gap looks like a real one beyond tests — an app that ships the resource bundle next to a framework rather than in Contents/Resources would hit it too. Happy to send a separate PR adding the code-bundle-sibling candidate if you want it; it felt unrelated to this one.

Full suite (460 tests) passes, and the package still builds for iOS.

…ed back

The update range reported by Terminal.getUpdateRange() is relative to the
live screen (yBase), but updateDisplay's CoreGraphics path used those rows
directly as view rows, which is only correct when the view is scrolled to
the bottom (yDisp == yBase). With the view scrolled back N rows, a change
on live-screen row r is displayed on view row r + N — or below the
viewport entirely — so the invalidated rect repainted unchanged scrollback
while the changed row stayed stale. Typing into a shell while scrolled
back moved the caret (positioned by updateCursorPosition, which does
account for the offset) but the echoed characters stayed invisible until
something forced a full redraw, such as scrolling back to the bottom.

Translate the update range into view rows before invalidating, skip the
invalidation when the changed rows sit entirely below the viewport, and
keep full-screen refreshes covering the whole viewport since they are used
for things that also affect visible scrollback (palette changes and the
like).

Also align the two other users of that coordinate space:

- The Metal branch computed the dirty rows as yDisp + row; the true
  absolute row is yBase + row. The per-line generation check in the
  renderer masked most of the impact, but the range was wrong.

- invalidateLinkHighlightRow fed yDisp-relative rows into updateRange,
  compensating for the CG bug and breaking once it is fixed; a highlight
  row can live in the scrollback, which the yBase-relative update range
  cannot express. Invalidate the displayed row directly instead — the
  highlight is view state, no buffer line changes.

Add regression tests asserting that the invalidated region follows the
displayed position of the changed rows in all four cases: at the bottom,
scrolled back, pushed below the viewport, and a full-screen refresh.
Two review findings on the previous commit:

- updateDisplay translated the update range from yBase for the Metal
  renderer, so a full-screen refresh while scrolled back marked
  yBase...yBase+rows-1 dirty instead of the visible yDisp...yDisp+rows-1.
  The visible scrollback rows then stayed stale for changes that do not bump
  a line's generation, a palette change being the reason the CoreGraphics
  path treats a full-screen refresh as "everything visible" in the first
  place. Derive the Metal range from the view rows that path already
  computes, so the two agree on where changed rows are displayed, on what a
  full-screen refresh means, and on leaving rows below the viewport alone.

- The Metal branch replaced metalDirtyRange, which discards a link-highlight
  row queued by invalidateLinkHighlightRow before the renderer got to draw
  it. Highlights are view state, so the buffer line's generation does not
  change and the renderer keeps its cached row — the update is not
  recoverable. Merge into the pending range instead, through the new
  addMetalDirtyRows, and use it on the iOS path too.

Four Metal regression tests cover both, plus the yBase-relative placement of
a partial update while scrolled back. They attach a bare MTKView rather than
calling setUseMetal(true): the range bookkeeping never draws, so the tests
need no device, renderer or shader library and run everywhere. Three of them
fail against the previous commit. Full suite (460 tests) passes, and the
package still builds for iOS.
@bones7456
bones7456 force-pushed the fix/scrollback-dirty-region branch from 5134c24 to 8d586fc Compare August 10, 2026 07:54
@bones7456

Copy link
Copy Markdown
Contributor Author

Rebased. The conflict wasn't textual — #620 landed the same diagnosis meanwhile — so to be explicit about what this still changes:

#620 invalidates the whole view when scrolled back. This narrows it to the rows that changed, which is the case its own message calls out: a TUI redrawing a status block while you read scrollback repaints one band instead of the entire view on every update. inPlaceRepaintWhileScrolledBackInvalidatesRenderedRow passes unchanged — it asserts the rendered row is repainted, which the precise rect satisfies.

The rest is what #620 doesn't reach: the Metal dirty range and its full-screen rule, the link-highlight invalidation, and the bidi dependency lookup added since, which asks renderingDependencyRange about yDisp + rowStart. That's the same conflation a third time — masked on the CG side by the whole-view invalidation, but absoluteDependencyRange went straight to the Metal renderer.

If you'd rather leave #620's behavior alone, I'm happy to drop the CG narrowing and keep only the Metal, highlight and bidi fixes.

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