///|
/// Internal shared helpers for read-only rich text/markdown viewers.
/// Uses MarkdownDocumentSession for editor-parity layout and rendering.
///|
priv struct RichTextDocumentViewPrepared {
document : RichTextDocument
content_height : Double
block_tops : Array[Double]
block_heights : Array[Double]
}
///|
fn RichTextDocumentViewPrepared::new(
document : RichTextDocument,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
block_width : Double,
) -> RichTextDocumentViewPrepared {
let block_tops : Array[Double] = []
let block_heights : Array[Double] = []
let mut content_height = 0.0
for block in document.blocks {
let height = rich_text_block_line_height(
block,
base_font,
text_system,
block_width~,
) +
block.inset.top +
block.inset.bottom
block_tops.push(content_height)
block_heights.push(height)
content_height = content_height + height
}
{ document, content_height, block_tops, block_heights, }
}
///|
/// A bounded process-local entry keeps prepared documents alive across view
/// reconciliation. Scroll position is application state in the virtual-list
/// API, so the viewer node itself is rebuilt for every scroll update; without
/// this shared store each rebuilt node would parse and measure its source again.
priv struct RichTextDocumentViewCacheEntry {
source : String
width : Double
base_font : @core.FontSpec
text_system_id : String
prepared : RichTextDocumentViewPrepared
}
///|
let rich_text_document_view_cache_store : Ref[
Array[RichTextDocumentViewCacheEntry],
] = Ref([])
///|
fn rich_text_document_view_cache_capacity() -> Int {
64
}
///|
priv struct RichTextDocumentViewCache {
mut source : String?
mut width : Double
mut base_font : @core.FontSpec?
mut text_system_id : String
mut prepared : RichTextDocumentViewPrepared?
}
///|
fn RichTextDocumentViewCache::new() -> RichTextDocumentViewCache {
{
source: None,
width: 0.0,
base_font: None,
text_system_id: "",
prepared: None,
}
}
///|
fn RichTextDocumentViewCache::prepare(
self : RichTextDocumentViewCache,
source : String,
width : Double,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> RichTextDocumentViewPrepared {
if self.source == Some(source) &&
self.width == width &&
self.base_font == Some(base_font) &&
self.text_system_id == text_system.id() {
match self.prepared {
Some(prepared) => return prepared
None => ()
}
}
// View nodes are immutable snapshots and may be recreated by a model
// update. Reuse a recent prepared document before doing Markdown parsing or
// native text measurement again.
for entry in rich_text_document_view_cache_store.val {
if entry.source == source &&
entry.width == width &&
entry.base_font == base_font &&
entry.text_system_id == text_system.id() {
self.source = Some(source)
self.width = width
self.base_font = Some(base_font)
self.text_system_id = text_system.id()
self.prepared = Some(entry.prepared)
return entry.prepared
}
}
let session = MarkdownDocumentSession::new(source)
let dummy_selection : MarkdownEditorSelection = { caret: 0, selection: None, }
let document = session.rich_text(
dummy_selection,
base=base_font,
source_mode=false,
focus_mode=false,
base_dir=None,
)
let wrapped = rich_text_document_view_wrap(
document, width, base_font, text_system,
)
let prepared = RichTextDocumentViewPrepared::new(
wrapped, base_font, text_system, width,
)
self.source = Some(source)
self.width = width
self.base_font = Some(base_font)
self.text_system_id = text_system.id()
self.prepared = Some(prepared)
let store = rich_text_document_view_cache_store.val
if store.length() >= rich_text_document_view_cache_capacity() {
ignore(store.remove(0))
}
store.push({
source,
width,
base_font,
text_system_id: text_system.id(),
prepared,
})
prepared
}
///|
/// Calculate content height using MarkdownDocumentSession for editor-parity.
/// This matches session.estimated_content_height() behavior.
fn rich_text_document_session_view_measure_height(
source : String,
view_size : @core.Size,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
cache : RichTextDocumentViewCache,
) -> Double {
let content_rect = rich_text_editor_content_rect(
@core.Rect::new(
x=0.0,
y=0.0,
width=view_size.width,
height=view_size.height,
),
)
let prepared = cache.prepare(
source,
content_rect.size.width,
base_font,
text_system,
)
prepared.content_height + content_rect.origin.y * 2.0
}
///|
/// Internal shared paint logic using MarkdownDocumentSession for editor-parity.
/// Creates a read-only window (no selection) and renders document layers.
fn rich_text_document_session_view_paint(
ctx : @core.ViewPaintContext,
source : String,
base_font : @core.FontSpec,
foreground : @core.Color,
background : @core.Brush,
corner_radius : Double,
visible_scroll_y : Double,
visible_viewport_height : Double,
visible_overscan : Double,
cache : RichTextDocumentViewCache,
) -> @core.ViewPaintPlan {
let commands : Array[@core.DrawCommand] = []
let rounded = @core.RoundedRect::new(rect=ctx.frame, radius=corner_radius)
commands.push(FillRoundedRectBrush(rounded, background))
let content = rich_text_editor_content_rect(ctx.frame)
commands.push(PushClip(content))
let prepared = cache.prepare(
source,
content.size.width,
base_font,
ctx.text_system,
)
let (window_document, top_padding) = rich_text_document_view_window(
prepared, visible_scroll_y, visible_viewport_height, visible_overscan,
)
// The parent scroll view already translates this view's frame by the
// current offset. Only restore the omitted window prefix here.
let document_content = rich_text_document_view_window_content_rect(
content, top_padding,
)
let child_layers : Array[@core.ViewPaintLayer] = []
append_rich_text_document_layers(
child_layers,
window_document,
document_content,
base_font,
foreground,
text_system=ctx.text_system,
clip_rect=Some(content),
)
let overlay_commands : Array[@core.DrawCommand] = []
for layer in child_layers {
commands.append(layer.commands)
overlay_commands.append(layer.overlay_commands)
}
{
commands,
child_layers: [],
post_commands: [PopClip],
overlay_commands,
repaint_boundary: false,
animating: false,
}
}
///|
fn rich_text_document_view_window_content_rect(
content : @core.Rect,
top_padding : Double,
) -> @core.Rect {
content.offset(dx=0.0, dy=top_padding)
}
///|
/// Hit-test a point against the document rendered via session window,
/// returning the source offset for link resolution.
fn rich_text_document_session_view_offset_at_point(
source : String,
point : @core.Point,
frame : @core.Rect,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
visible_scroll_y : Double,
visible_viewport_height : Double,
visible_overscan : Double,
) -> Int? {
ignore(visible_overscan)
let content = rich_text_editor_content_rect(frame)
let session = MarkdownDocumentSession::new(source)
let dummy_selection : MarkdownEditorSelection = { caret: 0, selection: None, }
let document = session.rich_text(
dummy_selection,
base=base_font,
source_mode=false,
focus_mode=false,
base_dir=None,
)
let wrapped = rich_text_document_view_wrap(
document,
content.size.width,
base_font,
text_system,
)
ignore(visible_scroll_y)
ignore(visible_viewport_height)
rich_text_document_source_offset_at_point(
wrapped, point, content, base_font, text_system,
)
}
///|
fn rich_text_document_view_wrap(
document : RichTextDocument,
width : Double,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> RichTextDocument {
let blocks : Array[RichTextBlock] = []
for block in document.blocks {
blocks.push(rich_text_block_soft_wrap(block, width, base_font, text_system))
}
{ blocks, }
}
///|
fn rich_text_document_view_window(
prepared : RichTextDocumentViewPrepared,
scroll_y : Double,
viewport_height : Double,
overscan : Double,
) -> (RichTextDocument, Double) {
if viewport_height <= 0.0 {
return (prepared.document, 0.0)
}
let visible_top = max_double(0.0, scroll_y - overscan)
let visible_bottom = scroll_y + viewport_height + overscan
let blocks : Array[RichTextBlock] = []
let mut y = 0.0
let mut top_padding = 0.0
for index, block in prepared.document.blocks {
y = prepared.block_tops[index]
let block_height = prepared.block_heights[index]
let block_bottom = y + block_height
if block_bottom >= visible_top && y <= visible_bottom {
if blocks.is_empty() {
top_padding = y
}
blocks.push(block)
} else if y > visible_bottom {
break
}
}
({ blocks, }, top_padding)
}
///|
fn rich_text_document_view_wrap_block(
block : RichTextBlock,
width : Double,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> RichTextBlock {
if block.rule || block.table is Some(_) || block.runs.is_empty() {
return block
}
if block.runs.any(run => run.image_source is Some(_)) {
return block
}
// Break lines on `TextSystem::measure_text` — the same measurement the
// draw pass uses for its glyph runs. SkParagraph shapes fallback runs
// slightly differently, and that drift used to push the line's last glyph
// past the paint frame's clip edge (a half-drawn 练/宿 at line ends).
let available_width = max_double(
1.0,
width - block.inset.left - block.inset.right,
)
let soft_breaks = rich_text_measure_based_soft_breaks(
block,
available_width~,
base_font~,
text_system~,
)
if soft_breaks.is_empty() {
return block
}
// The no-double-newline guard must look at the JOINED visual text: a
// wrap boundary can sit exactly where one run ends and the next run
// starts with '\n' (a hard break in the source). Checking only the
// run's own text would inject a second newline there and render an
// extra empty line between the two halves of the paragraph.
let visual_chars = rich_text_document_view_block_visual_text(block).to_array()
let visual_length = visual_chars.length()
let runs : Array[RichTextRun] = []
let mut offset = block.prefix.to_array().length()
// `soft_breaks` is ascending and `offset` advances monotonically across the
// runs, so one cursor hands each run its own slice. Filtering the whole
// `soft_breaks` array for every run made the wrap quadratic in the number
// of runs, and a soft-wrapped paragraph carries one run per source line.
let mut slice_start = 0
for run in block.runs {
let run_visual = rich_text_run_visual_text(run)
let run_length = text_length(run_visual)
while slice_start < soft_breaks.length() &&
soft_breaks[slice_start] <= offset {
slice_start = slice_start + 1
}
let local_breaks : Array[Int] = []
let mut slice_end = slice_start
while slice_end < soft_breaks.length() &&
soft_breaks[slice_end] <= offset + run_length {
local_breaks.push(soft_breaks[slice_end] - offset)
slice_end = slice_end + 1
}
slice_start = slice_end
if local_breaks.is_empty() {
runs.push(run)
} else {
let run_chars = run_visual.to_array()
let output : Array[Char] = []
// `local_breaks` is ascending, so one cursor replaces the per-character
// membership scan. `local_breaks.contains(boundary)` inside this loop
// made the wrap quadratic (characters × visual lines), which is what a
// long soft-wrapped paragraph — one block, hundreds of thousands of
// visual lines — turns into.
let mut break_cursor = 0
for index, ch in run_chars {
output.push(ch)
let boundary = index + 1
while break_cursor < local_breaks.length() &&
local_breaks[break_cursor] < boundary {
break_cursor = break_cursor + 1
}
let at_break = break_cursor < local_breaks.length() &&
local_breaks[break_cursor] == boundary
let next_is_newline = offset + boundary < visual_length &&
visual_chars[offset + boundary] == '\n'
if at_break && ch != '\n' && !next_is_newline {
output.push('\n')
}
}
runs.push({ ..run, visual_text: Some(String::from_array(output)), })
}
offset = offset + run_length
}
{ ..block, runs, }
}
///|
fn rich_text_document_view_block_visual_text(block : RichTextBlock) -> String {
let parts : Array[String] = [block.prefix]
for run in block.runs {
parts.push(rich_text_run_visual_text(run))
}
parts.join("")
}