///|
/// Shared soft-wrap layer for rich text blocks (ADR 0001).
///
/// One memoized `TextSystem::layout_paragraph` pass per block content,
/// content width, font, and text system produces the visual text with
/// injected soft breaks; paint, block height, selection, caret, and
/// hit-test all consume the same result, so a caret lands exactly where
/// the text was drawn. Keys are content-identity (like the other caches
/// in this package): upstream edits keep entries valid and the cache
/// never needs eviction hooks beyond its size cap.
///|
let rich_text_soft_wrap_cache : Map[String, RichTextBlockCacheEntry] = Map([])
///|
/// Wrap one block for a laid out content width. Blocks whose geometry is
/// custom (rules, tables, image runs) pass through untouched, matching
/// the read-only viewer's policy.
fn rich_text_block_soft_wrap(
block : RichTextBlock,
width : Double,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> RichTextBlock {
if width <= 0.0 ||
block.rule ||
block.table is Some(_) ||
block.runs.is_empty() ||
block.runs.any(run => run.image_source is Some(_)) {
return block
}
let key = rich_text_block_soft_wrap_key(block, width, base_font, text_system)
// The key is content identity, so a hit holds the offsets of whichever
// block built the entry. Two identical paragraphs must not share one
// source mapping: re-base the wrapped block onto this block's offsets
// (same contract as the block-conversion cache).
let source_start = rich_text_block_source_start(block)
match rich_text_soft_wrap_cache.get(key) {
Some(entry) => {
hot_path_block_wrap_cache_hits.val = hot_path_block_wrap_cache_hits.val +
1
markdown_editor_rich_block_shift(
entry.block,
source_start - entry.source_start,
)
}
None => {
hot_path_block_wrap_builds.val = hot_path_block_wrap_builds.val + 1
let wrapped = rich_text_document_view_wrap_block(
block, width, base_font, text_system,
)
if rich_text_soft_wrap_cache.length() >= 4096 {
rich_text_soft_wrap_cache.clear()
}
rich_text_soft_wrap_cache.set(key, { source_start, block: wrapped, })
wrapped
}
}
}
///|
/// The absolute source offset a block's own ranges start at. Every run and
/// cell offset inside the block is relative to it, so it is the anchor the
/// content-identity caches re-base on.
fn rich_text_block_source_start(block : RichTextBlock) -> Int {
match block.source_range {
Some(range) => range.start
None =>
match block.content_range {
Some(range) => range.start
None => 0
}
}
}
///|
fn rich_text_document_soft_wrap(
document : RichTextDocument,
width : Double,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> RichTextDocument {
{
blocks: document.blocks.map(block => {
rich_text_block_soft_wrap(block, width, base_font, text_system)
}),
}
}
///|
/// Cheap identity for the wrap inputs: the paragraph layout depends on
/// the laid out width, the vertical inset, the block line height, and the
/// visible text with the fonts that may draw it. Inline runs share one
/// layout pass, so per-run styling beyond the font (colors, links) does
/// not change break positions and is deliberately excluded. Absolute
/// offsets are excluded too — the entry is re-based on a hit — but the
/// content offset inside the block is kept: hidden marker widths shift it
/// independently of the visible text.
fn rich_text_block_soft_wrap_key(
block : RichTextBlock,
width : Double,
base_font : @core.FontSpec,
text_system : @core.TextSystem,
) -> String {
let block_font = rich_text_block_font(block, base_font)
let content_offset = match (block.source_range, block.content_range) {
(Some(source), Some(content)) => content.start - source.start
_ => 0
}
let parts : Array[String] = [
"soft-wrap",
text_system.id(),
rich_text_cache_font_key(base_font),
if block_font == base_font {
""
} else {
rich_text_cache_font_key(block_font)
},
width.to_string(),
block.inset.left.to_string(),
block.inset.right.to_string(),
block.line_height.unwrap_or(-1.0).to_string(),
block.prefix,
match block.marker {
Some(TaskCheckbox(checked)) =>
if checked {
"task-checkbox:checked"
} else {
"task-checkbox"
}
None => ""
},
content_offset.to_string(),
]
for run in block.runs {
parts.push(run.text)
match run.font {
Some(font) =>
parts.push(
if font == block_font {
""
} else {
rich_text_cache_font_key(font)
},
)
None => parts.push("")
}
}
rich_text_cache_join_key(parts)
}
///|
priv struct RichTextWrapCluster {
offset : Int
text : String
advance : Double
}
///|
/// A wrap may occur after this cluster. CJK characters and slashes are
/// breakable after (standard Chinese line breaking: any two Han characters
/// may split, and paths break at `/`); Latin words stay whole and only break
/// at whitespace or hyphens.
fn rich_text_wrap_breakable_after(cluster : String) -> Bool {
let chars = cluster.to_array()
match chars.get(0) {
Some(ch) => {
let code = ch.to_int()
if ch == ' ' || ch == '\t' || ch == '-' || ch == '/' {
true
} else if code >= 0x2E80 {
// CJK radicals, ideographs, kana, fullwidth forms.
true
} else {
false
}
}
None => false
}
}
///|
/// A line must not START with these clusters (closing punctuation and
/// slashes that belong to the preceding token).
fn rich_text_wrap_cannot_start_line(cluster : String) -> Bool {
match cluster {
"、"
| ","
| "。"
| ";"
| ":"
| ")"
| "!"
| "?"
| "》"
| "」"
| "』"
| ")"
| "%"
| "."
| ","
| ";"
| ":"
| "!"
| "?"
| "/" => true
_ => false
}
}
///|
/// A line must not END with these clusters (opening punctuation).
fn rich_text_wrap_cannot_end_line(cluster : String) -> Bool {
match cluster {
"(" | "《" | "「" | "『" | "(" | "[" | "{" => true
_ => false
}
}
///|
/// Soft-break offsets derived from `TextSystem::measure_text` — the same
/// measurement the draw pass uses for its glyph runs. The paragraph layout
/// shapes fallback runs differently from the single-run draw path, and the
/// few points of drift per dense line pushed the line's last glyph past the
/// paint frame's clip edge (a half-drawn 绘/宿 at line ends). Breaking on the
/// draw measurement keeps every wrapped line inside the clip by construction.
fn rich_text_measure_based_soft_breaks(
block : RichTextBlock,
available_width~ : Double,
base_font~ : @core.FontSpec,
text_system~ : @core.TextSystem,
) -> Array[Int] {
let breaks : Array[Int] = []
if available_width <= 1.0 {
return breaks
}
let block_font = rich_text_block_font(block, base_font)
// The joined visual text is the prefix followed by each run's visual text;
// measure every segment once with the font that will draw it.
let segments : Array[(String, @core.FontSpec, Int)] = []
segments.push((block.prefix, block_font, 0))
let mut run_offset = text_length(block.prefix)
for run in block.runs {
let run_visual = rich_text_run_visual_text(run)
segments.push((run_visual, run.font.unwrap_or(block_font), run_offset))
run_offset = run_offset + text_length(run_visual)
}
// One source line of clusters at a time. The greedy fill restarts at every
// hard break, so a line's breaks never depend on another line's clusters,
// and buffering the whole block made the wrap's footprint proportional to
// the document: a soft-wrapped paragraph carries one source line per visual
// line, so a novel-sized block meant millions of cluster objects and
// hundreds of megabytes to produce a result that needs one line of state.
let breaks : Array[Int] = []
let line_clusters : Array[RichTextWrapCluster] = []
for segment in segments {
let (text, font, base_offset) = segment
if text == "" {
continue
}
let chars = text.to_array()
let measured = text_system.measure_text(
@core.TextLayoutInput::new(text~, font~),
)
let carets = measured.caret_positions
let boundaries = @core.TextGraphemeBoundaries::new(text~)
let mut cursor = 0
while cursor < chars.length() {
let next = boundaries.next_boundary(cursor)
let end = @core.min_int(next, chars.length())
let advance_start = carets.get(cursor).unwrap_or(0.0)
let advance_end = carets.get(end).unwrap_or(advance_start)
let cluster_text = String::from_array(chars[cursor:end])
if rich_text_wrap_cluster_is_hard_break(cluster_text) {
rich_text_wrap_line_breaks(line_clusters, breaks, available_width)
line_clusters.clear()
} else {
line_clusters.push({
offset: base_offset + cursor,
text: cluster_text,
advance: max_double(0.0, advance_end - advance_start),
})
}
cursor = end
}
}
rich_text_wrap_line_breaks(line_clusters, breaks, available_width)
breaks
}
///|
/// Whether a grapheme cluster ends the source line.
///
/// Unicode keeps a CRLF pair inside one cluster, so a CRLF document yields
/// `"\r\n"` where an LF document yields `"\n"`. Matching only `"\n"` left
/// every CRLF line unterminated: the greedy fill then treated a whole block as
/// one line, which wrapped wrongly and defeated the per-line buffering above.
fn rich_text_wrap_cluster_is_hard_break(cluster_text : String) -> Bool {
cluster_text == "\n" || cluster_text == "\r\n"
}
///|
/// Greedy fill with kinsoku over one source line's clusters: a line never
/// starts with closing punctuation (or a stray slash) and never ends with
/// opening punctuation. Whitespace and closing punctuation hang at the line
/// end without forcing a break. Break offsets stay in block coordinates and
/// are appended to `breaks` in ascending order.
fn rich_text_wrap_line_breaks(
clusters : Array[RichTextWrapCluster],
breaks : Array[Int],
available_width : Double,
) -> Unit {
let cluster_count = clusters.length()
let mut i = 0
let mut line_width = 0.0
let mut line_has_content = false
let mut break_after_offset = -1
let mut width_since_break = 0.0
while i < cluster_count {
let cluster = clusters[i]
let width = cluster.advance
let overflow = line_has_content && line_width + width > available_width
if overflow && break_after_offset >= 0 {
// Break at the recorded opportunity, then apply kinsoku adjustments.
let mut break_offset = break_after_offset
// (a) A line may not end with opening punctuation: pull the break back
// over any opening cluster it would strand at the line end.
let mut pulled = true
while pulled {
pulled = false
match rich_text_cluster_ending_at(clusters, break_offset) {
Some(index) =>
if rich_text_wrap_cannot_end_line(clusters[index].text) {
break_offset = clusters[index].offset
pulled = true
}
None => ()
}
}
// (b) A line may not start with closing punctuation (or a stray slash):
// push the break past them so they stay with the preceding token.
let mut pushed = true
while pushed {
pushed = false
match rich_text_cluster_starting_at(clusters, break_offset) {
Some(index) =>
if rich_text_wrap_cannot_start_line(clusters[index].text) {
break_offset = clusters[index].offset +
text_length(clusters[index].text)
pushed = true
}
None => ()
}
}
breaks.push(break_offset)
// The new line carries every processed cluster from break_offset on.
line_width = 0.0
let mut j = i
while j >= 0 && clusters[j].offset >= break_offset {
line_width = line_width + clusters[j].advance
j = j - 1
}
if j + 1 > i {
// The kinsoku push moved the break past the current cluster.
i = j + 1
line_width = 0.0
line_has_content = false
break_after_offset = -1
width_since_break = 0.0
continue
}
line_has_content = true
break_after_offset = -1
width_since_break = 0.0
i = i + 1
continue
}
if overflow {
// No opportunity in the line: break before the current cluster.
breaks.push(cluster.offset)
line_width = width
break_after_offset = -1
width_since_break = 0.0
line_has_content = true
i = i + 1
continue
}
line_width = line_width + width
line_has_content = true
if rich_text_wrap_breakable_after(cluster.text) {
break_after_offset = cluster.offset + cluster.text.to_array().length()
width_since_break = 0.0
} else {
width_since_break = width_since_break + width
}
i = i + 1
}
}
///|
/// Index of the cluster whose start offset is exactly `offset`, or `None`.
///
/// Cluster offsets are strictly ascending, so a binary search replaces both
/// the linear scan this used to do and the offset-keyed map that briefly
/// stood in for it: on a paragraph with millions of graphemes the map cost
/// hundreds of megabytes, which is the wrong trade for a lookup that runs a
/// couple of times per visual line.
fn rich_text_cluster_starting_at(
clusters : Array[RichTextWrapCluster],
offset : Int,
) -> Int? {
let mut low = 0
let mut high = clusters.length() - 1
while low <= high {
let mid = low + (high - low) / 2
let candidate = clusters[mid].offset
if candidate == offset {
return Some(mid)
} else if candidate < offset {
low = mid + 1
} else {
high = mid - 1
}
}
None
}
///|
/// Index of the cluster whose end offset is exactly `offset`, or `None`. See
/// `rich_text_cluster_starting_at` for why this is a binary search.
fn rich_text_cluster_ending_at(
clusters : Array[RichTextWrapCluster],
offset : Int,
) -> Int? {
let mut low = 0
let mut high = clusters.length() - 1
while low <= high {
let mid = low + (high - low) / 2
let cluster = clusters[mid]
let end = cluster.offset + text_length(cluster.text)
if end == offset {
return Some(mid)
} else if end < offset {
low = mid + 1
} else {
high = mid - 1
}
}
None
}