///|
pub(all) enum MarkdownEditReason {
LoadDocument
TextInput
CommandEdit
ReplaceEdit
ToggleTaskEdit
MediaEdit
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownEditReason with Debug::{to_repr}
///|
pub extend MarkdownEditReason with Eq::{not_equal, equal}
///|
pub extend MarkdownEditReason with ToJson::{to_json}
///|
pub(all) struct MarkdownEditTransaction {
previous_source : String
source : String
edit_range : MarkdownSourceRange
replacement : String
// The text the edit removed, carried since the keystroke path (the
// control's committed edit record) already knows it; deriving it from
// `previous_source` would slice the whole document per keystroke.
deleted : String
caret : Int
selection : @core.TextRange?
reason : MarkdownEditReason
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownEditTransaction with Debug::{to_repr}
///|
pub extend MarkdownEditTransaction with Eq::{not_equal, equal}
///|
pub extend MarkdownEditTransaction with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentDirtyRange {
start_block : Int
end_block : Int
source_start : Int
source_end : Int
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentDirtyRange with Debug::{to_repr}
///|
pub extend MarkdownDocumentDirtyRange with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentDirtyRange with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentBlock {
id : Int
// The dirty ±1 edge re-stamps its revision in place on the shared
// record; absolute offsets shift in place with the incremental apply
// (shared mutable core, same contract as the text store).
mut revision : Int
mut source_range : MarkdownSourceRange
mut content_range : MarkdownSourceRange
kind : MarkdownBlockKind
text : String
// Content identity of the block, only ever compared for equality. A
// 64-bit kind tag + content hash replaces the previous kind-key:string
// concatenation, which allocated two small strings per block — on the
// stress fixture that was ~200k allocations per document open.
fingerprint : UInt64
inline_count : Int
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentBlock with Debug::{to_repr}
///|
pub extend MarkdownDocumentBlock with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentBlock with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentLayoutBlock {
block_id : Int
block_index : Int
revision : Int
top : Double
height : Double
source_range : MarkdownSourceRange
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentLayoutBlock with Debug::{to_repr}
///|
pub extend MarkdownDocumentLayoutBlock with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentLayoutBlock with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentBlockHeightMetric {
// Identity re-stamps in place when a dirty edge bumps the block's
// revision; position/offset/top fields shift with the incremental apply.
mut block_id : Int
mut block_index : Int
mut revision : Int
mut source_range : MarkdownSourceRange
mut visual_top_units : Double
visual_height_units : Double
mut source_top_units : Double
source_height_units : Double
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentBlockHeightMetric with Debug::{to_repr}
///|
pub extend MarkdownDocumentBlockHeightMetric with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentBlockHeightMetric with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentHeightIndex {
visual_content_units : Double
source_content_units : Double
blocks : Array[MarkdownDocumentBlockHeightMetric]
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentHeightIndex with Debug::{to_repr}
///|
pub extend MarkdownDocumentHeightIndex with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentHeightIndex with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentLayoutCache {
revision : Int
width : Double
scroll_y : Double
viewport_height : Double
overscan : Double
content_height : Double
visible_start : Int
visible_end : Int
blocks : Array[MarkdownDocumentLayoutBlock]
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentLayoutCache with Debug::{to_repr}
///|
pub extend MarkdownDocumentLayoutCache with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentLayoutCache with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentRichTextWindow {
document : RichTextDocument
content_height : Double
top_padding : Double
scroll_y : Double
viewport_height : Double
overscan : Double
visible_start : Int
visible_end : Int
block_count : Int
rendered_block_count : Int
// Visual lines clipped off the top of `visible_start`'s block. The document
// below starts at that line, and `top_padding` already accounts for the
// height above it, so consumers place content correctly without reading
// this. It is exposed for assertions and diagnostics.
clip_lines : Int
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentRichTextWindow with Debug::{to_repr}
///|
pub extend MarkdownDocumentRichTextWindow with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentRichTextWindow with ToJson::{to_json}
///|
pub(all) struct MarkdownDocumentSession {
// The document text: per-block segments with a lazily materialized
// whole-source view (`source`). Edits mutate the store in place; every
// session value derived from an edit shares it.
text_store : MarkdownDocumentTextStore
source_length : Int
source_digest : UInt64
// Stable identity for the lifetime of one open document. Unlike
// `source_digest`, this survives incremental edits so measured block
// heights remain valid for blocks whose content did not change.
document_identity : Int
blocks : Array[MarkdownDocumentBlock]
editor_blocks : Array[MarkdownEditorBlock]
line_count : Int
word_count : Int
reading_minutes : Int
active_heading : String
metadata_title : String
metadata_tags : Array[String]
outline : Array[MarkdownEditorOutlineItem]
revision : Int
parse_count : Int
next_block_id : Int
dirty_range : MarkdownDocumentDirtyRange?
height_index : MarkdownDocumentHeightIndex
layout_cache : MarkdownDocumentLayoutCache?
} derive(Eq, Debug, ToJson)
///|
pub extend MarkdownDocumentSession with Debug::{to_repr}
///|
pub extend MarkdownDocumentSession with Eq::{not_equal, equal}
///|
pub extend MarkdownDocumentSession with ToJson::{to_json}
///|
/// Process-local identity allocator for document-scoped layout caches.
let markdown_document_next_identity : Ref[Int] = Ref(1)
///|
fn markdown_document_fresh_identity() -> Int {
let identity = markdown_document_next_identity.val
markdown_document_next_identity.val = identity + 1
identity
}
///|
pub fn MarkdownEditTransaction::replace_all(
previous_source : String,
source : String,
caret? : Int = markdown_editor_text_length(source),
selection? : @core.TextRange? = None,
reason? : MarkdownEditReason = ReplaceEdit,
) -> MarkdownEditTransaction {
MarkdownEditTransaction::from_sources(
previous_source,
source,
caret~,
selection~,
reason~,
)
}
///|
pub fn MarkdownEditTransaction::from_sources(
previous_source : String,
source : String,
caret? : Int = markdown_editor_text_length(source),
selection? : @core.TextRange? = None,
reason? : MarkdownEditReason = TextInput,
) -> MarkdownEditTransaction {
let previous_length = markdown_editor_text_length(previous_source)
let source_length = markdown_editor_text_length(source)
let (prefix, previous_prefix_offset, source_prefix_offset) = markdown_document_common_prefix(
previous_source, source, previous_length, source_length,
)
let suffix = markdown_document_common_suffix_length(
previous_source, source, previous_length, source_length, prefix,
)
let previous_suffix = previous_length - suffix
let next_suffix = source_length - suffix
{
previous_source,
source,
edit_range: { start: prefix, end: previous_suffix, },
replacement: markdown_document_source_substring_after_prefix(
source, source_length, prefix, next_suffix, source_prefix_offset,
),
deleted: markdown_document_source_substring_after_prefix(
previous_source, previous_length, prefix, previous_suffix, previous_prefix_offset,
),
caret,
selection,
reason,
}
}
///|
fn markdown_document_common_prefix(
previous_source : String,
source : String,
previous_length : Int,
source_length : Int,
) -> (Int, Int, Int) {
let previous_utf16_length = previous_source.length()
let source_utf16_length = source.length()
let limit = markdown_editor_min_int(previous_length, source_length)
let mut index = 0
let mut previous_offset = 0
let mut source_offset = 0
while index < limit &&
previous_offset < previous_utf16_length &&
source_offset < source_utf16_length {
match
(
previous_source.get_char(previous_offset),
source.get_char(source_offset),
) {
(Some(previous_char), Some(source_char)) =>
if previous_char == source_char {
previous_offset = previous_offset + previous_char.utf16_len()
source_offset = source_offset + source_char.utf16_len()
index = index + 1
} else {
return (index, previous_offset, source_offset)
}
_ => return (index, previous_offset, source_offset)
}
}
(index, previous_offset, source_offset)
}
///|
fn markdown_document_common_suffix_length(
previous_source : String,
source : String,
previous_length : Int,
source_length : Int,
prefix : Int,
) -> Int {
let limit = markdown_editor_min_int(
previous_length - prefix,
source_length - prefix,
)
let previous_iter = previous_source.rev_iter()
let source_iter = source.rev_iter()
let mut suffix = 0
while suffix < limit {
match (previous_iter.next(), source_iter.next()) {
(Some(previous_char), Some(source_char)) =>
if previous_char == source_char {
suffix = suffix + 1
} else {
return suffix
}
_ => return suffix
}
}
suffix
}
///|
fn markdown_document_source_substring_after_prefix(
source : String,
source_length : Int,
start : Int,
end : Int,
start_offset : Int,
) -> String {
let source_utf16_length = source.length()
let end_offset = if end == source_length {
source_utf16_length
} else {
source
.offset_of_nth_char(
end - start,
start_offset~,
end_offset=source_utf16_length,
)
.unwrap_or(source_utf16_length)
}
source.exact_view(start=start_offset, end=end_offset).to_owned()
}
///|
/// Build a transaction from a recorded edit range without scanning both
/// documents. Malformed ranges (out of bounds, or a negative replacement
/// length) fall back to the O(doc) from_sources diff.
fn MarkdownEditTransaction::from_replacement_range(
previous_source : String,
source : String,
edit_range : MarkdownSourceRange,
caret~ : Int,
selection~ : @core.TextRange?,
reason~ : MarkdownEditReason,
) -> MarkdownEditTransaction {
let previous_length = markdown_editor_text_length(previous_source)
let source_length = markdown_editor_text_length(source)
let replacement_length = source_length -
edit_range.start -
(previous_length - edit_range.end)
if edit_range.start <= edit_range.end &&
edit_range.end <= previous_length &&
replacement_length >= 0 {
return MarkdownEditTransaction::from_edit(
previous_source,
source,
edit_range.start,
deleted=markdown_editor_substring(
previous_source,
edit_range.start,
edit_range.end,
),
replacement=markdown_editor_substring(
source,
edit_range.start,
edit_range.start + replacement_length,
),
caret~,
selection~,
reason~,
)
}
MarkdownEditTransaction::from_sources(
previous_source,
source,
caret~,
selection~,
reason~,
)
}
///|
pub fn MarkdownEditTransaction::from_edit_result(
previous_source : String,
result : MarkdownEditorEditResult,
reason? : MarkdownEditReason = CommandEdit,
) -> MarkdownEditTransaction {
match result.edit_range {
// The command recorded the exact range it replaced, so build the
// transaction directly instead of re-scanning both documents.
// Malformed ranges fall through to the from_sources diff below.
Some(range) =>
MarkdownEditTransaction::from_replacement_range(
previous_source,
result.source,
range,
caret=result.caret,
selection=result.selection,
reason~,
)
None =>
MarkdownEditTransaction::from_sources(
previous_source,
result.source,
caret=result.caret,
selection=result.selection,
reason~,
)
}
}
///|
pub fn MarkdownEditTransaction::from_input_transform(
previous_source : String,
transform : RichTextInputTransform,
reason? : MarkdownEditReason = TextInput,
) -> MarkdownEditTransaction {
match transform.edit_range {
// The transform chain recorded the exact range it replaced, so build
// the transaction directly instead of re-scanning both documents.
Some(range) =>
MarkdownEditTransaction::from_replacement_range(
previous_source,
transform.text,
range,
caret=transform.caret,
selection=transform.selection,
reason~,
)
None =>
MarkdownEditTransaction::from_sources(
previous_source,
transform.text,
caret=transform.caret,
selection=transform.selection,
reason~,
)
}
}
///|
/// Build a transaction directly from the committed edit range (the shape
/// `from_sources` recovers by scanning both documents). The ranges are
/// already grapheme-normalized by the text control.
pub fn MarkdownEditTransaction::from_edit(
previous_source : String,
source : String,
start : Int,
deleted~ : String,
replacement~ : String,
caret~ : Int,
selection? : @core.TextRange? = None,
reason? : MarkdownEditReason = TextInput,
) -> MarkdownEditTransaction {
{
previous_source,
source,
edit_range: { start, end: start + markdown_editor_text_length(deleted), },
replacement,
deleted,
caret,
selection,
reason,
}
}
///|
/// Seed the keyed definition cache for a freshly built session with the
/// single-line scanner. Leaving the cache empty charged the first edit
/// after load a whole-document markdown parse (~700 ms on a 5 MB fixture).
/// The scanner catches every single-line definition; the incremental apply
/// carries the set forward per keystroke. Takes the raw constructor source:
/// reading it back through `source()` would materialize the whole-text
/// view (a multi-megabyte join) on the open path.
fn markdown_document_session_seed_definitions(
source : String,
source_length~ : Int,
source_digest~ : UInt64,
) -> Unit {
markdown_reference_definitions_cache_store_keyed(
source_length~,
source_digest~,
markdown_reference_definitions_scan_only(source),
)
}
///|
pub fn MarkdownDocumentSession::new(source : String) -> MarkdownDocumentSession {
hot_path_full_parse_count.val = hot_path_full_parse_count.val + 1
// Opening a document runs the shell parse: one linear scan for block
// boundaries, kinds, and statistics, with no markdown parses. Content
// blocks start as shells and are re-parsed on demand by
// `markdown_editor_block_materialized` when their inline runs are needed
// (the visible window, caret geometry, export).
let session = markdown_document_session_from_blocks(
source,
parse_markdown_editor_blocks_shell(source),
previous=None,
revision=0,
parse_count=1,
dirty_range=None,
)
markdown_document_session_seed_definitions(
source,
source_length=session.source_length,
source_digest=session.source_digest,
)
session
}
///|
pub fn MarkdownDocumentSession::source(
self : MarkdownDocumentSession,
) -> String {
self.text_store.source()
}
///|
/// Every block with its inline runs in place. The whole-document
/// conversion paths (viewer prepare, snapshot export, IME command format)
/// need real runs for all blocks, so they resolve shells first; the parse
/// behind it is content-cached, so repeated calls only pay for blocks that
/// were never materialized before.
fn markdown_document_session_materialized_blocks(
session : MarkdownDocumentSession,
) -> Array[MarkdownEditorBlock] {
session.editor_blocks.map(fn(editor_block) {
markdown_editor_block_materialized(
session.source(),
source_length=session.source_length,
source_digest=session.source_digest,
editor_block,
)
})
}
///|
pub fn MarkdownDocumentSession::snapshot(
self : MarkdownDocumentSession,
base? : @core.FontSpec = @core.FontSpec::new(),
base_dir? : String? = None,
) -> MarkdownEditorSnapshot {
// Export-grade output: every block with its real inline runs.
let materialized = markdown_document_session_materialized_blocks(self)
{
source: self.source(),
blocks: materialized,
rich_text: markdown_editor_blocks_to_rich_text(
self.source(),
materialized,
base,
base_dir~,
),
line_count: self.line_count,
word_count: self.word_count,
reading_minutes: self.reading_minutes,
active_heading: self.active_heading,
metadata_title: self.metadata_title,
metadata_tags: self.metadata_tags,
outline: self.outline,
}
}
///|
/// An html block under the caret reveals its raw markup in place, like a code
/// block: the rendered gallery view (badges, image rows) hides the source the
/// caret is meant to reach, so the block collapses to its raw text while the
/// selection is inside it and flips back to the rendered view otherwise.
/// Source/content ranges stay attached, so typing in the raw view edits the
/// block's markup through the normal edit path.
fn markdown_editor_session_reveal_html_block_source(
source : String,
editor_block : MarkdownEditorBlock,
block : RichTextBlock,
selection : MarkdownEditorSelection,
base : @core.FontSpec,
) -> RichTextBlock {
let anchor = match markdown_editor_active_selection(selection.selection) {
Some(range) => range.start
None => selection.caret
}
// A freshly opened document parks an untouched caret at offset 0; without a
// user-placed selection, a first block that happens to be html must keep
// its rendered view instead of opening as raw text.
if anchor == 0 && selection.selection is None {
return block
}
if anchor < editor_block.source_range.start ||
anchor > editor_block.source_range.end {
return block
}
match editor_block.block.kind {
HtmlBlock => {
let raw = markdown_html_raw_block_rich_text(editor_block.block.text, base)
{
..raw,
runs: markdown_editor_runs_with_source_ranges(
raw.runs,
source,
editor_block,
),
source_range: block.source_range,
content_range: block.content_range,
}
}
_ => block
}
}
///|
pub fn MarkdownDocumentSession::rich_text(
self : MarkdownDocumentSession,
selection : MarkdownEditorSelection,
base? : @core.FontSpec = @core.FontSpec::new(),
source_mode? : Bool = false,
focus_mode? : Bool = false,
base_dir? : String? = None,
) -> RichTextDocument {
if source_mode {
markdown_editor_format_source_mode(self.source(), base)
} else {
let materialized = markdown_document_session_materialized_blocks(self)
let document = markdown_editor_blocks_to_rich_text(
self.source(),
materialized,
base,
base_dir~,
)
// The rendered html gallery hides the block source, so the block under
// the caret collapses to its raw markup in place, like a code block.
let blocks : Array[RichTextBlock] = []
for index, block in document.blocks {
blocks.push(
markdown_editor_session_reveal_html_block_source(
self.source(),
materialized[index],
block,
selection,
base,
),
)
}
let revealed = markdown_document_session_reveal_selection(
self.source(),
RichTextDocument::new(blocks~),
selection,
)
if focus_mode {
markdown_editor_dim_inactive_blocks(revealed, selection)
} else {
revealed
}
}
}
///|
pub fn MarkdownDocumentSession::rich_text_window(
self : MarkdownDocumentSession,
selection : MarkdownEditorSelection,
base? : @core.FontSpec = @core.FontSpec::new(),
source_mode? : Bool = false,
focus_mode? : Bool = false,
base_dir? : String? = None,
scroll_y? : Double = 0.0,
viewport_height? : Double = 0.0,
overscan? : Double = 320.0,
content_width? : Double = 0.0,
) -> MarkdownDocumentRichTextWindow {
let range = markdown_document_window_range(
self,
base,
source_mode~,
scroll_y~,
viewport_height~,
overscan~,
content_width~,
)
let (source_chars, source_char_offset) = if source_mode &&
range.visible_start >= 0 &&
range.visible_end >= range.visible_start {
let source_start = self.editor_blocks[range.visible_start].source_range.start
let source_end = self.editor_blocks[range.visible_end].source_range.end
(
markdown_document_source_chars_in_range(
self.source(),
self.source_length,
source_start,
source_end,
),
source_start,
)
} else {
([], 0)
}
// Line window: clip the leading lines of the first rendered block when it is
// taller than the viewport. `top_padding` absorbs their height, so
// `content_rect` places the remaining lines exactly where the full layout
// would have, and every consumer keeps working unchanged.
let line_step = base.size * 1.55
// DISABLED: the slice aborts the momark zen-mode editing test
// (editor_app_writing_modes_runtime_wbtest.mbt:222) deterministically. The
// helper, its tests and the clip decision below are in place; re-enable by
// removing the `|| true` once the abort is understood.
// DISABLED: with the window clipped to the visible band, a caret outside the
// band is no longer present in `window.document`, so
// `rich_text_document_caret_rect_at_source` misses and the estimate fallback
// paints the caret at the block's start — "scroll, then click, and the caret
// sits at the very front". Correctness first: the clip comes back once the
// caret path is windowed too (see the plan's 3b).
let (clip_lines, clip_last_line) = if source_mode {
(0, MARKDOWN_EDITOR_SLICE_TO_LAST_LINE)
} else {
markdown_document_clip_lines_for_window(
self,
base,
range.visible_start,
range.top_padding,
scroll_y,
viewport_height,
overscan,
)
}
let clip_height = clip_lines.to_double() * line_step
// Upper bound on the lines a block can show in the band. Used as the bottom
// cap for oversized blocks after the first one, whose distance from the band
// top is not known here.
let band_lines = if line_step > 0.0 {
((viewport_height + 2.0 * overscan) / line_step).to_int() + 2
} else {
MARKDOWN_EDITOR_SLICE_TO_LAST_LINE
}
let rich_blocks : Array[RichTextBlock] = []
// The document's line-start index turns each block slice into a binary
// search plus two lookups; without it a block that holds a whole novel
// is materialized and scanned per window build. Source mode never
// slices.
let line_index = if source_mode {
None
} else {
Some(markdown_document_session_line_index(self))
}
if range.visible_start >= 0 && range.visible_end >= range.visible_start {
for index in range.visible_start..<=range.visible_end {
// Slice the *shell* block, then materialise the slice. Doing it the
// other way round re-parsed the whole block — for an oversized block
// that is the entire document, which is the cost this window exists to
// avoid.
let shell = if source_mode {
self.editor_blocks[index]
} else if index == range.visible_start {
if clip_lines > 0 || clip_last_line < MARKDOWN_EDITOR_SLICE_TO_LAST_LINE {
markdown_editor_block_slice_to_source_lines(
self.source(),
self.editor_blocks[index],
clip_lines,
clip_last_line,
line_index~,
)
} else {
self.editor_blocks[index]
}
} else if markdown_document_block_needs_line_window(
self, base, index, viewport_height,
) {
markdown_editor_block_slice_to_source_lines(
self.source(),
self.editor_blocks[index],
0,
band_lines,
line_index~,
)
} else {
self.editor_blocks[index]
}
let editor_block = if source_mode {
shell
} else {
// Shell blocks from the cheap open parse carry no inline runs; the
// visible window is where they are needed, so re-parse on demand
// (a content-cached parse, near-free in steady state).
markdown_editor_block_materialized(
self.source(),
source_length=self.source_length,
source_digest=self.source_digest,
shell,
)
}
rich_blocks.push(
if source_mode {
markdown_document_source_mode_block_to_rich_text(
source_chars, source_char_offset, editor_block, base,
)
} else {
markdown_editor_session_reveal_html_block_source(
self.source(),
editor_block,
markdown_editor_block_to_rich_text(
self.source(),
editor_block,
base,
base_dir~,
),
selection,
base,
)
},
)
}
}
let document = RichTextDocument::new(blocks=rich_blocks)
let document = if source_mode {
document
} else {
let revealed = markdown_document_session_reveal_selection(
self.source(),
document,
selection,
)
let collapsed = markdown_editor_collapse_inactive_blank_gaps(
revealed, selection, base,
)
if focus_mode {
markdown_editor_dim_inactive_blocks(collapsed, selection)
} else {
collapsed
}
}
{
document,
content_height: range.content_height,
top_padding: range.top_padding + clip_height,
scroll_y,
viewport_height,
overscan,
visible_start: range.visible_start,
visible_end: range.visible_end,
block_count: self.editor_blocks.length(),
rendered_block_count: rich_blocks.length(),
clip_lines,
}
}
///|
pub fn MarkdownDocumentSession::estimated_content_height(
self : MarkdownDocumentSession,
base? : @core.FontSpec = @core.FontSpec::new(),
source_mode? : Bool = false,
content_width? : Double = 0.0,
) -> Double {
if source_mode {
self.height_index.source_content_units * base.size
} else {
match markdown_document_block_geometry(self, base, content_width~) {
Some(geometry) =>
markdown_document_scroll_geometry_prefix_at(
geometry,
count=geometry.heights.length(),
)
None => self.height_index.visual_content_units * base.size
}
}
}
///|
pub fn MarkdownDocumentSession::source_offset_y(
self : MarkdownDocumentSession,
source_offset : Int,
base? : @core.FontSpec = @core.FontSpec::new(),
source_mode? : Bool = false,
content_width? : Double = 0.0,
) -> Double {
if self.editor_blocks.length() == 0 {
return 0.0
}
if !source_mode {
match markdown_document_block_heights(self, base, content_width~) {
Some(heights) =>
return markdown_document_gallery_source_offset_y(
self, source_offset, heights,
)
None => ()
}
}
markdown_document_height_index_source_offset_y(
self.height_index,
source_offset,
base,
source_mode~,
)
}
///|
fn MarkdownDocumentRichTextWindow::content_rect(
self : MarkdownDocumentRichTextWindow,
content : @core.Rect,
) -> @core.Rect {
content.offset(dx=0.0, dy=self.top_padding - self.scroll_y)
}
///|
pub fn MarkdownDocumentSession::apply(
self : MarkdownDocumentSession,
transaction : MarkdownEditTransaction,
) -> MarkdownDocumentSession {
// Splicing `replacement` over `deleted` at one range yields the previous
// source exactly when the two strings are equal, so the O(doc) whole-source
// compare only has to run for the (rare) no-op re-commit case.
if transaction.replacement == transaction.deleted &&
transaction.source == self.source() {
return self
}
match markdown_document_session_apply_incremental(self, transaction) {
Some(next) => next
None => {
hot_path_full_parse_count.val = hot_path_full_parse_count.val + 1
markdown_document_line_index_invalidate()
let blocks = parse_markdown_editor_blocks(transaction.source)
let dirty = markdown_document_dirty_range(transaction, blocks)
let session = markdown_document_session_from_blocks(
transaction.source,
blocks,
previous=Some(self),
revision=self.revision + 1,
parse_count=self.parse_count + 1,
dirty_range=dirty,
)
// Keep the next keystroke off the definition-cache miss path.
markdown_document_session_seed_definitions(
transaction.source,
source_length=session.source_length,
source_digest=session.source_digest,
)
session
}
}
}
///|
/// Structure-preserving incremental apply: an edit (insert, delete,
/// replace) that stays strictly inside one line of a single paragraph
/// block and leaves that line ordinary paragraph text. The line-level
/// check below rejects exactly the edits a re-parse would answer with a
/// different block list (a line turned blank, a setext underline, a fence
/// or marker prefix), so the block list is reproduced by splicing the
/// text and shifting absolute ranges — no region re-parse, no window
/// decode. That is what keeps typing and backspacing responsive on a
/// document whose text collapses into one huge block, where both would
/// walk megabytes per keystroke. Returns None when the edit does not
/// qualify; the caller then re-parses the region as before.
fn markdown_document_session_apply_in_line_edit(
session : MarkdownDocumentSession,
transaction : MarkdownEditTransaction,
cluster : MarkdownDocumentEditCluster,
delta~ : Int,
) -> MarkdownDocumentSession? {
// Upstream newlines_safe already rejected edits touching a newline for
// non-fence clusters, so the edit provably stays on one line.
if transaction.replacement.contains("\n") ||
transaction.deleted.contains("\n") {
return None
}
if cluster.start_index != cluster.end_index {
return None
}
let editor_block = session.editor_blocks[cluster.start_index]
if !(editor_block.block.kind is Paragraph) {
return None
}
let edit_start = transaction.edit_range.start
let edit_end = transaction.edit_range.end
// The edit must land inside the block's own content.
if edit_start < editor_block.content_range.start ||
edit_end > editor_block.content_range.end {
return None
}
// Not at a line start: line starts decide classification (markers,
// setext underlines), and this path does not re-classify them. An O(1)
// read — a line start is the document start or the character right
// after a newline — with no dependence on the line-scan cache, which
// the region re-parse of a previous keystroke leaves holding a region
// slice, not this document.
let before = if edit_start == 0 {
Some('\n')
} else {
session.text_store.char_at(edit_start - 1)
}
if before is Some('\n') {
return None
}
// The splice below assumes the block text is the block's own source
// slice; paragraph blocks are built that way (shell and full parse
// alike). Anything else falls back to the re-parse.
if markdown_editor_text_length(editor_block.block.text) !=
editor_block.content_range.end - editor_block.content_range.start {
return None
}
// The edited line, post-edit, must still classify as ordinary paragraph
// text under the same cheap rules the parser uses; otherwise the
// re-parse would split or retag the block and only it may answer.
let index = markdown_document_session_line_index(session)
let source_length = session.source_length
let mut low = 0
let mut high = index.length() - 1
let mut line_k = 0
while low <= high {
let mid = low + (high - low) / 2
if index[mid] <= edit_start {
line_k = mid
low = mid + 1
} else {
high = mid - 1
}
}
let line_start = index[line_k]
let line_end = if line_k + 1 < index.length() {
index[line_k + 1] - 1
} else {
source_length
}
let pre_line = session.text_store.text_of(line_start, line_end)
let in_start = edit_start - line_start
let in_end = edit_end - line_start
let post_line = markdown_editor_substring(pre_line, 0, in_start) +
transaction.replacement +
markdown_editor_substring(
pre_line,
in_end,
markdown_editor_text_length(pre_line),
)
let post_view = post_line.exact_view(start=0, end=post_line.length())
if markdown_editor_line_text_is_blank(post_view) {
return None
}
if markdown_editor_shell_line_may_open_fence(post_view) &&
markdown_editor_opening_code_fence_marker(post_view) is Some(_) {
return None
}
if markdown_editor_shell_line_may_open_html(post_view) {
let probe : Array[MarkdownLine] = [
{ text: post_view, start: line_start, end: line_end, },
]
if markdown_editor_html_block_at(probe, 0) is Some(_) {
return None
}
}
if markdown_editor_setext_underline_level(post_view) is Some(_) {
return None
}
if !(markdown_editor_shell_structural_kind(post_view) is None) {
return None
}
let old_source_start = editor_block.source_range.start
let old_source_end = editor_block.source_range.end
let content_start = editor_block.content_range.start
let content_end = editor_block.content_range.end
// Advance the live text store first: every read below (probes, splices)
// sees the post-edit text, same contract as the region re-parse path.
session.text_store.edit(transaction.edit_range, transaction.replacement)
session.text_store.seed(transaction.source)
// Splice the text into the block and shift its absolute ranges. The
// block drops its inline runs (shell shape, matching the open path):
// the visible window re-parses whatever lines it renders.
let offset_in_text = edit_start - content_start
let edit_end_in_text = edit_end - content_start
let next_text = markdown_editor_substring(
editor_block.block.text,
0,
offset_in_text,
) +
transaction.replacement +
markdown_editor_substring(
editor_block.block.text,
edit_end_in_text,
markdown_editor_text_length(editor_block.block.text),
)
session.editor_blocks[cluster.start_index] = {
block: { ..editor_block.block, text: next_text, inlines: [], },
source_range: { start: old_source_start, end: old_source_end + delta, },
content_range: { start: content_start, end: content_end + delta, },
}
// Maintain the document line index in place: line starts after the edit
// shift by the delta (the edit cannot add or remove a line, and no line
// starts at the edit point).
let cached_index = markdown_document_line_index.val
if cached_index.0 == session.document_identity && delta != 0 {
let starts = cached_index.1
let mut low = 0
let mut high = starts.length()
while low < high {
let mid = low + (high - low) / 2
if starts[mid] <= edit_start {
low = mid + 1
} else {
high = mid
}
}
for i = low; i < starts.length(); i = i + 1 {
starts[i] = starts[i] + delta
}
}
// Shift the suffix blocks' shared range objects by the edit delta, in
// place, exactly like the region re-parse path: editor-block records,
// block records and height metrics all reference the same range
// objects, so one in-place shift keeps every view consistent. This must
// run before the dirty-range computation reads the post-edit tiling.
if delta != 0 {
for index = cluster.start_index + 1
index < session.editor_blocks.length()
index = index + 1 {
let suffix_block = session.editor_blocks[index]
suffix_block.source_range.start = suffix_block.source_range.start + delta
suffix_block.source_range.end = suffix_block.source_range.end + delta
suffix_block.content_range.start = suffix_block.content_range.start +
delta
suffix_block.content_range.end = suffix_block.content_range.end + delta
}
}
let window : MarkdownDocumentReindexWindow = {
previous_source_digest: session.source_digest,
previous_source_length: session.source_length,
prefix_count: cluster.start_index,
suffix_new_start: cluster.start_index + 1,
suffix_shift: 0,
old_region_start: old_source_start,
old_region_end: old_source_end,
edit_start,
deleted: transaction.deleted,
replacement: transaction.replacement,
char_delta: delta,
}
let dirty = markdown_document_dirty_range(transaction, session.editor_blocks)
// Same identity update the region re-parse path performs: the
// single-block window matches by position (id preserved, revision
// bumped), and the fingerprint is recomputed so the session keeps
// ending in exactly the state a from-scratch parse has.
let assigned = markdown_document_assign_blocks_reuse(
session.editor_blocks,
session,
window,
next_id=session.next_block_id,
dirty_range=dirty,
)
let word_count = markdown_document_word_count_after_edit(session, window)
let line_count = markdown_document_line_count_after_edit(
session,
transaction.source,
window,
)
let height_reuse_ok = session.height_index.blocks.length() ==
session.blocks.length()
let height_index = markdown_document_height_index(
transaction.source,
session.editor_blocks,
assigned.blocks,
previous=Some(session),
reuse=Some(window),
reuse_ok=height_reuse_ok,
dirty_range=dirty,
text_store=Some(session.text_store),
structure_preserving=true,
)
let outline = markdown_document_outline_after_edit(
session.editor_blocks,
session,
window,
)
let next_source_digest = markdown_document_edit_digest(
session.source_digest,
length=session.source_length + delta,
start=transaction.edit_range.start,
deleted="",
replacement=transaction.replacement,
)
// The upstream incremental-safety guard already proved this edit cannot
// change the definition set, so publish the previous set under the new
// (length, digest) key like the region re-parse path does.
markdown_reference_definitions_cache_store_keyed(
source_length=transaction.source.length(),
source_digest=next_source_digest,
markdown_reference_definitions_cached_keyed(
transaction.previous_source,
source_length=transaction.previous_source.length(),
source_digest=session.source_digest,
),
)
let revision = session.revision + 1
markdown_document_last_rebase.val = (revision, Some(window))
hot_path_in_line_apply_count.val = hot_path_in_line_apply_count.val + 1
Some({
text_store: session.text_store,
source_length: session.text_store.length(),
source_digest: next_source_digest,
document_identity: session.document_identity,
blocks: assigned.blocks,
editor_blocks: session.editor_blocks,
line_count,
word_count,
reading_minutes: markdown_editor_reading_minutes(word_count),
active_heading: session.active_heading,
metadata_title: session.metadata_title,
metadata_tags: session.metadata_tags,
outline,
revision,
parse_count: session.parse_count + 1,
next_block_id: assigned.next_id,
dirty_range: dirty,
height_index,
layout_cache: None,
})
}
///|
/// Incremental `apply`: re-parse only the blank-line-delimited block
/// cluster containing the edit, rebase the re-parsed region into new
/// source offsets, and shift the untouched suffix blocks by the edit
/// delta. Returns None when the full-document parse is required:
/// structural context markers (fences, `]:`, leading `---`), edits
/// carrying reference-definition characters (the definition set is a
/// whole-source property), and edits inside front matter. Edits that
/// touch newlines also force the full parse (they can merge or split
/// clusters) — with one fence-aware exception: a keystroke that stays
/// strictly inside a fenced code block (never touching its opener or
/// closer line, never typing fence characters) keeps the fence exactly
/// as bounded as it was, newlines included, so typing code re-parses
/// just the one fenced block.
fn markdown_document_session_apply_incremental(
session : MarkdownDocumentSession,
transaction : MarkdownEditTransaction,
) -> MarkdownDocumentSession? {
let deleted = session.text_store.text_of(
transaction.edit_range.start,
transaction.edit_range.end,
)
let blocks = session.editor_blocks
let edit_in_front_matter = blocks.length() > 0 &&
blocks[0].block.kind is FrontMatter &&
transaction.edit_range.start <= blocks[0].source_range.end
if blocks.length() == 0 ||
edit_in_front_matter ||
!markdown_document_transaction_incremental_safe(transaction, deleted) {
hot_path_incremental_fallback_count.val = hot_path_incremental_fallback_count.val +
1
return None
}
match markdown_document_edit_cluster(session.editor_blocks, transaction) {
Some(cluster) => {
// The cluster is re-parseable on its own only when its swallowable
// blocks (currently code fences) are entered strictly from the
// inside; that single decision also licenses newline edits, which
// a fence swallows.
let interior_fence_edit = cluster_interior_fence_edit(
session.editor_blocks,
cluster,
transaction,
deleted,
)
guard markdown_document_newlines_safe(
transaction,
deleted,
interior_fence_edit~,
) &&
markdown_document_region_can_stitch(
session.editor_blocks,
cluster,
interior_fence_edit~,
) else {
hot_path_incremental_fallback_count.val = hot_path_incremental_fallback_count.val +
1
return None
}
let delta = markdown_editor_text_length(transaction.replacement) -
markdown_editor_text_length(deleted)
// Structure-preserving fast path: a pure insertion strictly inside
// one line of a single paragraph block reproduces the region
// re-parse without walking the region — the keystroke path on
// documents whose text collapses into one huge block.
if deleted == "" {
match
markdown_document_session_apply_in_line_edit(
session,
transaction,
cluster,
delta~,
) {
Some(next) => return Some(next)
None => ()
}
}
let region_end = if cluster.end_index >=
session.editor_blocks.length() - 1 {
session.source_length
} else {
cluster.end_block_end
}
// The session text store is the document's live text: advance it in
// place once per apply, then adopt the string the control already
// materialized so the whole text stays a free read. Every read the
// bookkeeping needs from the old text (`deleted` above) happened
// before this point.
session.text_store.edit(transaction.edit_range, transaction.replacement)
session.text_store.seed(transaction.source)
let region_slice = session.text_store.text_of(
cluster.start_block_start,
markdown_editor_min_int(
region_end + delta,
session.source_length + delta,
),
)
// Definitions keyed by the previous session's (length, digest) pair:
// no multi-megabyte map-key hashing on the hot path.
let definitions = markdown_reference_definitions_cached_keyed(
transaction.previous_source,
source_length=transaction.previous_source.length(),
source_digest=session.source_digest,
)
// Shell parse for the re-parsed region, matching the open path's
// session shape: block boundaries are exact, inline runs materialize
// on demand (the visible window slices and parses its own lines;
// caret geometry materializes its neighborhood). Parsing the region
// with inlines made every keystroke inside a document whose text
// collapses into one block re-parse megabytes of runs — the typing
// lag on novel-shaped files.
let reparsed = parse_markdown_editor_blocks_with_definitions(
region_slice,
definitions=Some(definitions),
parse_inlines=false,
)
// In-place window splice on the shared editor-block table (same
// contract as the text store): the prefix keeps its records, the
// re-parsed region replaces its slice, and the suffix records move
// only when the region size changed while their absolute offsets
// shift by `delta`. No per-suffix-block record is allocated.
let editor_blocks = session.editor_blocks
let old_region_count = cluster.end_index - cluster.start_index + 1
let index_shift = reparsed.length() - old_region_count
markdown_document_shift_tail(
editor_blocks,
cluster.end_index + 1,
-index_shift,
)
for offset, block in reparsed {
editor_blocks[cluster.start_index + offset] = {
block: block.block,
source_range: {
start: block.source_range.start + cluster.start_block_start,
end: block.source_range.end + cluster.start_block_start,
},
content_range: {
start: block.content_range.start + cluster.start_block_start,
end: block.content_range.end + cluster.start_block_start,
},
}
}
if delta != 0 {
let suffix_start = cluster.start_index + reparsed.length()
for i = suffix_start; i < editor_blocks.length(); i = i + 1 {
let editor_block = editor_blocks[i]
// In-place int writes: this loop runs once per keystroke over the
// blocks after the edit, so a fresh range record per block here
// meant ~100k allocations per keystroke on the stress fixture.
editor_block.source_range.start = editor_block.source_range.start +
delta
editor_block.source_range.end = editor_block.source_range.end + delta
editor_block.content_range.start = editor_block.content_range.start +
delta
editor_block.content_range.end = editor_block.content_range.end +
delta
}
}
let stitched = editor_blocks
// The region re-parse can merge, split or retag lines; the line
// index must be rebuilt from the next source on demand.
markdown_document_line_index_invalidate()
hot_path_region_reparse_count.val = hot_path_region_reparse_count.val + 1
// The guard above proved this edit cannot change the whole-source
// definition set, so publish the previous set under the edited
// source's (length, digest) key; the next keystroke's lookup then
// hits instead of re-running the whole-document definition parse.
// The edited source's digest is computed once here and reused by the
// session construction below.
let next_source_digest = markdown_document_edit_digest(
session.source_digest,
length=session.source_length + delta,
start=transaction.edit_range.start,
deleted~,
replacement=transaction.replacement,
)
markdown_reference_definitions_cache_store_keyed(
source_length=transaction.source.length(),
source_digest=next_source_digest,
definitions,
)
let dirty = markdown_document_dirty_range(transaction, stitched)
let reindex : MarkdownDocumentReindexWindow = {
previous_source_digest: session.source_digest,
previous_source_length: session.source_length,
prefix_count: cluster.start_index,
suffix_new_start: cluster.start_index + reparsed.length(),
suffix_shift: cluster.end_index +
1 -
(cluster.start_index + reparsed.length()),
old_region_start: cluster.start_block_start,
old_region_end: region_end,
edit_start: transaction.edit_range.start,
deleted,
replacement: transaction.replacement,
char_delta: delta,
}
markdown_document_session_from_blocks(
transaction.source,
stitched,
previous=Some(session),
revision=session.revision + 1,
parse_count=session.parse_count + 1,
dirty_range=dirty,
reindex=Some(reindex),
source_digest=Some(next_source_digest),
text_store=Some(session.text_store),
)
|> Some
}
None => {
hot_path_incremental_fallback_count.val = hot_path_incremental_fallback_count.val +
1
None
}
}
}
///|
/// True when a transaction can be handled incrementally: no structural
/// context markers and no reference-definition characters (`[`, `]`, `:`)
/// that could change the whole-source definition set or a link span
/// inside a block. Newline safety is decided once the edit cluster is
/// known (`markdown_document_newlines_safe`), because a newline inside a
/// fenced block is structurally inert.
fn markdown_document_transaction_incremental_safe(
transaction : MarkdownEditTransaction,
deleted : String,
) -> Bool {
!markdown_document_transaction_needs_global_context(transaction) &&
!markdown_document_text_may_touch_reference_definition(
transaction.replacement,
) &&
!markdown_document_text_may_touch_reference_definition(deleted)
}
///|
/// The fence edit keeps the block's span iff it (a) types or deletes no
/// fence character, (b) starts strictly after the opener line — a change
/// on the opener can stop it opening a fence — and (c) stays strictly
/// before the closer line — typing on the closer stops it closing, which
/// would make the fence swallow the rest of the document. An unclosed
/// fence running to the end of the source has no closer line to protect,
/// so its last (interior) line is editable.
fn markdown_document_fence_edit_is_interior(
editor_block : MarkdownEditorBlock,
transaction : MarkdownEditTransaction,
deleted : String,
) -> Bool {
editor_block.block.kind is CodeBlock &&
!markdown_document_edit_text_has_fence_char(transaction.replacement) &&
!markdown_document_edit_text_has_fence_char(deleted) &&
markdown_document_fence_interior_bounds(editor_block, transaction)
}
///|
/// One forward pass over the fence block's source: remember the end of
/// the opener line (first newline at or after the block start) and the
/// start of the last line together with whether that last line is
/// fence-character-only. The edit range must live in
/// `[opener_end, closer_start)`; an unclosed fence that runs to the end
/// of the source (and whose last line is not fence-only) has no closer
/// line, so its bound is the block end itself.
fn markdown_document_fence_interior_bounds(
editor_block : MarkdownEditorBlock,
transaction : MarkdownEditTransaction,
) -> Bool {
let previous = transaction.previous_source
let previous_length = markdown_editor_text_length(previous)
let range = editor_block.source_range
let iterator = previous.iter()
let mut index = 0
let mut opener_end = -1
let mut last_line_start = range.start
let mut last_line_fence_only = true
let mut scanning = true
while scanning {
match iterator.next() {
Some(ch) => {
// A trailing newline may or may not belong to the block range;
// treat it as line terminator, never as line content, so the
// closer-line logic works for either parser convention.
if index == range.end - 1 && ch == '\n' {
} else if ch == '\n' {
if index >= range.start && opener_end < 0 {
opener_end = index + 1
}
if index >= range.start && index < range.end {
last_line_start = index + 1
last_line_fence_only = true
}
} else if index >= range.start &&
index < range.end &&
!(ch == ' ' || ch == '\t' || ch == '`' || ch == '~') {
last_line_fence_only = false
}
index = index + 1
if index >= range.end && opener_end >= 0 {
scanning = false
}
}
None => scanning = false
}
}
if opener_end < 0 {
opener_end = range.end
}
let closed_fence = range.end < previous_length || last_line_fence_only
let closer_start = if closed_fence { last_line_start } else { range.end }
let end_ok = if closed_fence {
transaction.edit_range.end < closer_start
} else {
// Unclosed fence: the document tail is still interior code, so an
// edit ending exactly at the block end is fine too.
transaction.edit_range.end <= closer_start
}
transaction.edit_range.start >= opener_end && end_ok
}
///|
fn markdown_document_edit_text_has_fence_char(text : String) -> Bool {
text.contains("`") || text.contains("~")
}
///|
/// Edits touching newlines can merge or split blank-line clusters and
/// are only safe inside a fenced code block, which swallows them.
fn markdown_document_newlines_safe(
transaction : MarkdownEditTransaction,
deleted : String,
interior_fence_edit~ : Bool,
) -> Bool {
interior_fence_edit ||
!(transaction.replacement.contains("\n") || deleted.contains("\n"))
}
///|
/// True when every swallowable block in the cluster (fences, HTML,
/// tables, footnote definitions) keeps its span across the edit: re-
/// parsing the cluster then reproduces the suffix boundary exactly. Only
/// code fences admit this, and only for strictly-interior edits (already
/// decided once as `interior_fence_edit`); HTML blocks, tables, and
/// footnote definitions always force the full-parse fallback.
fn markdown_document_region_can_stitch(
blocks : Array[MarkdownEditorBlock],
cluster : MarkdownDocumentEditCluster,
interior_fence_edit~ : Bool,
) -> Bool {
for index in cluster.start_index..<=cluster.end_index {
match blocks[index].block.kind {
CodeBlock if interior_fence_edit => ()
CodeBlock | HtmlBlock | Table | FootnoteDefinition(_) => return false
_ => ()
}
}
true
}
///|
/// True when the whole cluster is a single code fence and the edit stays
/// strictly in its interior (never on the opener or closer line, no
/// fence characters typed or deleted). The fence then keeps exactly the
/// span it had, newlines included.
fn cluster_interior_fence_edit(
blocks : Array[MarkdownEditorBlock],
cluster : MarkdownDocumentEditCluster,
transaction : MarkdownEditTransaction,
deleted : String,
) -> Bool {
cluster.start_index == cluster.end_index &&
markdown_document_fence_edit_is_interior(
blocks[cluster.start_index],
transaction,
deleted,
)
}
///|
fn markdown_document_text_may_touch_reference_definition(text : String) -> Bool {
text.contains("[") || text.contains("]") || text.contains(":")
}
///|
priv struct MarkdownDocumentEditCluster {
start_index : Int
end_index : Int
start_block_start : Int
end_block_end : Int
}
///|
/// The exact block reindexing an incremental apply produced: new blocks
/// `[0, prefix_count)` are content-identical to the previous blocks at the
/// same index, new blocks `[suffix_new_start, n)` are content-identical to
/// previous blocks at `index + suffix_shift`, and the window between them
/// is the re-parsed region (previous-source coordinates
/// `[old_region_start, old_region_end)`). Derived bookkeeping — block
/// fingerprints, height metrics, outline, word/line counts — may reuse the
/// previous session's results outside the window and recompute only inside
/// it. `edit_start`/`deleted`/`replacement`/`char_delta` describe the
/// character edit itself, which the count deltas need.
priv struct MarkdownDocumentReindexWindow {
previous_source_digest : UInt64
previous_source_length : Int
prefix_count : Int
suffix_new_start : Int
suffix_shift : Int
old_region_start : Int
old_region_end : Int
edit_start : Int
deleted : String
replacement : String
char_delta : Int
}
///|
/// Validate a reindex window against the sessions it claims to describe.
/// Bookkeeping reuse falls back to full computation whenever this fails.
fn markdown_document_reindex_window_fits(
window : MarkdownDocumentReindexWindow,
previous : MarkdownDocumentSession,
new_block_count : Int,
) -> Bool {
let suffix_old_start = window.suffix_new_start + window.suffix_shift
let old_window_count = suffix_old_start - window.prefix_count
window.prefix_count >= 0 &&
window.prefix_count <= window.suffix_new_start &&
window.suffix_new_start <= new_block_count &&
old_window_count >= 1 &&
suffix_old_start <= previous.blocks.length() &&
suffix_old_start + (new_block_count - window.suffix_new_start) ==
previous.blocks.length() &&
previous.height_index.blocks.length() == previous.blocks.length() &&
window.old_region_start <= window.old_region_end
}
///|
/// In-place tail move shared by the incremental-apply tables: every
/// element at index >= `from` moves to `index - shift`, so the live
/// length becomes `length - shift`. Only references move — records are
/// neither copied nor reallocated, which is what keeps a keystroke on a
/// 100k-block document from allocating a fresh table per pass.
fn[T] markdown_document_shift_tail(
array : Array[T],
from : Int,
shift : Int,
) -> Unit {
let length = array.length()
if shift == 0 {
return
}
if shift > 0 {
// Shrinking: move left (destination is always behind the source), then
// drop the tail.
for i = from; i < length; i = i + 1 {
array[i - shift] = array[i]
}
for _ in 0..= from; i = i - 1 {
array[i - shift] = array[i]
}
}
}
///|
/// The reindex window of the most recent session construction, keyed by
/// the session revision it produced. The scroll geometry reads it to
/// rebase its live entry in place after an incremental edit; any other
/// revision (or a full rebuild) falls back to the full block walk. The
/// UI runs single-threaded, so a module-level ref mirrors how the other
/// process-local caches work.
let markdown_document_last_rebase : Ref[(Int, MarkdownDocumentReindexWindow?)] = Ref(
(0, None),
)
///|
/// Char offset of every line start for the current document, keyed by the
/// session's document identity. The visible-window slice locates a block's
/// line range here by binary search instead of rescanning a multi-megabyte
/// block per paint (a novel-shaped document made every window build —
/// every frame — materialize and walk the whole block). The
/// structure-preserving apply maintains the array in place (one offset
/// shift); every other apply invalidates it, and the next query rebuilds
/// it from one line scan — free on the open path, whose merged scan is
/// still cached.
let markdown_document_line_index : Ref[(Int, Array[Int])] = Ref((-1, []))
///|
fn markdown_document_line_index_invalidate() -> Unit {
markdown_document_line_index.val = (-1, [])
}
///|
fn markdown_document_session_line_index(
session : MarkdownDocumentSession,
) -> Array[Int] {
let cached = markdown_document_line_index.val
if cached.0 == session.document_identity && cached.1.length() > 0 {
return cached.1
}
let scanned = markdown_editor_line_scan_cached_lines(session.source())
let starts : Array[Int] = []
for line in scanned {
starts.push(line.start)
}
if starts.length() == 0 {
starts.push(0)
}
markdown_document_line_index.val = (session.document_identity, starts)
starts
}
///|
/// The blank-line-delimited block cluster containing the edit. Blank-line
/// blocks are hard parser boundaries — no setext underline, table, list,
/// indented code block, or soft-wrapped paragraph continues across one,
/// and fences/definitions that could span them are excluded earlier — so
/// re-parsing just this cluster reproduces exactly what the full parse
/// produces for the same source range. A blank block is its own cluster.
fn markdown_document_edit_cluster(
blocks : Array[MarkdownEditorBlock],
transaction : MarkdownEditTransaction,
) -> MarkdownDocumentEditCluster? {
let edit_start = transaction.edit_range.start
let edit_end = markdown_editor_max_int(transaction.edit_range.end, edit_start)
let mut start_index = blocks.length() - 1
for index, block in blocks {
if block.source_range.end >= edit_start {
start_index = index
break
}
}
let mut end_index = start_index
for index in start_index..= edit_end {
break
}
}
// Editing inside a blank block can turn the empty line into paragraph
// content (or a marker line), which lazily joins the clusters on both
// sides of it. Grow through this blank block so the region always
// covers the fused result; for edits inside a non-blank block the
// blank neighbors are hard boundaries and stop the growth.
let mut grow_start = start_index
while grow_start > 0 &&
!markdown_document_editor_block_is_blank(blocks[grow_start - 1]) {
grow_start = grow_start - 1
}
if markdown_document_editor_block_is_blank(blocks[start_index]) {
if grow_start > 0 {
grow_start = grow_start - 1
}
while grow_start > 0 &&
!markdown_document_editor_block_is_blank(blocks[grow_start - 1]) {
grow_start = grow_start - 1
}
}
let mut grow_end = end_index
while grow_end + 1 < blocks.length() &&
!markdown_document_editor_block_is_blank(blocks[grow_end + 1]) {
grow_end = grow_end + 1
}
if markdown_document_editor_block_is_blank(blocks[start_index]) {
if grow_end + 1 < blocks.length() {
grow_end = grow_end + 1
}
while grow_end + 1 < blocks.length() &&
!markdown_document_editor_block_is_blank(blocks[grow_end + 1]) {
grow_end = grow_end + 1
}
}
Some({
start_index: grow_start,
end_index: grow_end,
start_block_start: blocks[grow_start].source_range.start,
end_block_end: blocks[grow_end].source_range.end,
})
}
///|
/// A blank-line block: the empty paragraph the parser produces for each
/// whitespace-only source line. Its range is zero-width at the line
/// start and its cluster role is always a boundary.
fn markdown_document_editor_block_is_blank(block : MarkdownEditorBlock) -> Bool {
block.block.text == "" &&
block.block.inlines.is_empty() &&
block.block.kind is Paragraph &&
block.source_range.start == block.source_range.end
}
///|
pub fn MarkdownDocumentSession::replace_source(
self : MarkdownDocumentSession,
source : String,
reason? : MarkdownEditReason = ReplaceEdit,
) -> MarkdownDocumentSession {
ignore(reason)
if source == self.source() {
return self
}
// A whole-source replacement (document open, undo/redo, source import)
// is a fresh document: build it with the shell parse instead of
// re-parsing every block eagerly. Shell blocks materialize on demand
// exactly like a freshly opened document.
hot_path_full_parse_count.val = hot_path_full_parse_count.val + 1
markdown_document_line_index_invalidate()
let session = markdown_document_session_from_blocks(
source,
parse_markdown_editor_blocks_shell(source),
previous=None,
revision=self.revision + 1,
parse_count=self.parse_count + 1,
dirty_range=None,
)
markdown_document_session_seed_definitions(
source,
source_length=session.source_length,
source_digest=session.source_digest,
)
session
}
///|
pub fn MarkdownDocumentSession::with_layout_cache(
self : MarkdownDocumentSession,
document : RichTextDocument,
base : @core.FontSpec,
width~ : Double,
scroll_y~ : Double,
viewport_height~ : Double,
overscan? : Double = 320.0,
text_system? : @core.TextSystem = @core.TextSystem::fallback(),
) -> MarkdownDocumentSession {
{
..self,
layout_cache: Some(
MarkdownDocumentLayoutCache::new(
self,
document,
base,
text_system~,
width~,
scroll_y~,
viewport_height~,
overscan~,
),
),
}
}
///|
fn MarkdownDocumentLayoutCache::new(
session : MarkdownDocumentSession,
document : RichTextDocument,
base : @core.FontSpec,
width~ : Double,
scroll_y~ : Double,
viewport_height~ : Double,
overscan? : Double = 320.0,
text_system? : @core.TextSystem = @core.TextSystem::fallback(),
) -> MarkdownDocumentLayoutCache {
let layout_blocks : Array[MarkdownDocumentLayoutBlock] = []
let mut top = 0.0
let visible_min = scroll_y - overscan
let visible_max = scroll_y + viewport_height + overscan
let mut visible_start = -1
let mut visible_end = -1
for index, block in document.blocks {
let height = rich_text_block_total_height(
block,
base,
text_system,
block_width=width,
)
let bottom = top + height
let session_block = if index < session.blocks.length() {
session.blocks[index]
} else {
{
id: index,
revision: 0,
source_range: { start: 0, end: 0, },
content_range: { start: 0, end: 0, },
kind: Paragraph,
text: "",
fingerprint: 0UL,
inline_count: 0,
}
}
if bottom >= visible_min && top <= visible_max {
if visible_start < 0 {
visible_start = index
}
visible_end = index
}
layout_blocks.push({
block_id: session_block.id,
block_index: index,
revision: session_block.revision,
top,
height,
source_range: session_block.source_range,
})
top = bottom
}
{
revision: session.revision,
width,
scroll_y,
viewport_height,
overscan,
content_height: top,
visible_start,
visible_end,
blocks: layout_blocks,
}
}
///|
priv struct MarkdownDocumentWindowRange {
visible_start : Int
visible_end : Int
// Height of everything above the first rendered line. It folds in the
// visual lines clipped from the top of `visible_start`'s block, so
// `content_rect` already places the window correctly and no consumer needs
// a separate offset.
top_padding : Double
content_height : Double
}
///|
fn markdown_document_window_range(
session : MarkdownDocumentSession,
base : @core.FontSpec,
source_mode~ : Bool,
scroll_y~ : Double,
viewport_height~ : Double,
overscan~ : Double,
content_width~ : Double,
) -> MarkdownDocumentWindowRange {
if !source_mode {
match markdown_document_block_geometry(session, base, content_width~) {
Some(geometry) =>
return markdown_document_window_range_for_geometry(
geometry,
scroll_y~,
viewport_height~,
overscan~,
)
None => ()
}
}
let index = session.height_index
if index.blocks.length() == 0 {
return {
visible_start: -1,
visible_end: -1,
top_padding: 0.0,
content_height: 0.0,
}
}
let content_units = markdown_document_height_index_content_units(
index,
source_mode~,
)
let content_height = content_units * base.size
if viewport_height <= 0.0 {
return {
visible_start: 0,
visible_end: index.blocks.length() - 1,
top_padding: 0.0,
content_height,
}
}
let visible_min_units = max_double(0.0, (scroll_y - overscan) / base.size)
let visible_max_units = min_double(
content_units,
(scroll_y + viewport_height + overscan) / base.size,
)
let visible_start = markdown_document_height_index_block_at_units(
index,
visible_min_units,
source_mode~,
)
let visible_end = markdown_document_height_index_block_at_units(
index,
visible_max_units,
source_mode~,
)
let top_padding = markdown_document_height_metric_top_units(
index.blocks[visible_start],
source_mode~,
) *
base.size
{ visible_start, visible_end, top_padding, content_height, }
}
///|
/// A block has to be this many viewports tall before the line window engages.
/// Below it the block-granular range already keeps the window small, and the
/// ordinary path stays bit-for-bit what it was.
const MARKDOWN_EDITOR_LINE_WINDOW_THRESHOLD : Double = 4.0
///|
/// Whether a block is tall enough that the block-granular range cannot bound
/// it. Only soft-wrapped paragraph bodies qualify: their lines are uniform,
/// which is what makes a line count and a line step meaningful.
fn markdown_document_block_needs_line_window(
session : MarkdownDocumentSession,
base : @core.FontSpec,
index : Int,
viewport_height : Double,
) -> Bool {
if index < 0 || index >= session.editor_blocks.length() {
return false
}
if !(session.editor_blocks[index].block.kind is Paragraph) {
return false
}
match session.height_index.blocks.get(index) {
Some(metric) =>
metric.visual_height_units * base.size >
MARKDOWN_EDITOR_LINE_WINDOW_THRESHOLD * viewport_height
None => false
}
}
///|
/// How many visual lines to clip off the top of the first rendered block.
///
/// The block-granular range can never cull a block taller than the viewport,
/// and a document with no blank lines is exactly that: one paragraph holding
/// every line. Without this the "visible blocks" window is the whole document
/// and every per-line stage runs over all of it.
///
/// Only the height index is available before the band is measured, so the line
/// step is estimated from the font — the same `size * 1.55` convention
/// `markdown_document_block_geometry` falls back to — and a screenful of lines
/// is left unclipped above the band to absorb the estimate. The measured
/// heights feed back through the existing geometry path, so the estimate only
/// has to be good enough for one frame.
fn markdown_document_clip_lines_for_window(
session : MarkdownDocumentSession,
base : @core.FontSpec,
visible_start : Int,
block_top : Double,
scroll_y : Double,
viewport_height : Double,
overscan : Double,
) -> (Int, Int) {
let whole = (0, MARKDOWN_EDITOR_SLICE_TO_LAST_LINE)
if visible_start < 0 ||
visible_start >= session.editor_blocks.length() ||
viewport_height <= 0.0 {
return whole
}
// Only soft-wrapped paragraph bodies: their lines are uniform, which is what
// makes a line count and a line step meaningful. Tables, code blocks and
// galleries keep the whole-block path.
if !(session.editor_blocks[visible_start].block.kind is Paragraph) {
return whole
}
if !markdown_document_block_needs_line_window(
session, base, visible_start, viewport_height,
) {
return whole
}
let line_step = base.size * 1.55
if line_step <= 0.0 {
return whole
}
// A screenful of lines is left unclipped on each side so a wrong estimate
// cannot expose a gap above or below the band. The measured heights feed
// back through the geometry path, so the estimate only has to hold for one
// frame.
let slack = (viewport_height / line_step).to_int() + 1
let above = ((scroll_y - overscan - block_top) / line_step).to_int()
let first_line = if above <= slack { 0 } else { above - slack }
let last_line = first_line + (2.0 * overscan / line_step).to_int() + slack * 2
(first_line, last_line)
}
///|
/// The height index is intentionally source-only for cheap scrolling. A
/// responsive HTML gallery is the narrow exception: its height depends on the
/// current content width, so calculate just those blocks from the rich-text
/// model and retain indexed estimates for every other block.
fn markdown_document_gallery_block_heights(
session : MarkdownDocumentSession,
base : @core.FontSpec,
content_width : Double,
) -> Array[Double]? {
if content_width <= 0.0 {
return None
}
let heights : Array[Double] = []
let mut found_gallery = false
for index, editor_block in session.editor_blocks {
let estimated = if index < session.height_index.blocks.length() {
session.height_index.blocks[index].visual_height_units * base.size
} else {
base.size * 1.55
}
if editor_block.block.kind is HtmlBlock &&
markdown_block_view_project(editor_block.block, base, base_dir=None)
is Some(_) {
let rich_block = markdown_block_to_rich_text(
editor_block.block,
base,
base_dir=None,
)
heights.push(
rich_text_block_total_height(
rich_block,
base,
@core.TextSystem::fallback(),
block_width=content_width,
),
)
found_gallery = true
} else {
heights.push(estimated)
}
}
if found_gallery {
Some(heights)
} else {
None
}
}
///|
/// Per-block pixel heights for scroll geometry. Prefers measured paint
/// geometry (recorded by the session editor once a block has been
/// rendered at this width and font), falls back to the gallery
/// rich-text computation, then to the parse-time estimate. Returns None
/// only when no block carries geometry better than the unit estimates,
/// which keeps the pure unit-index path for unrendered documents.
fn markdown_document_block_heights(
session : MarkdownDocumentSession,
base : @core.FontSpec,
content_width~ : Double,
) -> Array[Double]? {
match markdown_document_block_geometry(session, base, content_width~) {
Some(geometry) => Some(geometry.heights)
None => None
}
}
///|
/// Persisted scroll geometry for the session at a laid out content width.
/// The live entry is shared and mutable: an exact identity match performs
/// no block walk at all; an incremental edit rebases the entry in place
/// (window splice + point updates or one O(n) Fenwick rebuild); only a
/// cold or unrebasable miss walks every block once. Measured heights
/// landing afterwards are applied through the O(log n) point update, so
/// scrolling stays walk-free. Zero content width never has measured or
/// gallery geometry, so it skips the walk entirely and lets callers fall
/// back to the parse-time index.
fn markdown_document_block_geometry(
session : MarkdownDocumentSession,
base : @core.FontSpec,
content_width~ : Double,
) -> MarkdownDocumentScrollGeometry? {
if content_width <= 0.0 {
return None
}
match markdown_document_scroll_geometry_get(base, width=content_width) {
Some(geometry) => {
if geometry.source_digest == session.source_digest &&
geometry.source_length == session.source_length {
hot_path_scroll_geometry_cache_hits.val = hot_path_scroll_geometry_cache_hits.val +
1
// An empty entry is only a negative cache while no measured height
// exists. The first paint records measured blocks after this query;
// let that next query build the real geometry instead of returning
// None forever and publishing the same compensation on every frame.
if geometry.heights.is_empty() && rich_text_block_heights_count.val == 0 {
return None
}
if !geometry.heights.is_empty() {
return Some(geometry)
}
}
if markdown_document_scroll_geometry_rebase(
geometry,
session,
base,
content_width~,
) {
hot_path_scroll_geometry_rebase_hits.val = hot_path_scroll_geometry_rebase_hits.val +
1
return Some(geometry)
}
}
None => ()
}
let gallery = markdown_document_gallery_block_heights(
session, base, content_width,
)
let height_seed = rich_text_block_height_digest_seed(
base,
width=content_width,
source_digest=Some(session.document_identity.to_uint64()),
)
let heights : Array[Double] = []
let mut has_measured = false
for index, _editor_block in session.editor_blocks {
hot_path_block_height_walk_blocks.val = hot_path_block_height_walk_blocks.val +
1
// The measured-height table keys on block identity (id, revision),
// which the session's block assignment keeps in lockstep with content.
// session.blocks is parallel to session.editor_blocks.
let identity = session.blocks.get(index)
match
rich_text_block_measured_height(
base,
width=content_width,
seed=height_seed,
block_id=match identity {
Some(session_block) => session_block.id
None => index
},
block_revision=match identity {
Some(session_block) => session_block.revision
None => 0
},
source_digest=Some(session.document_identity.to_uint64()),
) {
Some(height) => {
has_measured = true
heights.push(height)
}
None =>
match gallery {
Some(gallery) => heights.push(gallery[index])
None =>
heights.push(
session.height_index.blocks
.get(index)
.map(metric => metric.visual_height_units * base.size)
.unwrap_or(base.size * 1.55),
)
}
}
}
let epoch = rich_text_block_heights_epoch.val
if !has_measured && gallery is None {
// Cache the “no measured geometry yet” verdict too, so wheels before
// the first measured paint stay O(1) until a height actually lands.
markdown_document_scroll_geometry_put(
base,
markdown_document_scroll_geometry_from_heights(
[],
epoch~,
source_digest=session.source_digest,
source_length=session.source_length,
),
width=content_width,
)
return None
}
let geometry = markdown_document_scroll_geometry_from_heights(
heights,
epoch~,
source_digest=session.source_digest,
source_length=session.source_length,
)
markdown_document_scroll_geometry_put(base, geometry, width=content_width)
Some(geometry)
}
///|
/// Rebase the live scroll geometry entry in place from the previous
/// document state to `session`: splice the re-parse window out of/into
/// the height array, recompute only the window heights (identity changed
/// there), and point-update the Fenwick tree — or rebuild it once when
/// the block count changed. Returns false when the transition cannot be
/// described (no reindex window, count mismatch, gallery geometry, stale
/// epoch), leaving the entry untouched for the full walk.
fn markdown_document_scroll_geometry_rebase(
geometry : MarkdownDocumentScrollGeometry,
session : MarkdownDocumentSession,
base : @core.FontSpec,
content_width~ : Double,
) -> Bool {
let (last_revision, last_window) = markdown_document_last_rebase.val
if last_revision != session.revision {
return false
}
let window = match last_window {
Some(window) => window
None => return false
}
if geometry.heights.is_empty() {
return false
}
if geometry.source_digest != window.previous_source_digest ||
geometry.source_length != window.previous_source_length {
return false
}
let new_count = session.editor_blocks.length()
if geometry.heights.length() != new_count + window.suffix_shift {
return false
}
// Gallery blocks carry measured image geometry that the window recompute
// below does not reproduce; take the full walk in that case.
if markdown_document_session_has_gallery_blocks(session, window) {
return false
}
let suffix_old_start = window.suffix_new_start + window.suffix_shift
let heights = geometry.heights
if window.suffix_shift != 0 {
markdown_document_shift_tail(heights, suffix_old_start, window.suffix_shift)
geometry.tree = markdown_document_scroll_geometry_build_tree(heights)
}
let height_seed = rich_text_block_height_digest_seed(
base,
width=content_width,
source_digest=Some(session.document_identity.to_uint64()),
)
for index = window.prefix_count
index < window.suffix_new_start
index = index + 1 {
let identity = session.blocks.get(index)
let height = match
rich_text_block_measured_height(
base,
width=content_width,
seed=height_seed,
block_id=match identity {
Some(session_block) => session_block.id
None => index
},
block_revision=match identity {
Some(session_block) => session_block.revision
None => 0
},
source_digest=Some(session.document_identity.to_uint64()),
) {
Some(height) => height
None =>
session.height_index.blocks
.get(index)
.map(metric => metric.visual_height_units * base.size)
.unwrap_or(base.size * 1.55)
}
// Route every recomputed height through the point update so the Fenwick
// tree stays in lockstep with `heights`. When the block count changed we
// rebuilt the tree above from the *shifted* array, whose window slots
// still hold stale values; writing `heights[index]` directly here would
// fix the array but leave the tree behind, so `prefix_at` (which feeds
// `top_padding` and `content_height`) would disagree with the rendered
// block stack — the scroll misalignment this guards against.
markdown_document_scroll_geometry_set_height(geometry, index~, height~)
}
geometry.source_digest = session.source_digest
geometry.source_length = session.source_length
true
}
///|
/// Whether any block inside the re-parse window is an HtmlBlock, whose
/// height may need the gallery pipeline rather than the plain estimate.
/// Conservative: any window HtmlBlock takes the full walk.
fn markdown_document_session_has_gallery_blocks(
session : MarkdownDocumentSession,
window : MarkdownDocumentReindexWindow,
) -> Bool {
for index = window.prefix_count
index < window.suffix_new_start
index = index + 1 {
match session.blocks.get(index) {
Some(session_block) => if session_block.kind is HtmlBlock { return true }
None => return true
}
}
false
}
///|
/// Feed one freshly measured block height into the live scroll geometry
/// with an O(log n) Fenwick point update, replacing the previous
/// epoch-invalidate-and-rebuild-everything path. Falls back to dropping
/// the cached entry (so the next query rebuilds once) when the entry is
/// missing, stale against the epoch, or the “no measured geometry yet”
/// verdict.
fn markdown_document_apply_measured_height(
session : MarkdownDocumentSession,
base : @core.FontSpec,
block_index~ : Int,
width~ : Double,
height~ : Double,
) -> Unit {
if width <= 0.0 || height <= 0.0 {
return
}
let key = markdown_document_scroll_geometry_key(base, width~)
match markdown_document_scroll_geometry_cache.get(key) {
Some(geometry) =>
if geometry.source_digest == session.source_digest &&
geometry.source_length == session.source_length &&
geometry.epoch == rich_text_block_heights_epoch.val &&
geometry.heights.length() == session.editor_blocks.length() {
hot_path_scroll_geometry_point_updates.val = hot_path_scroll_geometry_point_updates.val +
1
markdown_document_scroll_geometry_set_height(
geometry,
index=block_index,
height~,
)
}
None => ()
}
}
///|
/// Read the currently committed geometry slot without rebuilding geometry.
fn markdown_document_block_height_at(
session : MarkdownDocumentSession,
base : @core.FontSpec,
content_width~ : Double,
index : Int,
) -> Double {
match
markdown_document_scroll_geometry_cache.get(
markdown_document_scroll_geometry_key(base, width=content_width),
) {
Some(geometry) if geometry.source_digest == session.source_digest &&
geometry.source_length == session.source_length &&
geometry.epoch == rich_text_block_heights_epoch.val &&
index >= 0 &&
index < geometry.heights.length() => geometry.heights[index]
_ =>
session.height_index.blocks
.get(index)
.map(metric => metric.visual_height_units * base.size)
.unwrap_or(base.size * 1.55)
}
}
///|
fn markdown_document_anchor_index(
session : MarkdownDocumentSession,
base : @core.FontSpec,
content_width~ : Double,
offset~ : Double,
) -> Int {
match markdown_document_block_geometry(session, base, content_width~) {
Some(geometry) => {
let mut low = 0
let mut high = geometry.heights.length()
while low < high {
let middle = low + (high - low) / 2
if markdown_document_scroll_geometry_prefix_at(
geometry,
count=middle + 1,
) <=
offset {
low = middle + 1
} else {
high = middle
}
}
low.min(geometry.heights.length())
}
None => {
// Before the first measured paint, the cache intentionally contains an
// empty negative entry. Use the parse-time unit estimates to identify
// the stable top item; returning zero here would suppress compensation
// for every measured block above the viewport on the first paint.
let mut top = 0.0
for index, metric in session.height_index.blocks {
let height = metric.visual_height_units * base.size
if top + height > offset {
return index
}
top = top + height
}
session.height_index.blocks.length()
}
}
}
///|
/// Window lookup over persisted prefix sums: the total is `prefix[n]` and
/// both edges are binary searches, so a wheel step is O(log n) in blocks.
fn markdown_document_window_range_for_geometry(
geometry : MarkdownDocumentScrollGeometry,
scroll_y~ : Double,
viewport_height~ : Double,
overscan~ : Double,
) -> MarkdownDocumentWindowRange {
let count = geometry.heights.length()
if count == 0 {
return {
visible_start: -1,
visible_end: -1,
top_padding: 0.0,
content_height: 0.0,
}
}
let content_height = markdown_document_scroll_geometry_prefix_at(
geometry,
count~,
)
if viewport_height <= 0.0 {
return {
visible_start: 0,
visible_end: count - 1,
top_padding: 0.0,
content_height,
}
}
let visible_min = max_double(0.0, scroll_y - overscan)
let visible_max = min_double(
content_height,
scroll_y + viewport_height + overscan,
)
// First block whose bottom edge (prefix at `index + 1`) reaches the
// visible band; heights are non-negative, so prefix is monotone.
let mut low = 0
let mut high = count
while low < high {
let mid = low + (high - low) / 2
let bottom = markdown_document_scroll_geometry_prefix_at(
geometry,
count=mid + 1,
)
if bottom >= visible_min {
high = mid
} else {
low = mid + 1
}
}
let visible_start = if low >= count { count - 1 } else { low }
// Last block whose top edge (prefix at `index`) is still inside the band.
let mut low = 0
let mut high = count
while low < high {
let mid = low + (high - low) / 2
let top = markdown_document_scroll_geometry_prefix_at(geometry, count=mid)
if top <= visible_max {
low = mid + 1
} else {
high = mid
}
}
let visible_end = if low - 1 < visible_start {
visible_start
} else {
low - 1
}
{
visible_start,
visible_end,
top_padding: markdown_document_scroll_geometry_prefix_at(
geometry,
count=visible_start,
),
content_height,
}
}
///|
fn markdown_document_gallery_source_offset_y(
session : MarkdownDocumentSession,
source_offset : Int,
heights : Array[Double],
) -> Double {
let mut top = 0.0
for index, block in session.editor_blocks {
if source_offset <= block.source_range.end {
return top
}
match heights.get(index) {
Some(height) => top = top + height
None => ()
}
}
top
}
///|
fn markdown_document_height_index_content_units(
index : MarkdownDocumentHeightIndex,
source_mode~ : Bool,
) -> Double {
if source_mode {
index.source_content_units
} else {
index.visual_content_units
}
}
///|
fn markdown_document_height_index(
source : String,
editor_blocks : Array[MarkdownEditorBlock],
blocks : Array[MarkdownDocumentBlock],
previous? : MarkdownDocumentSession? = None,
reuse? : MarkdownDocumentReindexWindow? = None,
// Whether the previous height index can be updated in place. Callers
// must capture this before the in-place block assignment runs: the
// apply mutates `previous.blocks` to the new count first, so a length
// comparison here would always miss.
reuse_ok? : Bool = false,
// Precomputed per-block newline counts from the open path's merged
// scan; when present the full height index skips its own source scan.
full_line_counts? : Array[Int]? = None,
// The apply's dirty block window; its ±1 revision edge decides whether
// the in-place reuse path can skip the suffix top re-accumulation.
dirty_range? : MarkdownDocumentDirtyRange? = None,
// The live store advanced by the incremental apply, when present: the
// window decode reads through it (bounded by the window) instead of
// scanning the whole source for char offsets.
text_store? : MarkdownDocumentTextStore? = None,
// The edit provably kept every block boundary and line count (a pure
// insertion strictly inside one line), so the window's heights equal the
// previous metrics and the window decode can be skipped entirely — the
// per-keystroke cost on a document that is one huge block.
structure_preserving? : Bool = false,
) -> MarkdownDocumentHeightIndex {
match (previous, reuse) {
(Some(prev), Some(window)) if reuse_ok =>
markdown_document_height_index_reuse(
source,
editor_blocks,
blocks,
prev,
window,
dirty_range~,
text_store~,
structure_preserving~,
)
_ =>
markdown_document_height_index_full(
source,
editor_blocks,
blocks,
line_counts=full_line_counts,
)
}
}
///|
/// Full height index: decodes the whole source and measures every block —
/// the session-creation and full-parse cost. `line_counts` skips the
/// per-block newline scan when the caller already produced them.
fn markdown_document_height_index_full(
source : String,
editor_blocks : Array[MarkdownEditorBlock],
blocks : Array[MarkdownDocumentBlock],
line_counts? : Array[Int]? = None,
) -> MarkdownDocumentHeightIndex {
let counts = match line_counts {
Some(counts) => counts
None => markdown_document_block_source_line_counts(source, editor_blocks)
}
let metrics : Array[MarkdownDocumentBlockHeightMetric] = []
let mut visual_top_units = 0.0
let mut source_top_units = 0.0
for index, editor_block in editor_blocks {
let session_block = blocks[index]
let visual_height_units = markdown_document_estimated_editor_block_height_units(
editor_block,
)
let source_height_units = counts[index].to_double() * 1.42
metrics.push({
block_id: session_block.id,
block_index: index,
revision: session_block.revision,
source_range: editor_block.source_range,
visual_top_units,
visual_height_units,
source_top_units,
source_height_units,
})
visual_top_units = visual_top_units + visual_height_units
source_top_units = source_top_units + source_height_units
}
{
visual_content_units: visual_top_units,
source_content_units: source_top_units,
blocks: metrics,
}
}
///|
/// Newline count per block source range, in one forward pass over the
/// source (line count = newlines + 1, matching
/// `markdown_document_source_range_line_count`). The old path materialized
/// the whole document as a char array just to count newlines per range.
/// Block ranges tile the source in parse order; zero-width blank blocks
/// own no characters and keep the base count of 1.
fn markdown_document_block_source_line_counts(
source : String,
editor_blocks : Array[MarkdownEditorBlock],
) -> Array[Int] {
let counts : Array[Int] = []
for _ in editor_blocks {
counts.push(1)
}
if markdown_editor_text_length(source) == 0 {
return counts
}
let mut block_index = 0
let mut char_index = 0
for ch in source {
while block_index < editor_blocks.length() &&
char_index >= editor_blocks[block_index].source_range.end {
block_index = block_index + 1
}
if block_index >= editor_blocks.length() {
break
}
if ch == '\n' && char_index >= editor_blocks[block_index].source_range.start {
counts[block_index] = counts[block_index] + 1
}
char_index = char_index + 1
}
counts
}
///|
/// One forward scan over the source producing BOTH the whole-source
/// content digest and the per-block newline counts — the document-open
/// path used to pay two separate full-source scans for these. The digest
/// is bit-identical to `rich_text_digest_string(seed, source)`.
fn markdown_document_source_digest_and_line_counts(
source : String,
editor_blocks : Array[MarkdownEditorBlock],
) -> (UInt64, Array[Int]) {
let counts : Array[Int] = []
for _ in editor_blocks {
counts.push(1)
}
let digest = match markdown_editor_line_scan_cached_digest(source) {
Some(digest) => digest
None =>
rich_text_digest_string(
rich_text_digest_mix(0xcbf29ce484222325UL, source.length().to_uint64()),
source,
)
}
// Per-block newline counts from line structure: every line but the last
// terminates in a '\n' at `line.end`, and shell-parse blocks are
// line-aligned, so a two-pointer walk over (newline positions x block
// ranges) reproduces the merged char-scan's `start <= p < end` counts
// without touching the source again.
let block_count = editor_blocks.length()
if block_count > 0 {
let lines = markdown_editor_line_scan_cached_lines(source)
let mut block_index = 0
let last_newline_index = lines.length() - 1
for line_index in 0..= editor_blocks[block_index].source_range.end {
block_index = block_index + 1
}
if block_index < block_count &&
position >= editor_blocks[block_index].source_range.start {
counts[block_index] = counts[block_index] + 1
}
}
}
(digest, counts)
}
///|
/// Height index for an incremental apply: block heights depend only on
/// block content, so the unchanged prefix and suffix keep their previous
/// per-block height units and only the re-parse window is measured (its
/// source is decoded as a slice instead of the whole document). Operates
/// in place on `previous.height_index.blocks` (shared by reference): the
/// suffix records move only when the region size changed, their ranges
/// re-read from the already-shifted editor blocks, and the tops are
/// re-accumulated from the prefix base in one double-addition pass, so
/// the result is numerically identical to a full rebuild without
/// allocating a metric per block.
fn markdown_document_height_index_reuse(
source : String,
editor_blocks : Array[MarkdownEditorBlock],
blocks : Array[MarkdownDocumentBlock],
previous : MarkdownDocumentSession,
window : MarkdownDocumentReindexWindow,
dirty_range? : MarkdownDocumentDirtyRange? = None,
text_store? : MarkdownDocumentTextStore? = None,
structure_preserving? : Bool = false,
) -> MarkdownDocumentHeightIndex {
let metrics = previous.height_index.blocks
let window_open = window.suffix_new_start > window.prefix_count
// A structure-preserving edit keeps every line count, so the single
// window block's heights are the previous metric's. The window decode
// (a store read of the whole block span plus a newline walk) is what
// made every keystroke inside a novel-shaped document O(document).
let reuse_window_heights = structure_preserving &&
window.suffix_shift == 0 &&
window_open &&
window.suffix_new_start - window.prefix_count == 1 &&
window.prefix_count < metrics.length()
let span_start = if window_open {
editor_blocks[window.prefix_count].source_range.start
} else {
0
}
let span_end = if window_open {
editor_blocks[window.suffix_new_start - 1].source_range.end
} else {
0
}
let window_chars : Array[Char] = if reuse_window_heights {
[]
} else {
match text_store {
Some(store) =>
// Bounded window read through the store: the full-source
// char-offset walk (`offset_of_nth_char`) costs O(document) twice.
store.text_of(span_start, span_end).to_array()
None =>
markdown_document_source_chars_in_range(
source,
markdown_editor_text_length(source),
span_start,
span_end,
)
}
}
// Window metrics first: fresh records (window-sized) carrying the
// recomputed heights; tops are filled by the running pass below.
let window_records : Array[MarkdownDocumentBlockHeightMetric] = []
// Typing inside existing lines leaves every height identical and the
// block count untouched, so the O(suffix) top re-accumulation below can
// be skipped entirely — the dominant keystroke case on large documents.
let mut heights_changed = false
for index = window.prefix_count
index < window.suffix_new_start
index = index + 1 {
let editor_block = editor_blocks[index]
hot_path_height_metrics_recomputed.val = hot_path_height_metrics_recomputed.val +
1
let session_block = blocks[index]
let previous_metric = if index < metrics.length() {
Some(metrics[index])
} else {
// The edit grew the block table past the previous height index; the
// new records count as changed.
None
}
let (visual_height_units, source_height_units) = if reuse_window_heights {
let metric = previous_metric.unwrap()
(metric.visual_height_units, metric.source_height_units)
} else {
let source_height_units = markdown_document_source_range_line_count(
window_chars,
{
start: editor_block.source_range.start - span_start,
end: editor_block.source_range.end - span_start,
},
).to_double() *
1.42
let visual_height_units = markdown_document_estimated_editor_block_height_units(
editor_block,
)
(visual_height_units, source_height_units)
}
if !heights_changed &&
(
previous_metric is None ||
({
let metric = previous_metric.unwrap()
metric.source_height_units != source_height_units ||
metric.visual_height_units != visual_height_units ||
metric.block_id != session_block.id
})
) {
heights_changed = true
}
window_records.push({
block_id: session_block.id,
block_index: index,
revision: session_block.revision,
source_range: editor_block.source_range,
visual_top_units: 0.0,
visual_height_units,
source_top_units: 0.0,
source_height_units,
})
}
// Move the suffix metrics into place, then refresh their identity and
// offsets while re-accumulating the tops from the unchanged prefix base.
markdown_document_shift_tail(
metrics,
window.suffix_new_start + window.suffix_shift,
window.suffix_shift,
)
for offset, record in window_records {
metrics[window.prefix_count + offset] = record
}
if !heights_changed && window.suffix_shift == 0 {
// Heights inside the window are unchanged and the block count did not
// move, so the suffix keeps its accumulated tops. The fresh window
// records still need their tops filled from the prefix base — an
// O(window) walk instead of the O(suffix) re-accumulation.
let prefix_base = if window.prefix_count > 0 {
let last = metrics[window.prefix_count - 1]
(
last.visual_top_units + last.visual_height_units,
last.source_top_units + last.source_height_units,
)
} else {
(0.0, 0.0)
}
let mut visual_top_units = prefix_base.0
let mut source_top_units = prefix_base.1
for index = window.prefix_count
index < window.suffix_new_start
index = index + 1 {
let metric = metrics[index]
metric.visual_top_units = visual_top_units
metric.source_top_units = source_top_units
visual_top_units = visual_top_units + metric.visual_height_units
source_top_units = source_top_units + metric.source_height_units
}
// Refresh only the suffix metrics whose blocks the dirty ±1 edge
// re-stamped (at most one block on each window edge).
let dirty_start = match dirty_range {
Some(range) => range.start_block
None => metrics.length()
}
let dirty_end = match dirty_range {
Some(range) => range.end_block
None => -1
}
let refresh_start = if dirty_start > window.suffix_new_start {
dirty_start
} else {
window.suffix_new_start
}
let refresh_end = if dirty_end < metrics.length() - 1 {
dirty_end
} else {
metrics.length() - 1
}
for index = refresh_start; index <= refresh_end; index = index + 1 {
metrics[index].revision = blocks[index].revision
}
let last = metrics[metrics.length() - 1]
{
visual_content_units: last.visual_top_units + last.visual_height_units,
source_content_units: last.source_top_units + last.source_height_units,
blocks: metrics,
}
} else {
let prefix_base = if window.prefix_count > 0 {
let last = metrics[window.prefix_count - 1]
(
last.visual_top_units + last.visual_height_units,
last.source_top_units + last.source_height_units,
)
} else {
(0.0, 0.0)
}
let mut visual_top_units = prefix_base.0
let mut source_top_units = prefix_base.1
for index = window.prefix_count; index < metrics.length(); index = index + 1 {
let metric = metrics[index]
metric.block_index = index
metric.block_id = blocks[index].id
metric.revision = blocks[index].revision
metric.source_range = editor_blocks[index].source_range
metric.visual_top_units = visual_top_units
metric.source_top_units = source_top_units
visual_top_units = visual_top_units + metric.visual_height_units
source_top_units = source_top_units + metric.source_height_units
}
{
visual_content_units: visual_top_units,
source_content_units: source_top_units,
blocks: metrics,
}
}
}
///|
fn markdown_document_height_index_block_at_units(
index : MarkdownDocumentHeightIndex,
y_units : Double,
source_mode~ : Bool,
) -> Int {
let count = index.blocks.length()
if count == 0 {
return -1
}
if y_units <= 0.0 {
return 0
}
let mut low = 0
let mut high = count - 1
let mut answer = count - 1
while low <= high {
let mid = (low + high) / 2
let metric = index.blocks[mid]
let bottom = markdown_document_height_metric_top_units(metric, source_mode~) +
markdown_document_height_metric_height_units(metric, source_mode~)
if bottom >= y_units {
answer = mid
high = mid - 1
} else {
low = mid + 1
}
}
answer
}
///|
fn markdown_document_height_index_source_offset_y(
index : MarkdownDocumentHeightIndex,
source_offset : Int,
base : @core.FontSpec,
source_mode~ : Bool,
) -> Double {
let count = index.blocks.length()
if count == 0 {
return 0.0
}
let mut low = 0
let mut high = count - 1
let mut answer = count
while low <= high {
let mid = (low + high) / 2
let range = index.blocks[mid].source_range
if source_offset <= range.end {
answer = mid
high = mid - 1
} else {
low = mid + 1
}
}
if answer >= count {
markdown_document_height_index_content_units(index, source_mode~) *
base.size
} else {
markdown_document_height_metric_top_units(
index.blocks[answer],
source_mode~,
) *
base.size
}
}
///|
fn markdown_document_height_metric_top_units(
metric : MarkdownDocumentBlockHeightMetric,
source_mode~ : Bool,
) -> Double {
if source_mode {
metric.source_top_units
} else {
metric.visual_top_units
}
}
///|
fn markdown_document_height_metric_height_units(
metric : MarkdownDocumentBlockHeightMetric,
source_mode~ : Bool,
) -> Double {
if source_mode {
metric.source_height_units
} else {
metric.visual_height_units
}
}
///|
fn markdown_document_estimated_editor_block_height_units(
editor_block : MarkdownEditorBlock,
) -> Double {
// A blank source line renders as the paragraph separator when the caret is
// not on it (see `markdown_editor_collapse_inactive_blank_gaps`), not as a
// full text line, so the parse-time estimate uses the separator margin. The
// alternation that turns every second blank line into a visible empty line
// is deliberately not modelled here: the estimate only has to keep the
// scroll geometry close and every blank line positive. A zero estimate
// would be a real bug — the visible-block window is derived from these
// units, and a block whose bottom is flush with the block before it can
// never be selected by that search, so a zero-height block at the end of
// the document would drop out of the window and take the caret's line with
// it. Measured paint heights replace the estimate once a block is rendered.
if markdown_document_editor_block_is_blank(editor_block) {
return markdown_editor_blank_gap_line_height_scale()
}
let line_count = markdown_document_estimated_visual_line_count(editor_block)
let line_height = markdown_document_estimated_line_height_units(
editor_block.block.kind,
)
let inset_units = markdown_document_estimated_inset_units(
editor_block.block.kind,
)
let mut height_units = line_height * line_count.to_double()
if editor_block.block.kind is Table {
height_units = max_double(height_units, 1.9 * line_count.to_double())
}
if markdown_document_block_has_image(editor_block.block) {
height_units = max_double(height_units, 4.5)
}
height_units + inset_units.top + inset_units.bottom
}
///|
fn markdown_document_estimated_visual_line_count(
editor_block : MarkdownEditorBlock,
) -> Int {
let text_lines = markdown_editor_max_int(
1,
1 + markdown_document_newline_count(editor_block.block.text),
)
match editor_block.block.kind {
CodeBlock =>
match editor_block.block.code_language {
Some(_) => text_lines + 1
None => text_lines
}
_ => text_lines
}
}
///|
fn markdown_document_estimated_line_height_units(
kind : MarkdownBlockKind,
) -> Double {
match kind {
Heading(level) => markdown_heading_line_height_scale(level)
CodeBlock => 1.1
HorizontalRule => 1.0
Table => 1.35
FootnoteDefinition(_) => 1.45
HtmlBlock | FrontMatter => 1.25
Paragraph
| UnorderedListItem
| TaskListItem(_)
| OrderedListItem(_)
| Blockquote => 1.55
}
}
///|
fn markdown_document_estimated_inset_units(
kind : MarkdownBlockKind,
) -> @core.Insets {
match kind {
Heading(_) => { top: 0.6, right: 0.0, bottom: 0.3, left: 0.0, }
Blockquote => { top: 0.43, right: 0.0, bottom: 0.86, left: 1.0, }
CodeBlock => { top: 0.86, right: 1.0, bottom: 0.86, left: 1.0, }
Table => { top: 0.43, right: 0.71, bottom: 0.43, left: 0.71, }
FootnoteDefinition(_) =>
{ top: 0.14, right: 0.0, bottom: 0.14, left: 0.86, }
HtmlBlock => { top: 0.29, right: 0.71, bottom: 0.29, left: 0.71, }
FrontMatter => { top: 0.43, right: 0.71, bottom: 0.43, left: 0.71, }
Paragraph => { top: 0.0, right: 0.0, bottom: 0.4, left: 0.0, }
UnorderedListItem | TaskListItem(_) | OrderedListItem(_) | HorizontalRule =>
@core.Insets::symmetric()
}
}
///|
///|
/// Image blocks get a taller estimate. Shell blocks (no inline runs yet)
/// are covered by the raw-text `![` probe; materialized blocks keep the
/// exact inline check. This feeds the parse-time height estimate only —
/// measured heights replace it once a block is rendered.
fn markdown_document_block_has_image(block : MarkdownBlock) -> Bool {
block.inlines.any(inline => inline.kind is Image) ||
markdown_text_contains_image_marker(block.text)
}
///|
fn markdown_text_contains_image_marker(text : String) -> Bool {
text.contains("![")
}
///|
fn markdown_document_newline_count(text : String) -> Int {
let mut count = 0
for ch in text {
if ch == '\n' {
count = count + 1
}
}
count
}
///|
fn markdown_document_source_range_line_count(
source_chars : Array[Char],
range : MarkdownSourceRange,
) -> Int {
if source_chars.length() == 0 {
return 1
}
let start = markdown_editor_clamp_int(range.start, 0, source_chars.length())
let end = markdown_editor_clamp_int(range.end, start, source_chars.length())
let mut lines = 1
for index in start.. Array[Char] {
let start = markdown_editor_clamp_int(start, 0, char_length)
let end = markdown_editor_clamp_int(end, start, char_length)
let source_length = source.length()
let start_offset = if start == char_length {
source_length
} else {
source.offset_of_nth_char(start).unwrap_or(source_length)
}
let end_offset = if end == char_length {
source_length
} else {
source.offset_of_nth_char(end).unwrap_or(source_length)
}
source.exact_view(start=start_offset, end=end_offset).to_array()
}
///|
fn markdown_document_source_mode_block_to_rich_text(
source_chars : Array[Char],
source_char_offset : Int,
editor_block : MarkdownEditorBlock,
base : @core.FontSpec,
) -> RichTextBlock {
let source_length = source_chars.length()
let start = markdown_editor_clamp_int(
editor_block.source_range.start - source_char_offset,
0,
source_length,
)
let end = markdown_editor_clamp_int(
editor_block.source_range.end - source_char_offset,
start,
source_length,
)
markdown_document_source_mode_block_to_rich_text_with_cache(
String::from_array(source_chars[start:end]),
editor_block,
base,
build=(editor_block, base) => {
markdown_document_source_mode_block_to_rich_text_uncached(
source_chars, source_char_offset, editor_block, base,
)
},
)
}
///|
fn markdown_document_source_mode_block_to_rich_text_uncached(
source_chars : Array[Char],
source_char_offset : Int,
editor_block : MarkdownEditorBlock,
base : @core.FontSpec,
) -> RichTextBlock {
let source_length = source_chars.length()
let start = markdown_editor_clamp_int(
editor_block.source_range.start - source_char_offset,
0,
source_length,
)
let end = markdown_editor_clamp_int(
editor_block.source_range.end - source_char_offset,
start,
source_length,
)
let text = String::from_array(source_chars[start:end])
let range = markdown_core_range(editor_block.source_range)
let font = {
..base,
families: @core.FontFamilyStack::monospace(),
size: base.size * 0.92,
}
RichTextBlock::new(
runs=[RichTextRun::new(text~, font=Some(font), source_range=Some(range))],
font=Some(font),
source_range=Some(range),
content_range=Some(range),
line_height=Some(base.size * 1.42),
)
}
///|
fn markdown_document_session_from_blocks(
source : String,
editor_blocks : Array[MarkdownEditorBlock],
previous~ : MarkdownDocumentSession?,
revision~ : Int,
parse_count~ : Int,
dirty_range~ : MarkdownDocumentDirtyRange?,
reindex? : MarkdownDocumentReindexWindow? = None,
// Precomputed content digest for this source; callers that already hold
// one (the incremental apply) skip the second whole-source hash.
source_digest? : UInt64? = None,
// The live document store already advanced by the incremental apply.
// When present the session adopts it; otherwise one is seeded from
// `source`, segmented per block on large documents so local text reads
// never scan the whole document.
text_store? : MarkdownDocumentTextStore? = None,
) -> MarkdownDocumentSession {
// Only trust the reindex window when it describes the sessions exactly;
// every derived bookkeeping step falls back to its full computation
// otherwise. The height-index decision is captured before the in-place
// block assignment: the apply mutates `previous.blocks` to the new
// count first, so a later length comparison would always miss.
let reuse = match (previous, reindex) {
(Some(prev), Some(window)) if markdown_document_reindex_window_fits(
window,
prev,
editor_blocks.length(),
) => Some(window)
_ => None
}
let height_reuse_ok = match (previous, reuse) {
(Some(prev), Some(_)) =>
prev.height_index.blocks.length() == prev.blocks.length()
_ => false
}
// NOTE: no blocks-cache publish here. The hot block-context queries now
// read the session's blocks directly (`MarkdownEditorInputContext`);
// seeding the 5MB-keyed parse cache on every keystroke was pure
// overhead. Command-path queries still memoize through
// `parse_markdown_editor_blocks_cached`.
let word_count = match (previous, reuse) {
(Some(prev), Some(window)) =>
markdown_document_word_count_after_edit(prev, window)
_ => markdown_editor_word_count(source)
}
let line_count = match (previous, reuse) {
(Some(prev), Some(window)) =>
markdown_document_line_count_after_edit(prev, source, window)
_ => markdown_editor_line_count(source)
}
let metadata = markdown_editor_front_matter_metadata(editor_blocks)
let next_block_id_seed = match previous {
Some(prev) => prev.next_block_id
None => 1
}
// Full rebuilds (no chained digest) pay one merged scan for the content
// digest and the height-index newline counts; the incremental path
// chains the digest instead and reuses the previous height metrics.
let merged_scan : (UInt64, Array[Int])? = if source_digest is None &&
reuse is None {
Some(markdown_document_source_digest_and_line_counts(source, editor_blocks))
} else {
None
}
let assigned = markdown_document_assign_blocks(
editor_blocks,
previous~,
next_id=next_block_id_seed,
dirty_range~,
reuse~,
)
let height_index = markdown_document_height_index(
source,
editor_blocks,
assigned.blocks,
previous~,
reuse~,
reuse_ok=height_reuse_ok,
full_line_counts=merged_scan.map(scan => scan.1),
dirty_range~,
// The incremental apply already advanced the live store: forward it so
// the reuse path decodes only the re-parse window instead of walking
// the whole document for `markdown_editor_text_length` and char
// offsets.
text_store~,
)
let outline = match (previous, reuse) {
(Some(prev), Some(window)) =>
markdown_document_outline_after_edit(editor_blocks, prev, window)
_ => markdown_editor_outline(editor_blocks)
}
// The store seeds the whole-source view from `source`: every builder
// here already holds the materialized text (document open, full
// rebuild, or a transaction carrying the next text), so seeding costs
// no copy. Documents with many blocks get a store segmented at block
// starts — one linear pass at build time that keeps every later local
// read (word probes, region slices) bounded by one segment instead of
// the document. The stride groups ~64 blocks per segment: local reads
// still scan at most a few KB, while the open path builds thousands of
// segments instead of one per block (the 200k-segment copy pass and the
// per-keystroke tail-offset shift were measurable on stress documents).
let text_store = match text_store {
Some(store) => store
None if editor_blocks.length() > 32 => {
let boundaries : Array[Int] = []
let stride = 64
for index, block in editor_blocks {
if index > 0 && index % stride == 0 {
boundaries.push(block.source_range.start)
}
}
MarkdownDocumentTextStore::segmented(source, boundaries)
}
None => MarkdownDocumentTextStore::new(source)
}
// Publish the reindex window of this session so the scroll geometry can
// rebase its live entry in place on the next query. Full rebuilds
// publish `None` (no rebase possible).
markdown_document_last_rebase.val = (
revision,
match reuse {
Some(window) => Some(window)
None => None
},
)
{
text_store,
source_length: text_store.length(),
source_digest: match source_digest {
Some(digest) => digest
None =>
match merged_scan {
Some(scan) => scan.0
None => markdown_document_source_digest(source)
}
},
document_identity: match previous {
Some(prev) => prev.document_identity
None => markdown_document_fresh_identity()
},
blocks: assigned.blocks,
editor_blocks,
line_count,
word_count,
reading_minutes: markdown_editor_reading_minutes(word_count),
active_heading: markdown_editor_active_heading(editor_blocks),
metadata_title: metadata.title,
metadata_tags: metadata.tags,
outline,
revision,
parse_count,
next_block_id: assigned.next_id,
dirty_range,
height_index,
layout_cache: None,
}
}
///|
priv struct MarkdownDocumentBlockAssignment {
blocks : Array[MarkdownDocumentBlock]
next_id : Int
}
///|
fn markdown_document_assign_blocks(
editor_blocks : Array[MarkdownEditorBlock],
previous~ : MarkdownDocumentSession?,
next_id~ : Int,
dirty_range~ : MarkdownDocumentDirtyRange?,
reuse? : MarkdownDocumentReindexWindow? = None,
) -> MarkdownDocumentBlockAssignment {
match (previous, reuse) {
(Some(prev), Some(window)) =>
markdown_document_assign_blocks_reuse(
editor_blocks,
prev,
window,
next_id~,
dirty_range~,
)
_ =>
markdown_document_assign_blocks_full(
editor_blocks,
previous~,
next_id~,
dirty_range~,
)
}
}
///|
/// Id/revision assignment when the incremental apply proved that every
/// block outside the re-parse window is content-identical to a previous
/// block. Operates in place on `previous.blocks` (shared by reference,
/// same contract as the text store): the prefix keeps its records (only
/// the dirty ±1 edge re-stamps its revision), suffix records move only
/// when the region size changed while their absolute ranges shift by the
/// edit delta, and window blocks match fingerprints only among the
/// previous window blocks — outside ones are already accounted for, so
/// ids follow content through the window instead of drifting to
/// unrelated positions. Fingerprint construction (kind key + content
/// hash) is paid only for window blocks, so nothing here allocates per
/// suffix block.
fn markdown_document_assign_blocks_reuse(
editor_blocks : Array[MarkdownEditorBlock],
previous : MarkdownDocumentSession,
window : MarkdownDocumentReindexWindow,
next_id~ : Int,
dirty_range~ : MarkdownDocumentDirtyRange?,
) -> MarkdownDocumentBlockAssignment {
let blocks = previous.blocks
let suffix_old_start = window.suffix_new_start + window.suffix_shift
let used_window : Array[Bool] = []
for _ in window.prefix_count.. range.end_block
None => -1
}
let dirty_start = match dirty_range {
Some(range) => range.start_block
None => blocks.length()
}
for index = dirty_start; index <= dirty_end; index = index + 1 {
if index >= blocks.length() {
break
}
if index < window.prefix_count || index >= window.suffix_new_start {
blocks[index].revision = blocks[index].revision + 1
}
}
// Publish the window records over the stale region slots.
for offset, record in window_records {
blocks[window.prefix_count + offset] = record
}
{ blocks, next_id, }
}
///|
fn markdown_document_assigned_block(
editor_block : MarkdownEditorBlock,
id~ : Int,
revision~ : Int,
fingerprint~ : UInt64,
) -> MarkdownDocumentBlock {
{
id,
revision,
source_range: editor_block.source_range,
content_range: editor_block.content_range,
kind: editor_block.block.kind,
text: editor_block.block.text,
fingerprint,
inline_count: editor_block.block.inlines.length(),
}
}
///|
fn markdown_document_assign_blocks_full(
editor_blocks : Array[MarkdownEditorBlock],
previous~ : MarkdownDocumentSession?,
next_id~ : Int,
dirty_range~ : MarkdownDocumentDirtyRange?,
) -> MarkdownDocumentBlockAssignment {
let blocks : Array[MarkdownDocumentBlock] = []
let used_previous : Array[Bool] = []
match previous {
Some(prev) =>
for _ in prev.blocks {
used_previous.push(false)
}
None => ()
}
let mut next_id = next_id
for index, editor_block in editor_blocks {
let fingerprint = markdown_document_block_fingerprint(editor_block)
let matched = markdown_document_match_previous_block(
previous, used_previous, index, fingerprint,
)
let dirty = markdown_document_block_index_dirty(dirty_range, index)
let (id, block_revision) = match matched {
Some(match_index) => {
guard previous is Some(prev) else { (next_id, 0) }
used_previous[match_index] = true
let previous_block = prev.blocks[match_index]
(
previous_block.id,
if dirty || previous_block.fingerprint != fingerprint {
previous_block.revision + 1
} else {
previous_block.revision
},
)
}
None => {
let id = next_id
next_id = next_id + 1
(id, 0)
}
}
blocks.push(
markdown_document_assigned_block(
editor_block,
id~,
revision=block_revision,
fingerprint~,
),
)
}
{ blocks, next_id, }
}
///|
fn markdown_document_match_previous_block(
previous : MarkdownDocumentSession?,
used_previous : Array[Bool],
index : Int,
fingerprint : UInt64,
) -> Int? {
match previous {
Some(prev) => {
if index < prev.blocks.length() &&
!used_previous[index] &&
prev.blocks[index].fingerprint == fingerprint {
return Some(index)
}
for previous_index, block in prev.blocks {
if !used_previous[previous_index] && block.fingerprint == fingerprint {
return Some(previous_index)
}
}
if index < prev.blocks.length() && !used_previous[index] {
Some(index)
} else {
None
}
}
None => None
}
}
///|
fn markdown_document_block_index_dirty(
dirty_range : MarkdownDocumentDirtyRange?,
index : Int,
) -> Bool {
match dirty_range {
Some(range) => index >= range.start_block && index <= range.end_block
None => false
}
}
///|
/// Content fingerprint used to carry block ids/revisions across
/// re-parses. It is only ever compared for equality, so it mixes a
/// per-kind tag with the block's content hash and length — no string
/// allocation, unlike the previous kind-key + hash-string concatenation.
fn markdown_document_block_fingerprint(block : MarkdownEditorBlock) -> UInt64 {
let kind_tag = markdown_document_block_kind_tag(block.block.kind)
let content = rich_text_digest_mix(
kind_tag,
block.block.text.hash().to_uint64(),
)
rich_text_digest_mix(
content,
markdown_editor_text_length(block.block.text).to_uint64(),
)
}
///|
/// Stable numeric tag per block kind; parameterized kinds mix their
/// parameter in so distinct parameters never collide.
fn markdown_document_block_kind_tag(kind : MarkdownBlockKind) -> UInt64 {
match kind {
Paragraph => 0UL
Heading(level) => rich_text_digest_mix(1UL, level.to_uint64())
UnorderedListItem => 2UL
TaskListItem(checked) =>
rich_text_digest_mix(3UL, if checked { 1UL } else { 0UL })
OrderedListItem(number) => rich_text_digest_mix(4UL, number.to_uint64())
Blockquote => 5UL
CodeBlock => 6UL
HorizontalRule => 7UL
Table => 8UL
FootnoteDefinition(label) =>
rich_text_digest_mix(9UL, label.hash().to_uint64())
HtmlBlock => 10UL
FrontMatter => 11UL
}
}
///|
/// Outline for an incremental apply: heading items before the re-parse
/// region are unchanged; items after it only shift by the character
/// delta; the region itself is rescanned. Blocks outside the window keep
/// their content, so their heading items survive untouched.
fn markdown_document_outline_after_edit(
editor_blocks : Array[MarkdownEditorBlock],
previous : MarkdownDocumentSession,
window : MarkdownDocumentReindexWindow,
) -> Array[MarkdownEditorOutlineItem] {
let items = previous.outline
// Fast path: the re-parsed window contains no heading, the block count
// did not change, and no previous item originated inside the replaced
// region — the heading set is identical, so the previous array is
// reused as-is with its offsets shifted in place (same shared-mutable
// contract as the block ranges). The rebuild path below re-created
// every item per keystroke on heading-heavy documents.
let mut window_has_heading = false
for index in window.prefix_count..= window.old_region_start {
first_inside = mid
high = mid - 1
} else {
low = mid + 1
}
}
let item_inside_region = first_inside < items.length() &&
items[first_inside].source_start < window.old_region_end
if !window_has_heading && window.suffix_shift == 0 && !item_inside_region {
if window.char_delta != 0 {
for index = first_inside; index < items.length(); index = index + 1 {
items[index].source_start = items[index].source_start +
window.char_delta
}
}
return items
}
let rebuilt : Array[MarkdownEditorOutlineItem] = []
for item in items {
if item.source_start < window.old_region_start {
rebuilt.push(item)
}
}
for index in window.prefix_count..
rebuilt.push({
level,
title: editor_block.block.text,
source_start: editor_block.content_range.start,
})
_ => ()
}
}
for item in items {
if item.source_start >= window.old_region_end {
rebuilt.push({
level: item.level,
title: item.title,
source_start: item.source_start + window.char_delta,
})
}
}
rebuilt
}
///|
/// Word-start count inside `text`, given whether the character before it
/// is whitespace. Returns the count and whether the span ends in (or
/// starts in, if empty) whitespace — enough to stitch neighbouring spans.
fn markdown_document_word_starts_in(
text : String,
after_whitespace~ : Bool,
) -> (Int, Bool) {
let mut count = 0
let mut whitespace = after_whitespace
for ch in text {
if ch.is_ascii_whitespace() {
whitespace = true
} else {
if whitespace {
count = count + 1
}
whitespace = false
}
}
(count, whitespace)
}
///|
/// Word count after an incremental edit. A word starts at each
/// non-whitespace char preceded by whitespace (or at the document start),
/// and an edit only changes the start predicate inside the edited span
/// and at the first character after it — so the delta is exact: subtract
/// the starts counted for the deleted span (with the same left context),
/// add the ones for the replacement, and reconcile the boundary char that
/// follows.
fn markdown_document_word_count_after_edit(
previous : MarkdownDocumentSession,
window : MarkdownDocumentReindexWindow,
) -> Int {
// The shared store already carries the post-edit text by the time the
// bookkeeping runs: the left probe sits before the edit start (its char
// is untouched by the splice) and the right probe is taken after the
// replacement instead of after the deleted span.
let before = previous.text_store.char_at(window.edit_start - 1)
let before_whitespace = match before {
None => true
Some(ch) => ch.is_ascii_whitespace()
}
let (old_starts, old_ends_whitespace) = markdown_document_word_starts_in(
window.deleted,
after_whitespace=before_whitespace,
)
let (new_starts, new_ends_whitespace) = markdown_document_word_starts_in(
window.replacement,
after_whitespace=before_whitespace,
)
let replacement_length = markdown_editor_text_length(window.replacement)
let after = previous.text_store.char_at(
window.edit_start + replacement_length,
)
let boundary = match after {
Some(ch) if !ch.is_ascii_whitespace() =>
(
if old_ends_whitespace {
1
} else {
0
},
if new_ends_whitespace {
1
} else {
0
},
)
Some(_) | None => (0, 0)
}
previous.word_count - old_starts - boundary.0 + new_starts + boundary.1
}
///|
/// Line count after an incremental edit: lines equal newline count plus
/// one (zero for the empty document), and the newline count only changes
/// by what the edit deleted and inserted.
fn markdown_document_line_count_after_edit(
previous : MarkdownDocumentSession,
source : String,
window : MarkdownDocumentReindexWindow,
) -> Int {
let previous_newlines = if previous.line_count == 0 {
0
} else {
previous.line_count - 1
}
if source == "" {
0
} else {
previous_newlines -
markdown_document_newline_count(window.deleted) +
markdown_document_newline_count(window.replacement) +
1
}
}
///|
fn markdown_document_dirty_range(
transaction : MarkdownEditTransaction,
blocks : Array[MarkdownEditorBlock],
) -> MarkdownDocumentDirtyRange? {
if blocks.length() == 0 {
return None
}
let source_start = transaction.edit_range.start
let inserted_length = markdown_editor_text_length(transaction.replacement)
let source_end = source_start + inserted_length
let context_all = markdown_document_transaction_needs_global_context(
transaction,
)
if context_all {
return Some({
start_block: 0,
end_block: blocks.length() - 1,
source_start: 0,
source_end: markdown_editor_text_length(transaction.source),
})
}
let mut start_block = -1
let mut end_block = -1
// Blocks tile the source in ascending order, so the blocks touching the
// edited range form a contiguous run: binary-search the first block
// whose end reaches the edit start (any touching block must satisfy
// that), then walk forward only while the touch holds.
let mut low = 0
let mut high = blocks.length() - 1
let mut first = blocks.length()
while low <= high {
let mid = (low + high) / 2
if blocks[mid].source_range.end >= source_start {
first = mid
high = mid - 1
} else {
low = mid + 1
}
}
let mut index = first
while index < blocks.length() {
let block = blocks[index]
if !markdown_document_ranges_touch(
block.source_range.start,
block.source_range.end,
source_start,
source_end,
) {
break
}
if start_block < 0 {
start_block = index
}
end_block = index
index = index + 1
}
if start_block < 0 {
start_block = markdown_document_block_index_after_source(
blocks, source_start,
)
end_block = start_block
}
start_block = markdown_editor_clamp_int(
start_block - 1,
0,
blocks.length() - 1,
)
end_block = markdown_editor_clamp_int(
end_block + 1,
start_block,
blocks.length() - 1,
)
Some({ start_block, end_block, source_start, source_end, })
}
///|
fn markdown_document_transaction_needs_global_context(
transaction : MarkdownEditTransaction,
) -> Bool {
// The transaction already carries the removed text, so this never
// slices the (potentially multi-megabyte) source per keystroke.
let text = transaction.replacement + transaction.deleted
text.contains("```") ||
text.contains("~~~") ||
text.contains("]:") ||
text.has_prefix("---")
}
///|
fn markdown_document_ranges_touch(
start_a : Int,
end_a : Int,
start_b : Int,
end_b : Int,
) -> Bool {
if start_b == end_b {
start_b >= start_a && start_b <= end_a
} else {
start_a <= end_b && end_a >= start_b
}
}
///|
fn markdown_document_block_index_after_source(
blocks : Array[MarkdownEditorBlock],
source_offset : Int,
) -> Int {
for index, block in blocks {
if source_offset <= block.source_range.end {
return index
}
}
blocks.length() - 1
}
///|
/// Slack past a run's visible text that a reveal span can still reach: the
/// closing markers and a link or image target, which the run's source range
/// does not cover.
const MARKDOWN_EDITOR_REVEAL_TARGET_SLACK : Int = 4096
///|
/// Whether a run is close enough to the reveal anchor that a marker span could
/// be active on it. A run with no source range cannot be judged, so it is
/// treated as near — that keeps the un-ranged case on the original path.
fn markdown_editor_run_near_reveal_anchor(
run : RichTextRun,
anchor : Int,
) -> Bool {
match run.source_range {
Some(range) =>
anchor >= range.start - 2 &&
anchor <= range.end + MARKDOWN_EDITOR_REVEAL_TARGET_SLACK
None => true
}
}
///|
fn markdown_document_session_reveal_selection(
source : String,
document : RichTextDocument,
selection : MarkdownEditorSelection,
) -> RichTextDocument {
let blocks : Array[RichTextBlock] = []
let reveal_anchor = match
markdown_editor_active_selection(selection.selection) {
Some(range) => range.start
None => selection.caret
}
for block in document.blocks {
// The inline marker probes read only around one run, so each touched
// run gets a block-sized char window instead of a whole-document array.
let block_source_end = match block.source_range {
Some(source_range) => source_range.end
None => 0
}
let block_source_start = match block.source_range {
Some(source_range) => source_range.start
None => 0
}
// Revealing only ever touches the block the anchor sits in: a marker span
// is revealed because the caret is inside it, and a span cannot leave its
// block. Every other block passes through untouched, which matters because
// this pass runs on every window query — a soft-wrapped paragraph carries
// one run per source line, so walking them all made each query proportional
// to the document. The one-character slack absorbs a collapsed caret
// resting against a block edge.
if reveal_anchor + 1 < block_source_start ||
reveal_anchor > block_source_end + 1 {
blocks.push(block)
continue
}
// One decoded window per block, shared by every run: a soft-wrapped
// paragraph carries one run per source line, and rebuilding the window
// per run cost the whole block each time.
let chars = if block.runs.length() > 0 {
Some(
markdown_editor_block_source_chars_near(
source, block_source_start, block_source_end,
),
)
} else {
None
}
let runs : Array[RichTextRun] = []
for run in block.runs {
runs.push(
match chars {
Some(chars) =>
// Only a run the anchor sits in (or beside) can carry an active
// marker span. This pass runs on every window query, and a
// soft-wrapped paragraph carries one run per source line, so
// probing all of them made each frame walk the whole document.
if markdown_editor_run_near_reveal_anchor(run, reveal_anchor) {
markdown_editor_reveal_active_inline_run(
source,
chars~,
selection,
run,
)
} else {
run
}
None => run
},
)
}
blocks.push(
markdown_editor_reveal_active_block_marker(source, selection, {
..block,
runs,
}),
)
}
RichTextDocument::new(blocks~)
}
///|
fn rich_text_block_total_height(
block : RichTextBlock,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
block_width? : Double = 0.0,
) -> Double {
rich_text_block_line_height(block, base_font, text_system, block_width~) +
block.inset.top +
block.inset.bottom
}