///|
/// 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,
) -> 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.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 }
}
///|
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 => ()
}
}
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,
)
self.source = Some(source)
self.width = width
self.base_font = Some(base_font)
self.text_system_id = text_system.id()
self.prepared = Some(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,
visible_scroll_state : @core.ScrollState?,
cache : RichTextDocumentViewCache,
) -> @core.ViewPaintPlan {
let commands : Array[@core.DrawCommand] = []
let rounded = @core.RoundedRect::new(rect=ctx.frame, radius=corner_radius)
commands.push(@core.DrawCommand::FillRoundedRectBrush(rounded, background))
let content = rich_text_editor_content_rect(ctx.frame)
commands.push(@core.DrawCommand::PushClip(content))
let prepared = cache.prepare(
source,
content.size.width,
base_font,
ctx.text_system,
)
let current_scroll_y = markdown_session_current_scroll_y(
visible_scroll_y, visible_scroll_state,
)
let (window_document, top_padding) = rich_text_document_view_window(
prepared, current_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] = []
let platform_views : Array[@core.PlatformViewPlacement] = []
for layer in child_layers {
commands.append(layer.commands)
overlay_commands.append(layer.overlay_commands)
platform_views.append(layer.platform_views)
}
{
commands,
child_layers: [],
post_commands: [@core.DrawCommand::PopClip],
overlay_commands,
platform_views,
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,
visible_scroll_state : @core.ScrollState?,
) -> 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)
ignore(visible_scroll_state)
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_document_view_wrap_block(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
}
let available_width = max_double(
1.0,
width - block.inset.left - block.inset.right,
)
let visual = rich_text_document_view_block_visual_text(block)
let wrap_font = rich_text_document_view_wrap_font(
block, visual, base_font, text_system,
)
let layout = text_system.layout_paragraph(
@core.TextLayoutInput::new(
text=visual,
font=wrap_font,
max_width=available_width,
),
)
if layout.lines.length() <= 1 {
return block
}
let soft_breaks : Array[Int] = []
for index, line in layout.lines {
if index + 1 < layout.lines.length() &&
line.text_range.end > 0 &&
line.text_range.end < visual.to_array().length() {
soft_breaks.push(line.text_range.end)
}
}
if soft_breaks.is_empty() {
return block
}
let runs : Array[RichTextRun] = []
let mut offset = block.prefix.to_array().length()
for run in block.runs {
let run_visual = rich_text_run_visual_text(run)
let run_length = run_visual.to_array().length()
let local_breaks : Array[Int] = []
for soft_break in soft_breaks {
if soft_break > offset && soft_break <= offset + run_length {
local_breaks.push(soft_break - offset)
}
}
if local_breaks.is_empty() {
runs.push(run)
} else {
runs.push({
..run,
visual_text: Some(
rich_text_document_view_insert_breaks(run_visual, local_breaks),
),
})
}
offset = offset + run_length
}
{ ..block, runs, }
}
///|
fn rich_text_document_view_wrap_font(
block : RichTextBlock,
visual : String,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> @core.FontSpec {
let mut widest_font = rich_text_block_font(block, base_font)
let mut widest_width = rich_text_width(visual, widest_font, text_system)
for run in block.runs {
match run.font {
Some(font) => {
let width = rich_text_width(visual, font, text_system)
if width > widest_width {
widest_font = font
widest_width = width
}
}
None => ()
}
}
widest_font
}
///|
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("")
}
///|
fn rich_text_document_view_insert_breaks(
value : String,
breaks : Array[Int],
) -> String {
let chars = value.to_array()
let output : Array[Char] = []
for index, ch in chars {
output.push(ch)
let boundary = index + 1
if breaks.any(value => value == boundary) &&
ch != '\n' &&
(boundary >= chars.length() || chars[boundary] != '\n') {
output.push('\n')
}
}
String::from_array(output)
}