255 lines
12 KiB
Swift
255 lines
12 KiB
Swift
import AppKit
|
|
|
|
// MARK: - Display Composition & Coordinate Mapping
|
|
//
|
|
// `recompose` rebuilds the whole text storage by styling every block and
|
|
// joining them; `recomposeIncremental` re-styles only the block(s) the cursor
|
|
// moved between, which is what runs on most edits. Because the text storage
|
|
// always equals the raw source (word-level rendering, no string stripping),
|
|
// the display↔raw coordinate mapping is the identity — display offsets are
|
|
// used as raw offsets directly.
|
|
|
|
extension EditorTextView {
|
|
|
|
// MARK: - Display Composition
|
|
//
|
|
// Text storage content = rawSource, always.
|
|
// Styling is attribute-only; the string never changes during recompose.
|
|
|
|
/// Full recompose: replaces the entire text storage with `rawSource` in
|
|
/// base attributes, then styles via the dirty flush — viewport-first when
|
|
/// the editor is in a scroll view, everything synchronously otherwise.
|
|
/// Used when rawSource was rebuilt (initial load, undo/redo, indent) and
|
|
/// for content changes that bypass the edit path.
|
|
func recompose(cursorInRaw: Int, selectionInRaw: NSRange? = nil) {
|
|
guard let ts = textStorage else { return }
|
|
|
|
Log.measure("Full recompose (\(blocks.count) blocks)", category: .compose, level: .debug) {
|
|
isUpdating = true
|
|
let fullRange = NSRange(location: 0, length: ts.length)
|
|
ts.beginEditing()
|
|
ts.replaceCharacters(in: fullRange,
|
|
with: NSAttributedString(string: rawSource,
|
|
attributes: baseAttributes))
|
|
ts.endEditing()
|
|
// Whole-document replacement; blocks are re-parsed by our callers.
|
|
(ts as? EditorTextStorage)?.clearPendingEdit()
|
|
isUpdating = false
|
|
|
|
for i in blocks.indices { blocks[i].isStyled = false }
|
|
recomposeDirty(IndexSet(blocks.indices), cursorInRaw: cursorInRaw,
|
|
selectionInRaw: selectionInRaw, settingSelection: true)
|
|
}
|
|
}
|
|
|
|
/// Range-bounded recompose: replaces only `oldRange` (pre-edit storage
|
|
/// coordinates) with `newText` in base attributes, then restyles the given
|
|
/// dirty blocks. Unlike `recompose`, the storage — and its TextKit 2 layout
|
|
/// — outside `oldRange` is untouched, so content above the edit keeps its
|
|
/// laid-out positions and the viewport can't lurch. For edits that rebuild
|
|
/// `rawSource` but change only a contiguous span (Tab / Shift-Tab indent):
|
|
/// callers update `rawSource` and re-parse `blocks` first, then pass the old
|
|
/// span's range and its replacement text.
|
|
func recomposeReplacing(oldRange: NSRange, with newText: String,
|
|
dirty: IndexSet, cursorInRaw: Int,
|
|
selectionInRaw: NSRange? = nil) {
|
|
guard let ts = textStorage else { return }
|
|
|
|
isUpdating = true
|
|
ts.beginEditing()
|
|
ts.replaceCharacters(in: oldRange,
|
|
with: NSAttributedString(string: newText,
|
|
attributes: baseAttributes))
|
|
ts.endEditing()
|
|
// Programmatic replacement; the incremental parser must not see it.
|
|
(ts as? EditorTextStorage)?.clearPendingEdit()
|
|
isUpdating = false
|
|
|
|
for idx in dirty where idx < blocks.count { blocks[idx].isStyled = false }
|
|
recomposeDirty(dirty, cursorInRaw: cursorInRaw,
|
|
selectionInRaw: selectionInRaw, settingSelection: true)
|
|
}
|
|
|
|
/// Dirty-set recompose: restyles exactly the given block indexes in place.
|
|
/// Attribute-only — the storage string is never touched. This is the
|
|
/// single styling path for edits, activation changes, and theme /
|
|
/// appearance refreshes; `recompose` (string-replacing) remains only for
|
|
/// paths that rebuild `rawSource` (load, undo, indent).
|
|
///
|
|
/// `settingSelection` is true for selection-driven and whole-document
|
|
/// callers (preserving the old recompose behavior); the edit path leaves
|
|
/// the caret where NSTextView already placed it to avoid re-entrant
|
|
/// selection notifications.
|
|
func recomposeDirty(
|
|
_ dirty: IndexSet,
|
|
cursorInRaw: Int,
|
|
selectionInRaw: NSRange? = nil,
|
|
settingSelection: Bool = false
|
|
) {
|
|
guard let ts = textStorage else { return }
|
|
|
|
isUpdating = true
|
|
|
|
let newActiveIndex = blockIndexForRawOffset(cursorInRaw)
|
|
activeBlockIndex = newActiveIndex
|
|
|
|
// Lazy rendering: a LARGE dirty set (load, theme change, a fence
|
|
// absorbing half the document) is restyled synchronously only near
|
|
// the viewport; the rest goes to the idle drain / scroll promotion.
|
|
// Small sets — every normal interaction — are styled in full, so
|
|
// user-visible state transitions are never deferred. Without a
|
|
// scroll view (headless), everything is synchronous.
|
|
var syncSet = dirty
|
|
if dirty.count > 8, let bounds = syncStylingBlockRange() {
|
|
syncSet = dirty.filteredIndexSet { bounds.contains($0) }
|
|
if let active = newActiveIndex, dirty.contains(active) {
|
|
syncSet.insert(active)
|
|
}
|
|
}
|
|
let deferred = dirty.subtracting(syncSet)
|
|
|
|
let nsString = ts.string as NSString
|
|
ts.beginEditing()
|
|
for idx in syncSet where idx < blocks.count {
|
|
let cursorInBlock: Int? = (idx == newActiveIndex)
|
|
? max(0, cursorInRaw - blocks[idx].range.location) : nil
|
|
restyleBlock(idx, cursorInBlock: cursorInBlock)
|
|
blocks[idx].isStyled = true
|
|
|
|
// Full recompose resets separator newlines to base attributes as
|
|
// a side effect of rebuilding the whole string; do the same for
|
|
// dirty blocks so stale paragraph styles can't linger on the `\n`
|
|
// after e.g. a former callout.
|
|
let sep = blocks[idx].range.upperBound
|
|
if sep < nsString.length && nsString.character(at: sep) == 0x0A {
|
|
ts.setAttributes(baseAttributes, range: NSRange(location: sep, length: 1))
|
|
}
|
|
}
|
|
ts.endEditing()
|
|
|
|
// A block whose paragraph style we just changed (e.g. a freshly created
|
|
// list item's indent) can keep a stale first-line layout: on the edit
|
|
// path `insertText` already laid the line out with the base typing
|
|
// attributes, and TextKit 2 doesn't re-measure the first-line indent for
|
|
// an attribute-only change. Force the restyled blocks to re-lay-out so
|
|
// the new indent shows immediately instead of after the next cursor move.
|
|
if let tlm = textLayoutManager {
|
|
for idx in syncSet where idx < blocks.count {
|
|
if let range = blockTextRange(blocks[idx].range, tlm) {
|
|
tlm.invalidateLayout(for: range)
|
|
}
|
|
}
|
|
}
|
|
|
|
for idx in deferred where idx < blocks.count {
|
|
blocks[idx].isStyled = false
|
|
}
|
|
|
|
if settingSelection {
|
|
if let rawSel = selectionInRaw, rawSel.length > 0 {
|
|
let len = ts.length
|
|
let displaySel = NSRange(
|
|
location: min(rawSel.location, len),
|
|
length: max(0, min(rawSel.upperBound, len) - min(rawSel.location, len))
|
|
)
|
|
setSelectedRange(displaySel)
|
|
} else {
|
|
let clamped = min(cursorInRaw, ts.length)
|
|
setSelectedRange(NSRange(location: clamped, length: 0))
|
|
}
|
|
}
|
|
|
|
typingAttributes = baseAttributes
|
|
|
|
isUpdating = false
|
|
|
|
if !deferred.isEmpty {
|
|
scheduleProgressiveStyling()
|
|
} else {
|
|
// Small documents stay fully laid out (no TextKit 2 height
|
|
// estimates): re-lay the blocks this flush invalidated once this
|
|
// interaction settles. Cheap on the per-keystroke path — only the
|
|
// invalidated fragments are re-laid.
|
|
scheduleFullLayoutSettle()
|
|
}
|
|
}
|
|
|
|
/// Maps a block's raw NSRange to an `NSTextRange` for layout invalidation.
|
|
func blockTextRange(_ nsRange: NSRange, _ tlm: NSTextLayoutManager) -> NSTextRange? {
|
|
guard let start = tlm.location(tlm.documentRange.location, offsetBy: nsRange.location),
|
|
let end = tlm.location(start, offsetBy: nsRange.length) else { return nil }
|
|
return NSTextRange(location: start, end: end)
|
|
}
|
|
|
|
/// Incremental recompose: only re-styles the old and new active blocks.
|
|
/// Used when the cursor moves between blocks without changing content.
|
|
/// `settingSelection` is false when the caller already owns the caret (a
|
|
/// user-driven cursor move) — re-setting it would trigger AppKit's
|
|
/// scroll-the-selection-into-view, fighting typewriter centering.
|
|
func recomposeIncremental(cursorInRaw: Int, selectionInRaw: NSRange? = nil,
|
|
settingSelection: Bool = true) {
|
|
var dirty = IndexSet()
|
|
if let oldIdx = activeBlockIndex, oldIdx < blocks.count { dirty.insert(oldIdx) }
|
|
if let newIdx = blockIndexForRawOffset(cursorInRaw) { dirty.insert(newIdx) }
|
|
recomposeDirty(dirty, cursorInRaw: cursorInRaw,
|
|
selectionInRaw: selectionInRaw, settingSelection: settingSelection)
|
|
}
|
|
|
|
/// Restyles every block in place (attribute-only). For theme and
|
|
/// appearance changes: the string is unchanged but every attribute
|
|
/// derives from the new theme/appearance.
|
|
func recomposeAllDirty() {
|
|
for i in blocks.indices { blocks[i].isStyled = false }
|
|
recomposeDirty(IndexSet(blocks.indices),
|
|
cursorInRaw: currentCursorInRaw(),
|
|
settingSelection: true)
|
|
}
|
|
|
|
/// The block-index window to style synchronously: the TextKit 2 viewport
|
|
/// plus a margin, or — before any layout exists (fresh load) — a window
|
|
/// around the active block. Returns nil without a scroll view (headless):
|
|
/// callers then style everything synchronously.
|
|
func syncStylingBlockRange() -> ClosedRange<Int>? {
|
|
guard enclosingScrollView != nil, !blocks.isEmpty,
|
|
let tlm = textLayoutManager else { return nil }
|
|
|
|
if let viewport = tlm.textViewportLayoutController.viewportRange {
|
|
let docStart = tlm.documentRange.location
|
|
let start = tlm.offset(from: docStart, to: viewport.location)
|
|
let end = tlm.offset(from: docStart, to: viewport.endLocation)
|
|
if let s = blockIndexForRawOffset(start),
|
|
let e = blockIndexForRawOffset(max(start, end)) {
|
|
let margin = max(16, e - s + 1)
|
|
return max(0, s - margin) ... min(blocks.count - 1, e + margin)
|
|
}
|
|
}
|
|
// No viewport yet (first layout hasn't happened): style a generous
|
|
// window around the cursor; the drain and scroll promotion cover the rest.
|
|
let anchor = activeBlockIndex ?? 0
|
|
return max(0, anchor - 200) ... min(blocks.count - 1, anchor + 200)
|
|
}
|
|
|
|
// MARK: - Coordinate Mapping
|
|
//
|
|
// With text storage = rawSource, display offset = raw offset (identity).
|
|
|
|
/// Binary search over the (sorted, adjacent) block ranges. An offset at a
|
|
/// block's `upperBound` — the trailing `\n` separator — belongs to that
|
|
/// block; offsets past the last block clamp to it.
|
|
func blockIndexForRawOffset(_ rawOffset: Int) -> Int? {
|
|
guard !blocks.isEmpty else { return nil }
|
|
var lo = 0
|
|
var hi = blocks.count - 1
|
|
// First block whose inclusive upper bound reaches the offset.
|
|
while lo < hi {
|
|
let mid = (lo + hi) / 2
|
|
if blocks[mid].range.upperBound < rawOffset {
|
|
lo = mid + 1
|
|
} else {
|
|
hi = mid
|
|
}
|
|
}
|
|
return lo
|
|
}
|
|
}
|