Skip to content

Embedder scroll seam: no way to observe or drive scroll position in .scrolls mode (two-pane editor/preview sync) #171

Description

@gerardp

Not a bug — a design question before I write anything, since CONTRIBUTING says an architectural change is better sketched than described. If the shape below sounds right I'll bring it as a draft PR.

Use case

A two-pane Markdown app: source on the left, rendered preview on the right, both NativeTextViewWrapper over the same text. Scrolling one pane should carry the other.

What's missing

In .scrolls (the default) an embedder has neither half of that:

  • No signal out. Nothing reports where the reader is. onPersistScrollOffset / restoreScrollOffset are scroll memory across document switches — they fire on switch-away and teardown, not while scrolling, and speak raw y points.
  • No command in. Nothing scrolls a pane to a position. scrollOffsets is internal, ClampedScrollView has no public surface, and the NSScrollView never reaches the embedder through SwiftUI.

heightBehavior: .fitsContent is a real workaround — the enclosing ScrollView becomes mine, so I can sync it myself — but it forgoes TextKit-2 viewport virtualization, which the docs already warn about and which #107 suggests matters for large notes. It also only buys me proportional sync, and that drifts precisely when the two panes are styled differently (a source pane with flattened heading multipliers and a mono font wraps differently from the rendered one).

Why this is a smaller ask than it sounds

The mapping problem that usually sinks editor↔preview sync doesn't exist here. Both panes are the same engine over the same string, so character offset is already a shared coordinate space — no source maps, no line tables. And both halves exist internally:

  • the clip view's boundsDidChangeNotification observer (NativeTextViewWrapper.swift:384) already fires on every scroll and already does five things
  • NativeTextView.scrollRangeToVisible(_:) (NativeTextView+FrameAndOverscroll.swift:302) already handles the reading-column and overscroll cases
  • textLayoutFragment(for:) is already used in four places for point → fragment

So the seam is roughly: report the top visible text location, and accept a request to bring a text location to the top.

Question

#89 proposed almost exactly the missing half — a ScrollHandler the embedder owns and the engine fills in makeNSView, plus a shared scrollRangeIntoView(_:in:) — for a TOC panel. It was closed by its own author with no discussion, and none of it is in main. Was there something wrong with that shape, or did it simply lapse? I'd rather build on your answer than guess.

Three things I'd want to get right, whatever the shape:

  1. Feedback loop. A drives B, B's bounds change, B drives A. Needs an explicit "currently driven" state; I don't think it can be left to embedders to debounce.
  2. Cost of the round trip. A @Binding in the pendingInlineReplacement style is clean but goes through SwiftUI's update cycle every frame — probably too slow for scrolling. That's an argument for feat(toc): add headings extraction, scroll-to-range, and per-editor TOC integration #89's imperative handle over a binding, which is why I'm asking rather than assuming.
  3. Observer lifetime. Editor stack leaks: makeNSView observer tokens are never removed #63 says the makeNSView observer tokens are never released. Hanging a public callback off that same observer would deepen an existing leak, so it may want fixing first or together.

Happy to take the smallest version of this you'd accept — even just the read-only half (a "visible range changed" callback), which is enough for a scrollbar-position indicator and half of the sync.

Environment

macOS 15.7.9, Xcode 26.3, Swift 6.2.4, MarkdownEngine main (08ff3c0)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions