Files
i7t5--edmund/Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

221 lines
10 KiB
Swift

import AppKit
// MARK: - Lazy Styling: idle drain + scroll promotion
//
// The dirty flush styles only blocks near the viewport synchronously and
// leaves the rest marked `isStyled == false` (base attributes after a load,
// or briefly-stale styling after an offscreen structural change). Two
// mechanisms converge the document:
//
// - The idle drain: time-budgeted main-thread slices restyling unstyled
// blocks until none remain, so document height settles and offscreen
// content is ready before the user gets there.
// - Scroll promotion: when the clip view scrolls, unstyled blocks entering
// the viewport window are styled synchronously so the user never sees raw
// base-attributed text.
extension EditorTextView {
/// Schedules the idle drain (coalesced; safe to call repeatedly).
func scheduleProgressiveStyling() {
guard !progressiveStylingScheduled else { return }
progressiveStylingScheduled = true
DispatchQueue.main.async { [weak self] in
self?.progressiveStylingScheduled = false
self?.drainStylingSlice()
}
}
/// Restyles unstyled blocks for ~6 ms, then reschedules itself if any
/// remain. Reads current state each slice, so edits/undo/load between
/// slices are naturally accommodated. Internal so tests can drive the
/// drain synchronously.
func drainStylingSlice() {
guard let ts = textStorage else { return }
guard !isUpdating else { scheduleProgressiveStyling(); return }
// Restyling marked text aborts IME composition — wait it out.
guard !hasMarkedText() else { scheduleProgressiveStyling(); return }
let start = ContinuousClock.now
let budget = Duration.milliseconds(6)
isUpdating = true
let nsString = ts.string as NSString
let cursor = selectedRange().location
var remaining = false
// Blocks restyled this slice need their TextKit 2 layout invalidated
// afterward: restyling is attribute-only, and TextKit 2 doesn't
// re-measure a fragment's geometry (height, first-line indent) for an
// attribute-only change — so a deferred block whose styled height differs
// from its base/estimated height would otherwise keep a stale fragment,
// leaving an empty band on screen. `recomposeDirty` invalidates its
// synchronously-styled blocks for the same reason.
var restyled = IndexSet()
// Explicit pool: styling churns through transient images/attributed
// strings, and a caller may run many slices without a run-loop turn.
autoreleasepool {
ts.beginEditing()
// Resume the scan where the last slice stopped (`drainCursor` is a
// hint — edits shift indices, the wrap-around pass self-corrects).
// Rescanning from 0 each slice made the drain quadratic: deep
// slices burned their whole budget skipping styled blocks.
let count = blocks.count
var scanned = 0
var idx = min(drainCursor, max(0, count - 1))
while scanned < count {
if idx >= count { idx = 0 }
if !blocks[idx].isStyled {
let cursorInBlock: Int? = (idx == activeBlockIndex)
? max(0, cursor - blocks[idx].range.location) : nil
restyleBlock(idx, cursorInBlock: cursorInBlock)
blocks[idx].isStyled = true
restyled.insert(idx)
let sep = blocks[idx].range.upperBound
if sep < nsString.length && nsString.character(at: sep) == 0x0A {
ts.setAttributes(baseAttributes, range: NSRange(location: sep, length: 1))
}
if ContinuousClock.now - start > budget {
remaining = true
idx += 1
break
}
}
idx += 1
scanned += 1
}
drainCursor = idx
ts.endEditing()
}
if let tlm = textLayoutManager {
for idx in restyled where idx < blocks.count {
if let range = blockTextRange(blocks[idx].range, tlm) {
tlm.invalidateLayout(for: range)
}
}
}
isUpdating = false
if remaining {
scheduleProgressiveStyling()
} else {
scheduleFullLayoutSettle()
}
}
/// TextKit 2 only gives a fragment a real frame once it's laid out;
/// everything else is a height *estimate*, and estimate corrections are
/// what make the scroller jump, drag-selection autoscroll oscillate, and
/// scroll targets land wrong. For small documents we can afford to lay
/// everything out once styling has converged, so no estimates remain.
/// `ensureLayout` is incremental — already-laid-out fragments are skipped
/// — so repeated settles after edits only re-lay the invalidated blocks.
/// (Large documents keep viewport-based layout: a full layout there is
/// the process-killing path that motivated `scrollRangeToVisible`'s
/// override.)
///
/// Runs on the next run-loop pass, wrapped in `preservingViewportAnchor`:
/// correcting estimates *above* the viewport shifts every laid-out
/// position below them, so doing it synchronously inside a caller's own
/// anchored restyle would poison that caller's before/after measurement.
func scheduleFullLayoutSettle() {
guard !fullLayoutSettleScheduled else { return }
fullLayoutSettleScheduled = true
// RunLoop.perform, not DispatchQueue.main.async, so tests can drain it
// with `RunLoop.main.run(until:)`.
RunLoop.main.perform { [weak self] in
MainActor.assumeIsolated {
guard let self else { return }
self.fullLayoutSettleScheduled = false
guard !self.isUpdating, !self.hasMarkedText(),
let tlm = self.textLayoutManager else { return }
self.repairContentAboveOrigin()
guard (self.textStorage?.length ?? 0) <= Self.fullLayoutMaxLength,
self.blocks.allSatisfy({ $0.isStyled }) else { return }
self.preservingViewportAnchor {
tlm.ensureLayout(for: tlm.documentRange)
}
}
}
}
/// TextKit 2 can leave the document's first fragment at a *negative* y
/// after edits near the top: layout proceeding upward from a viewport
/// anchor with a wrong height estimate assigns origins above 0, and
/// nothing renormalizes them. The symptom is the first line sitting above
/// the visible area with the scroller already at the top — unreachable.
/// Repair: re-lay from the document start (anchoring the first fragment
/// back at y 0) inside `preservingViewportAnchor`, which compensates the
/// clip origin so what the user is looking at doesn't move — and the
/// content above becomes scrollable again.
func repairContentAboveOrigin() {
guard let tlm = textLayoutManager else { return }
var firstMinY: CGFloat?
tlm.enumerateTextLayoutFragments(from: tlm.documentRange.location, options: []) {
firstMinY = $0.layoutFragmentFrame.minY
return false
}
guard let firstMinY, firstMinY < -0.5 else { return }
// Bound the re-lay to start→viewport-end (the bug only manifests with
// the viewport near the top, so this is small); bail on huge spans
// rather than risk the full-document layout cost on a large file.
var end = tlm.documentRange.endLocation
if let vp = tlm.textViewportLayoutController.viewportRange {
end = vp.endLocation
}
guard tlm.offset(from: tlm.documentRange.location, to: end) <= 60_000,
let range = NSTextRange(location: tlm.documentRange.location, end: end)
else { return }
Log.info("repairing content above origin: firstMinY=\(firstMinY)",
category: .compose)
preservingViewportAnchor {
tlm.invalidateLayout(for: range)
tlm.ensureLayout(for: range)
}
}
/// Styles any unstyled blocks inside the current viewport window. Forces a
/// viewport layout first because callers may run before the next layout
/// pass (the viewport range would otherwise be stale).
func promoteVisibleUnstyledBlocks() {
textLayoutManager?.textViewportLayoutController.layoutViewport()
guard let bounds = syncStylingBlockRange() else { return }
let unstyled = IndexSet(bounds.filter { !blocks[$0].isStyled })
guard !unstyled.isEmpty else { return }
recomposeDirty(unstyled, cursorInRaw: selectedRange().location)
}
/// Observes clip-view scrolling for promotion. Called from
/// `viewDidMoveToWindow`.
func installScrollPromotionObserver() {
guard let clipView = enclosingScrollView?.contentView else { return }
clipView.postsBoundsChangedNotifications = true
// viewDidMoveToWindow can fire more than once; keep one observation.
NotificationCenter.default.removeObserver(
self, name: NSView.boundsDidChangeNotification, object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(clipViewBoundsDidChange(_:)),
name: NSView.boundsDidChangeNotification,
object: clipView
)
}
@objc private func clipViewBoundsDidChange(_ note: Notification) {
// Promotion forces a viewport layout and may restyle blocks (changing
// their heights). Running that synchronously inside the scroll
// notification fights the momentum scroll and makes the viewport
// bounce. Defer to the next run-loop turn (coalesced), so each scroll
// tick just scrolls and styling catches up between ticks.
guard !isUpdating, !pendingPromotion else { return }
pendingPromotion = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingPromotion = false
guard !self.isUpdating else { return }
self.promoteVisibleUnstyledBlocks()
}
}
}