Skip to content

mac: paint elided pages, follow the viewport, and advertise display-list-v2-window - #367

Closed
GoKubar wants to merge 4 commits into
mainfrom
agent/mac-claude/ft070-page-window
Closed

GoKubar wants to merge 4 commits into
mainfrom
agent/mac-claude/ft070-page-window

Conversation

@GoKubar

@GoKubar GoKubar commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

The consumer half of display-list-v2-window, and the second half of the fix for
a 500 KB document that has no preview at all today.

Depends on #294 (engine/ft-070-page-window, the producer), which itself
stacks #206 and #232. Not merged here — for the integrator to sequence.
This branch is based on current origin/main (b6752e78) and does not contain
#294's producer changes, so nothing here windows anything until #294 lands.

What the producer asked for, and what this does

#294's §9 co-signer table has a row for apps/mac, and it is a gate, not a
suggestion:

apps/mac must not add the name to setLiveV2's capability list until the
pane paints an elided page as a placeholder, because today it would paint
nothing there and a blank page is indistinguishable from a page that is
genuinely empty — the confusion PageContent::Elided exists to prevent on the
producer side and which the consumer must not reintroduce.

So the branch is ordered that way, and you can check the ordering by commit:

commit what
d3ba3f05 the pane holds a partial frame: decode, validate, paint the placeholder, follow the viewport, refuse what needs the whole document
cdc38416 rendering-core / document-runtime / preview-controller pass a windowed reply through unaltered
ccfa49d3 only now, setLiveV2 sends the name
014fc2e8 a test of mine picks up #307's python3 resolver

What an elided page looks like

Not blank, and not an error. It keeps its real frame — width/height are on
the wire for exactly this — so it occupies precisely the room the built page
would, and scrolling and page navigation land where they always would. Over that
frame the pane draws a tinted fill with a dashed border, a document glyph,
"Page 41", and "not loaded yet", plus a corner label
page 41 · v2 · outside window. It is never rasterized, has no hover and no tap
target (there is no provenance on it to navigate to), and it carries its own
VoiceOver label: "Page 41, not loaded yet. Scroll here to load it." The pane
header says Showing pages 33–48 of 385 · 369 pages not loaded · caret sync and PDF export are off for them.

The fail-closed part is on the wire, not in the drawing. RenderingV2.Page gains
PageContent, mirroring the producer's enum, and both readers enforce §4's
asymmetry:

  • items present → resident (today's object, unchanged);
  • items absent with "resident": false → elided;
  • items absent without the flag → decode error.

That third rule is the whole reason §4 says "absent, not []". A resident page
still encodes with no resident key and an unwindowed list emits no window, so
every line on the wire today is byte-for-byte what it is now, and so is every
page digest built from one.

How the window follows the viewport

PreviewV2Window is a pure policy, so both halves are testable without a window
server; PreviewAnchorProbe is the only thing that touches AppKit.

  1. Report. The probe reports the page range the viewport intersects, and
    only when it changes — a scroll within one page reports nothing at all.
  2. Decide. next re-requests only when the reader is within 2 pages of
    an edge of what is resident, or has left it. Inside that band the answer is
    nil and no request is sent. The new window is maxWindowPages (24) centred
    on the viewport, clamped to the document.
  3. Send. Debounced with the compile debounce, so a flick through fifty pages
    is one request.
  4. Do not loop. The input is always the echoed window, never what was
    asked for, so a window the producer narrowed to fit the reply limit (§8) is
    left alone as long as it still covers the viewport.

Two deliberate non-behaviours. A document that fits in one window is never
windowed at all
— the common case keeps today's complete reply and keeps its
-delta base, which matters because §7 makes -window and -delta exclusive.
And the case the proposal exists for: a document too large to serialise comes
back status: failed with no pages and no sibling, so the shell cannot learn
its size from a reply that does not exist. It does not try to — it retries
once with a window at wherever the reader is (page 1 until the pane has
reported a viewport), and the echo then says how big the document really is.

What is disabled while pages are elided

Per §4.1, a windowed reply is not a complete compile:

  • Export PDF (v2) and Export Exact PDF refuse, and the toolbar button
    is disabled with a reason. The v1 Export PDF refuses too — §5.6 has
    v1::fallback skip an elided page rather than send it empty, so the v1
    pages of a windowed reply are a subset of the document. Each refusal names
    the damage: "this preview is a 16-page window over a 385-page document
    (369 pages not loaded)"
    . An export that silently dropped 369 pages would be
    far worse than one that refuses.
  • A windowed frame is never a -delta base (§7). The request never carries
    both names, and the frame guards it independently.
  • Caret sync / click-to-source. CaretFollow walks resident pages only —
    explicitly, not by accident — so the pane never scrolls to a placeholder as if
    the caret were on it. ⌘⇧J reports "Caret sync is limited to the loaded page
    window (pages 33–48 of 385); the caret maps to no page in it. Scroll to the
    page you want and it is requested."
    rather than "inside no preview item",
    which is a different claim.

The Rust route

An audit of everything between the worker and the shell. The sibling body was
already opaque in both forwarding lanes and needed no change: document-runtime's
raw_display retains the original text verbatim (its own test asserts
c.raw().get() == input), display_candidate reads only the envelope
correlation fields, and preview-controller forwards it as a RawValue
(byte-exact) or a Value (keys reordered, content preserved). What needed
changing was everything that reads the shape:

  • document-runtime — the hard blocker. validate_reply_value's reply
    allowlist did not contain display-list-v2-window, so the moment a worker
    echoed the name the reply was rejected → Session::fail → the process is
    taken. The compiler session would have been killed by the capability meant to
    rescue it. The name is added with the same requires-display-list-v2 guard the
    images capability already has; the request payload gains display_list_window
    (skipped when absent, so the frozen runtime-v1 request bytes are unchanged);
    and the v1 pages contiguity rule is relaxed to "strictly increasing" only
    when the reply echoed the capability, because §5.6's subset does not start at
    page 1.
  • preview-controller. restart() stripped display-list-v2 but left
    display-list-v2-images behind — and would have left -window — so a later
    compile asked for an extension whose base a previous restart had dropped. All
    three go together now.
  • rendering-core is the reference model rather than a link in the route
    (nothing in tree depends on it), but it is what the Swift consumer mirrors, and
    deny_unknown_fields made it refuse a windowed list twice: unknown field
    window, missing field items. Page now hand-rolls its (de)serialization
    with exactly the Swift rule, re-encodes a resident page with no resident key,
    and validates residency against the window. bind_list already accepted a
    frame whose fonts[] is the whole-document closure while only the window's
    pages are resident — the other half of that crate's co-signer row — and now
    says so.

Verification

Mac (Xcode 26.6, ~/mac-build/window, clean .build for both runs):

swift build tests
control @ b6752e78 (this branch's base) clean 1058 executed, 136 skipped, 0 failures
this branch @ ccfa49d3 clean 1080 executed, 136 skipped, 0 failures

Same failing set (empty), +22 tests. 014fc2e8 is later and touches only a Rust
test module, no Swift. Note for anyone reproducing: the Page layout change
means an incremental .build from the base segfaults on unrelated tests —
rm -rf .build after checking out.

Rust, each against a control run of the same base in a separate worktree:

crate branch control failing set
rendering-core 4 failures 4 failures identical (pre-existing PDF byte-hash mismatches)
document-runtime 30 failures 30 failures identical (pre-existing: these tests hardcode /usr/bin/python3, which NixOS lacks — #307 fixed this for preview-controller only)
preview-controller 0 failures, 89 passed 0 failures

The 22 new Swift tests (V2PageWindowTests) are the co-signer row, in its own
order: the wire shape on both readers and through a round trip; the fail-closed
refusal of a page with neither items nor resident: false; residency
disagreeing with the echoed window; §3's whole-document required_features
being accepted on a windowed reply; the elided page prepared at its real frame
and not rasterized; an empty page and an elided page being distinguishable;
the viewport policy (centring, clamping, the comfort band, a narrowed window not
looping, a small document never being windowed, the failure retry firing once);
an end-to-end scroll through a real NSScrollView in an off-screen window,
asserting that a scroll inside one page reports nothing and a scroll to page 20
reports a range containing it; and the export/delta/caret refusals.

Open

The capability is advertised as of ccfa49d3, which is what #294's gate
allows now that the placeholder exists. It still windows nothing on its own: §4
requires the request to also carry display_list_window, and this shell sends
that only for a document with more pages than one window holds, or after a
compile that failed outright. FLASHTEX_DISPLAY_LIST_WINDOW=0 turns it off.

Not done, and not claimed: the searchable-text cache and the accessibility
document model still build from whatever pages are present, so "Find in preview"
over a windowed frame searches the resident pages only. That is a narrower
version of the same whole-document problem and wants its own change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu

GoKubar and others added 4 commits September 14, 2026 04:53
…ader

The consumer half of `display-list-v2-window`
(`protocol/proposals/display-list-v2-window.md` r2, producer PR #294, which this
stacks on and which itself stacks #206 and #232 — for the integrator, not merged
here). The producer's `apps/mac` co-signer row gates the capability on exactly
one thing: the pane must paint an elided page as a placeholder before the shell
may send the name, because today it would paint nothing there and a blank page
is indistinguishable from a page that is genuinely empty — the confusion
`PageContent::Elided` exists to prevent on the producer side and which the
consumer must not reintroduce. This commit does the painting. The name is not
sent yet; that is the last commit of this branch.

## The wire shape, fail-closed

`RenderingV2.Page` gains `PageContent`, mirroring the producer's enum so that
"this page was not built" is not representable as "this page is empty":

* `items` present → resident (today's object exactly);
* `items` absent with `"resident": false` → elided;
* `items` absent with no flag → a **decode error**.

That last rule is the whole reason §4 makes `items` absent rather than `[]`: a
consumer that never learned about windows gets a refusal instead of a blank
page. Both readers enforce it — `Codable` and the fast byte reader — and
`DisplayList` gains `window`. A resident page still encodes with no `resident`
key and an unwindowed list emits no `window`, so every line on the wire today is
byte-for-byte unchanged, and so is every page digest built from one.

`validate` gains the residency/window agreement check — an elided page with no
window, a hole inside the window, a resident page outside it, a
`document_page_count` that disagrees with the entries are all refusals — and
skips item checks on elided pages while still requiring their page number and a
positive frame. The `used ⊆ declared` direction of the feature check is already
what §3's whole-document `required_features` needs; a test pins that, because
the opposite direction would refuse a window whose only rule sits on a page the
producer was not asked to build.

## The paint

An elided page keeps its real frame, so the scroll column and page navigation
land exactly where they would have — only the glyph-level content is windowed.
`PageV2View` draws it as a dashed, tinted placeholder labelled "Page N / not
loaded yet": not a white rectangle, not an error. It is not rasterized, has no
hover and no tap target (there is no provenance on it to navigate to), and
carries its own accessibility label. The pane header says "Showing pages a–b of
N · k pages not loaded".

## The window follows the viewport, with hysteresis

`PreviewV2Window` is a pure policy so both halves are testable without a window
server. `PreviewAnchorProbe` reports the page range under the viewport, and only
when it changes — a scroll inside one page reports nothing at all.
`PreviewV2Window.next` then re-requests only when the reader comes within two
pages of an edge of what is resident, or leaves it, centring
`RenderingV2.maxWindowPages` pages on the viewport; the send is debounced with
the compile debounce, so a flick through many pages is one request. The *echoed*
window is always the input, never the requested one, so a window the producer
narrowed to fit the reply limit (§8) does not loop. A document that fits in one
window is never windowed at all, which keeps the common case — and `-delta`,
which §7 makes exclusive with `-window` — exactly as it is today.

And the case the proposal exists for: a document too large to serialise comes
back `status: failed` with no pages and no sibling, so the shell cannot learn its
size from a reply that does not exist. It does not try to — it retries once with
a window at wherever the reader is, and the echo then says how big the document
really is.

## What refuses while pages are elided

`Export PDF (v2)`, `Export Exact PDF` and the v1 `Export PDF` all refuse a
windowed frame and say how many pages are missing (§4.1: a windowed reply is
never a PDF's source; §5.6: `v1::fallback` skips elided pages, so the v1 payload
is a subset of the document). A windowed frame is never installed as a
`display-list-v2-delta` base. Caret follow walks resident pages only, and ⌘⇧J
reports that caret sync is limited to the loaded window rather than "inside no
preview item" — a different claim, and the honest one.

Implementation-Agent: claude-opus-5 (mac lane, linux-primary NixOS)
Commit-Executor: claude-opus-5 (direct git; Cursor usage limit in effect)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
… reply through unaltered

The three Rust crates on (or modelling) the route between the worker and the Mac
shell, for `display-list-v2-window`. An audit of the route found the sibling
*body* already opaque in both forwarding lanes — `document-runtime`'s
`raw_display` retains the original text verbatim and its `display_candidate`
validator reads only the envelope correlation fields; `preview-controller`
forwards it as a `RawValue` (byte-exact) or as a `Value` (keys reordered, content
preserved). Nothing there needed changing. What did need changing was
everything that reads the *shape*:

**`document-runtime` — the hard blocker.** `validate_reply_value`'s reply
capability allowlist did not contain `display-list-v2-window`, so the moment a
worker echoed the name the reply was rejected, which reaches `Session::fail`,
which takes the process — the compiler session would be killed by the very
capability meant to rescue it. The name is added, with the same
requires-`display-list-v2` guard the images capability already has. The request
payload gains `display_list_window` (skipped when absent, so the frozen
runtime-v1 request bytes are unchanged) with a session setter mirroring
`set_project_root`.

Its v1 `pages` validator also required page numbers contiguous from 1. Per §5.6
`v1::fallback` *skips* an elided page rather than sending it as an empty one, so
a windowed reply's v1 pages are a strictly increasing subset that starts at the
window's first page. The contiguity rule is relaxed to "strictly increasing"
only when the reply echoed `display-list-v2-window`; without it the original
rule is untouched.

**`preview-controller`.** `restart()` stripped `display-list-v2` from the
capability set but left `display-list-v2-images` behind — and would have left
`display-list-v2-window` — so a later compile asked for an extension whose base
capability a previous restart had silently dropped. All three now go together.

**`rendering-core`** is the reference model rather than a link in the route
(nothing in tree depends on it), but it is what the Swift consumer mirrors, and
`deny_unknown_fields` on `Page` and `DisplayList` made it refuse a windowed list
twice over: unknown field `window`, missing field `items`. `Page` now hand-rolls
its (de)serialization around a private wire shape, with exactly the Swift side's
rule — `items` present is resident, `resident: false` with no `items` is elided,
anything else is a refusal — and re-encodes a resident page with no `resident`
key, so a resident page round-trips byte-identically and an unwindowed list
emits no `window`. Validation skips item checks on elided pages and gains the
residency-agrees-with-the-window rule. `bind_list`'s font binding already
accepts a frame whose `fonts[]` is the whole-document closure while only the
window's pages are resident — the rendering-core co-signer row's other half —
and now says so.

Tests: a windowed list round-trips without losing `window` or `resident`; an
unwindowed one is byte-identical to before; a page with neither `items` nor
`resident: false` is refused; residency disagreeing with the window is refused;
`document-runtime` accepts the echo only when it was requested alongside
`display-list-v2`, and a windowed v1 `pages` subset validates.

Implementation-Agent: claude-opus-5 (mac lane, linux-primary NixOS)
Commit-Executor: claude-opus-5 (direct git; Cursor usage limit in effect)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
…nted

`setLiveV2` adds `display-list-v2-window` next to `display-list-v2` and
`display-list-v2-images`. This is the last commit of the branch on purpose: the
producer's co-signer row says the shell must not send the name until the pane
paints an elided page as a placeholder, and until the commit before this one it
did not.

The name on its own windows nothing. §4: a reply is windowed only when the
request *also* carries `display_list_window`, and this shell sends that in
exactly two situations — a document with more pages than one window holds, and a
compile that came back `status: failed` because the whole document would not fit
(§1.0, the 500 KB case that has no preview at all today). Every other document
gets the complete reply it gets now, including its `-delta` base.
`FLASHTEX_DISPLAY_LIST_WINDOW=0` turns the request off for a session.

`V2ImageTests` learns the third opt-in; `apps/mac/README.md` documents the
capability, the placeholder, the viewport policy and what refuses while pages
are elided.

Implementation-Agent: claude-opus-5 (mac lane, linux-primary NixOS)
Commit-Executor: claude-opus-5 (direct git; Cursor usage limit in effect)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
…bin/python3

The restart-capability test added in this branch was written against the old
`tests/lifecycle.rs` convention; #307 replaced that with a resolver
(`FLASHTEX_TEST_PYTHON`, else `/usr/bin/python3` when it exists, else the first
`python3` on `PATH`) because NixOS has no `/usr/bin/python3`. The unit-test
module cannot borrow the integration test's helper — it needs the private
`layout_capabilities` field — so it carries the same resolver.

`cargo test -p flashtex-preview-controller` now passes 89 tests, 0 failures,
matching a control run of the same base.

Implementation-Agent: claude-opus-5 (mac lane, linux-primary NixOS)
Commit-Executor: claude-opus-5 (direct git; Cursor usage limit in effect)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5Hmwh5GHNTiAmHUJ1MZu
@d-q222

d-q222 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

daniel-parent overlap heads-up: #367 (display-list-v2-window, draft on #294) and #333 (clickable links, the Mac consumer of display-list-v2-links; ready, merges cleanly with main) conflict in PreviewAnchor.swift, PreviewV2View.swift, ShellModel.swift, RenderingV2.swift and RenderingV2Fast.swift. #367 merges cleanly with my #345 and #363. #333 is older, not stacked, and not a draft, so I suggest it lands first and #367 rebases onto it. If #367 lands first instead, I'll rebase #333 myself. Either way it's one rebase, not two.

@d-q222

d-q222 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

daniel-parent: #333 has landed, so per the earlier heads-up this PR now needs its rebase (the conflicts were in PreviewAnchor.swift, PreviewV2View.swift, ShellModel.swift and RenderingV2*.swift). Ping me here if any of #333's link-hit-testing code gets in the way, and I'll help resolve it.

GoKubar pushed a commit that referenced this pull request Sep 15, 2026
… gaps (ordering only, on #476) (#411)

* wip(#76): failing tests for Problems list bucket order

Add DiagnosticsPanelTests that pin errors-then-warnings-then-gaps
across two documents, Copy Diagnostics walking that order, and
⌘⌥] starting on the first author error rather than an earlier gap.

What was run: `swift test --filter` of the three new methods in
apps/mac. Result: Executed 3 tests, with 7 failures (0 unexpected)
in 0.238 seconds. Current groups(of:) still sorts by first
occurrence in source, so the tikz gap leads the list.

Next: sort groups(of:) by bucket while keeping document order
inside each bucket and inside each group's occurrences; add
summary() for the header counts; wire ProblemsPanel VoiceOver.

Open questions: whether ProblemsPanel.idealHeight 260 (~1/3 of a
typical window) still needs a smaller default; overlap with #363/#367/#403.

Implementation-Agent: cursor-agent cursor-grok-4.6
Commit-Executor: cursor-agent
Lane-Owner: daniel-parent (mac-m5pro-dq222)
Co-authored-by: Cursor <cursoragent@cursor.com>

* wip(#76): sort Problems groups by bucket and show header counts

groups(of:) lists author errors, then warnings, then FlashTeX gaps,
keeping document order inside each bucket and inside each group's
occurrences. result.diagnostics is not reordered. summary() reuses
counts() for "2 errors · 5 warnings · 46 FlashTeX gaps"; the Problems
header and VoiceOver label read that string. Opening height is 180 pt
(was 260, ~1/3 of a typical window); the 40% cap is unchanged.

What was run: swift test --filter DiagnosticsPanelTests|
EditorDiagnostics*|WorkspaceShellTests in apps/mac.
Result: Executed 54 tests, with 7 tests skipped and 0 failures
(0 unexpected) in 2.107 seconds.

Next: full `swift test` from apps/mac; overlap check; push.

Open questions: none on ordering/header. Existing AppStorage values
of 260 remain until the user resizes.

Implementation-Agent: cursor-agent cursor-grok-4.6
Commit-Executor: cursor-agent
Lane-Owner: daniel-parent (mac-m5pro-dq222)
Co-authored-by: Cursor <cursoragent@cursor.com>

* wip(#76): restore coloured Problems header count chips

Restore the three SF Symbol count labels (errors red, warnings orange,
gaps puzzle-piece secondary) including zeros so an all-gap document still
shows the error column. VoiceOver uses summary() once via .ignore on the
chips; the panel stays .contain so the list remains accessible.
DiagnosticsListView caption matches. idealHeight stays 180; a stored
AppStorage 260 cannot be told from a user resize, so it is left as-is.

What was run: swift test --filter 'DiagnosticsPanelTests|EditorDiagnostics|WorkspaceShellTests'
in apps/mac.
Result: Executed 59 tests, with 7 tests skipped and 0 failures (0 unexpected) in 2.112 seconds.

Next: daniel-parent review / PR.

Open questions: none on the header. AppStorage 260 left as-is.

Implementation-Agent: cursor-agent cursor-grok-4.6
Commit-Executor: cursor-agent
Lane-Owner: daniel-parent (mac-m5pro-dq222)
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
@d-q222

d-q222 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Superseded — the whole page-windowing feature (producer + consumer) shipped under different PR numbers, via #665 (35ead1c2).

Verified on origin/main: apps/mac/Sources/FlashTeXMac/V2PageWindow.swift exists and its header cites "producer PR #294"; V2WindowTests.swift plus fixtures display-list-v2-window.{json,tex} are present.


Closed by the Commander after a stale-draft screening pass that required evidence in both directions — a landing SHA to close, an empty grep to keep open. If this verdict is wrong, say so on issue #2 and I will reopen: an asymmetric bar has produced wrong calls here in both directions before.

@d-q222 d-q222 closed this Sep 16, 2026
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