···119119 char_offset: usize,
120120) -> Option<(&OffsetMapping, bool)> {
121121 // Binary search for the mapping
122122- // Note: We allow cursor at the end boundary of a mapping (cursor after text)
123123- // This makes ranges END-INCLUSIVE for cursor positioning
122122+ // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9.
123123+ // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20),
124124+ // prefer the NEXT mapping so cursor goes "down" to new content.
124125 let idx = offset_map
125126 .binary_search_by(|mapping| {
126126- if mapping.char_range.end < char_offset {
127127- // Cursor is after this mapping
127127+ if mapping.char_range.end <= char_offset {
128128+ // Cursor is at or after end of this mapping - look forward
128129 std::cmp::Ordering::Less
129130 } else if mapping.char_range.start > char_offset {
130131 // Cursor is before this mapping
131132 std::cmp::Ordering::Greater
132133 } else {
133133- // Cursor is within [start, end] OR exactly at end (inclusive)
134134- // This handles cursor at position N matching range N-1..N
134134+ // Cursor is within [start, end)
135135 std::cmp::Ordering::Equal
136136 }
137137 })
+368-46
crates/weaver-app/src/components/editor/render.rs
···44//!
55//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
6677+use super::document::EditInfo;
78use super::offset_map::{OffsetMapping, RenderResult};
89use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
910use super::writer::EditorWriter;
1011use jumprope::JumpRopeBuf;
1112use markdown_weaver::Parser;
1313+use std::ops::Range;
12141313-/// Render markdown to HTML with visible formatting characters and offset mappings.
1414-///
1515-/// This function performs a full re-render of the document on every change.
1616-/// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility.
1717-///
1818-/// Uses EditorWriter which processes offset_iter events to detect consumed
1919-/// formatting characters and emit them as `<span class="md-syntax-*">` elements.
2020-///
2121-/// Returns both the rendered HTML and offset mappings for cursor restoration.
2222-///
2323-/// # Phase 2 features
2424-/// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block)
2525-/// - Offset map generation for cursor restoration
2626-/// - Full document re-render (fast enough for current needs)
2727-///
2828-/// # Deprecated: Use `render_paragraphs()` for incremental rendering
2929-pub fn render_markdown_simple(source: &str) -> RenderResult {
3030- let source_rope = JumpRopeBuf::from(source);
3131- let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter();
3232- let mut output = String::new();
1515+/// Cache for incremental paragraph rendering.
1616+/// Stores previously rendered paragraphs to avoid re-rendering unchanged content.
1717+#[derive(Clone, Debug, Default)]
1818+pub struct RenderCache {
1919+ /// Cached paragraph renders (content paragraphs only, gaps computed fresh)
2020+ pub paragraphs: Vec<CachedParagraph>,
2121+ /// Next available node ID for fresh renders
2222+ pub next_node_id: usize,
2323+}
33243434- match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() {
3535- Ok(result) => RenderResult {
3636- html: output,
3737- offset_map: result.offset_maps,
3838- },
3939- Err(_) => {
4040- // Fallback to empty result on error
4141- RenderResult {
4242- html: String::new(),
4343- offset_map: Vec::new(),
4444- }
4545- }
4646- }
2525+/// A cached paragraph render that can be reused if source hasn't changed.
2626+#[derive(Clone, Debug)]
2727+pub struct CachedParagraph {
2828+ /// Hash of paragraph source text for change detection
2929+ pub source_hash: u64,
3030+ /// Byte range in source document
3131+ pub byte_range: Range<usize>,
3232+ /// Char range in source document
3333+ pub char_range: Range<usize>,
3434+ /// Rendered HTML
3535+ pub html: String,
3636+ /// Offset mappings for cursor positioning
3737+ pub offset_map: Vec<OffsetMapping>,
4738}
48394940/// Render markdown in paragraph chunks for incremental DOM updates.
···9889 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
9990 let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
10091101101- for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
9292+ for (_idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
10293 // Extract paragraph source
10394 let para_source = rope_slice_to_string(rope, char_range.clone());
10495 let source_hash = hash_source(¶_source);
···159150 });
160151 }
161152162162- // Insert gap paragraphs for whitespace between blocks
163163- // This gives the cursor somewhere to land when positioned in newlines
153153+ // Insert gap paragraphs for EXTRA whitespace between blocks.
154154+ // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that.
155155+ // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot.
156156+ // Gap IDs are position-based for stability across renders.
157157+ const MIN_PARAGRAPH_BREAK: usize = 2; // \n\n
158158+164159 let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2);
165160 let mut prev_end_char = 0usize;
166161 let mut prev_end_byte = 0usize;
167162168163 for para in paragraphs {
169169- // Check for gap before this paragraph
170170- if para.char_range.start > prev_end_char {
171171- let gap_start_char = prev_end_char;
164164+ // Check for gap before this paragraph - only if MORE than minimum break
165165+ let gap_size = para.char_range.start.saturating_sub(prev_end_char);
166166+ if gap_size > MIN_PARAGRAPH_BREAK {
167167+ // Gap covers the EXTRA whitespace beyond the minimum break
168168+ let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK;
172169 let gap_end_char = para.char_range.start;
173173- let gap_start_byte = prev_end_byte;
170170+ let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK;
174171 let gap_end_byte = para.byte_range.start;
175172176176- let gap_node_id = format!("n{}", node_id_offset);
177177- node_id_offset += 1;
173173+ // Position-based ID: deterministic, stable across cache states
174174+ let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char);
178175 let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}');
179176180177 paragraphs_with_gaps.push(ParagraphRender {
···209206210207 // Only add if there's actually a gap at the end
211208 if doc_end_char > prev_end_char {
212212- let empty_node_id = format!("n{}", node_id_offset);
213213- let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
209209+ // Position-based ID for trailing gap
210210+ let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char);
211211+ let trailing_html = format!(r#"<span id="{}">{}</span>"#, trailing_node_id, '\u{200B}');
214212215213 paragraphs_with_gaps.push(ParagraphRender {
216214 byte_range: prev_end_byte..doc_end_byte,
217215 char_range: prev_end_char..doc_end_char,
218218- html: empty_html,
216216+ html: trailing_html,
219217 offset_map: vec![OffsetMapping {
220218 byte_range: prev_end_byte..doc_end_byte,
221219 char_range: prev_end_char..doc_end_char,
222222- node_id: empty_node_id,
220220+ node_id: trailing_node_id,
223221 char_offset_in_node: 0,
224222 child_index: None,
225223 utf16_len: 1, // zero-width space is 1 UTF-16 code unit
···231229232230 paragraphs_with_gaps
233231}
232232+233233+/// Check if an edit affects paragraph boundaries.
234234+///
235235+/// Edits that don't contain newlines and aren't in the block-syntax zone
236236+/// are considered "safe" and can skip boundary rediscovery.
237237+fn is_boundary_affecting(edit: &EditInfo) -> bool {
238238+ // Newlines always affect boundaries (paragraph splits/joins)
239239+ if edit.contains_newline {
240240+ return true;
241241+ }
242242+243243+ // Edits in the block-syntax zone (first ~6 chars of line) could affect
244244+ // headings, lists, blockquotes, code fences, etc.
245245+ if edit.in_block_syntax_zone {
246246+ return true;
247247+ }
248248+249249+ false
250250+}
251251+252252+/// Adjust a cached paragraph's positions after an earlier edit.
253253+fn adjust_paragraph_positions(
254254+ cached: &CachedParagraph,
255255+ char_delta: isize,
256256+ byte_delta: isize,
257257+) -> ParagraphRender {
258258+ let mut adjusted_map = cached.offset_map.clone();
259259+ for mapping in &mut adjusted_map {
260260+ mapping.char_range.start = (mapping.char_range.start as isize + char_delta) as usize;
261261+ mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize;
262262+ mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize;
263263+ mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize;
264264+ }
265265+266266+ ParagraphRender {
267267+ byte_range: (cached.byte_range.start as isize + byte_delta) as usize
268268+ ..(cached.byte_range.end as isize + byte_delta) as usize,
269269+ char_range: (cached.char_range.start as isize + char_delta) as usize
270270+ ..(cached.char_range.end as isize + char_delta) as usize,
271271+ html: cached.html.clone(),
272272+ offset_map: adjusted_map,
273273+ source_hash: cached.source_hash,
274274+ }
275275+}
276276+277277+/// Render markdown with incremental caching.
278278+///
279279+/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
280280+/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely.
281281+///
282282+/// # Arguments
283283+/// - `rope`: The document rope to render
284284+/// - `cache`: Previous render cache (if any)
285285+/// - `edit`: Information about the most recent edit (if any)
286286+///
287287+/// # Returns
288288+/// Tuple of (rendered paragraphs, updated cache)
289289+pub fn render_paragraphs_incremental(
290290+ rope: &JumpRopeBuf,
291291+ cache: Option<&RenderCache>,
292292+ edit: Option<&EditInfo>,
293293+) -> (Vec<ParagraphRender>, RenderCache) {
294294+ let source = rope.to_string();
295295+296296+ // Handle empty document
297297+ if source.is_empty() {
298298+ let empty_node_id = "n0".to_string();
299299+ let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
300300+301301+ let para = ParagraphRender {
302302+ byte_range: 0..0,
303303+ char_range: 0..0,
304304+ html: empty_html.clone(),
305305+ offset_map: vec![],
306306+ source_hash: 0,
307307+ };
308308+309309+ let new_cache = RenderCache {
310310+ paragraphs: vec![CachedParagraph {
311311+ source_hash: 0,
312312+ byte_range: 0..0,
313313+ char_range: 0..0,
314314+ html: empty_html,
315315+ offset_map: vec![],
316316+ }],
317317+ next_node_id: 1,
318318+ };
319319+320320+ return (vec![para], new_cache);
321321+ }
322322+323323+ // Determine if we can use fast path (skip boundary discovery)
324324+ let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
325325+326326+ // Get paragraph boundaries
327327+ let paragraph_ranges = if use_fast_path {
328328+ // Fast path: adjust cached boundaries based on edit
329329+ let cache = cache.unwrap();
330330+ let edit = edit.unwrap();
331331+332332+ // Find which paragraph the edit falls into
333333+ let edit_pos = edit.edit_char_pos;
334334+ let char_delta = edit.inserted_len as isize - edit.deleted_len as isize;
335335+336336+ // Adjust each cached paragraph's range
337337+ cache
338338+ .paragraphs
339339+ .iter()
340340+ .map(|p| {
341341+ if p.char_range.end <= edit_pos {
342342+ // Before edit - no change
343343+ (p.byte_range.clone(), p.char_range.clone())
344344+ } else if p.char_range.start >= edit_pos {
345345+ // After edit - shift by delta
346346+ // Calculate byte delta (approximation: assume 1 byte per char for ASCII)
347347+ // This is imprecise but boundaries are rediscovered on slow path anyway
348348+ let byte_delta = char_delta; // TODO: proper byte calculation
349349+ (
350350+ (p.byte_range.start as isize + byte_delta) as usize
351351+ ..(p.byte_range.end as isize + byte_delta) as usize,
352352+ (p.char_range.start as isize + char_delta) as usize
353353+ ..(p.char_range.end as isize + char_delta) as usize,
354354+ )
355355+ } else {
356356+ // Edit is within this paragraph - expand its end
357357+ (
358358+ p.byte_range.start..(p.byte_range.end as isize + char_delta) as usize,
359359+ p.char_range.start..(p.char_range.end as isize + char_delta) as usize,
360360+ )
361361+ }
362362+ })
363363+ .collect::<Vec<_>>()
364364+ } else {
365365+ // Slow path: run boundary-only pass to discover paragraph boundaries
366366+ let parser =
367367+ Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter();
368368+ let mut scratch_output = String::new();
369369+370370+ match EditorWriter::<_, _, ()>::new_boundary_only(
371371+ &source,
372372+ rope,
373373+ parser,
374374+ &mut scratch_output,
375375+ )
376376+ .run()
377377+ {
378378+ Ok(result) => result.paragraph_ranges,
379379+ Err(_) => return (Vec::new(), RenderCache::default()),
380380+ }
381381+ };
382382+383383+ // Render paragraphs, reusing cache where possible
384384+ let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
385385+ let mut new_cached = Vec::with_capacity(paragraph_ranges.len());
386386+ let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0);
387387+388388+ for (byte_range, char_range) in paragraph_ranges.iter() {
389389+ let para_source = rope_slice_to_string(rope, char_range.clone());
390390+ let source_hash = hash_source(¶_source);
391391+392392+ // Check if we have a cached render with matching hash
393393+ let cached_match =
394394+ cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash));
395395+396396+ let (html, offset_map) = if let Some(cached) = cached_match {
397397+ // Reuse cached HTML and offset map (adjusted for position)
398398+ let char_delta = char_range.start as isize - cached.char_range.start as isize;
399399+ let byte_delta = byte_range.start as isize - cached.byte_range.start as isize;
400400+401401+ let mut adjusted_map = cached.offset_map.clone();
402402+ for mapping in &mut adjusted_map {
403403+ mapping.char_range.start =
404404+ (mapping.char_range.start as isize + char_delta) as usize;
405405+ mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize;
406406+ mapping.byte_range.start =
407407+ (mapping.byte_range.start as isize + byte_delta) as usize;
408408+ mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize;
409409+ }
410410+411411+ (cached.html.clone(), adjusted_map)
412412+ } else {
413413+ // Fresh render needed
414414+ let para_rope = JumpRopeBuf::from(para_source.as_str());
415415+ let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options())
416416+ .into_offset_iter();
417417+ let mut output = String::new();
418418+419419+ let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset(
420420+ ¶_source,
421421+ ¶_rope,
422422+ parser,
423423+ &mut output,
424424+ node_id_offset,
425425+ )
426426+ .run()
427427+ {
428428+ Ok(result) => {
429429+ // Update node ID offset
430430+ let max_node_id = result
431431+ .offset_maps
432432+ .iter()
433433+ .filter_map(|m| {
434434+ m.node_id
435435+ .strip_prefix("n")
436436+ .and_then(|s| s.parse::<usize>().ok())
437437+ })
438438+ .max()
439439+ .unwrap_or(node_id_offset);
440440+ node_id_offset = max_node_id + 1;
441441+ result.offset_maps
442442+ }
443443+ Err(_) => Vec::new(),
444444+ };
445445+446446+ // Adjust offsets to document coordinates
447447+ let para_char_start = char_range.start;
448448+ let para_byte_start = byte_range.start;
449449+ for mapping in &mut offset_map {
450450+ mapping.byte_range.start += para_byte_start;
451451+ mapping.byte_range.end += para_byte_start;
452452+ mapping.char_range.start += para_char_start;
453453+ mapping.char_range.end += para_char_start;
454454+ }
455455+456456+ (output, offset_map)
457457+ };
458458+459459+ // Store in cache
460460+ new_cached.push(CachedParagraph {
461461+ source_hash,
462462+ byte_range: byte_range.clone(),
463463+ char_range: char_range.clone(),
464464+ html: html.clone(),
465465+ offset_map: offset_map.clone(),
466466+ });
467467+468468+ paragraphs.push(ParagraphRender {
469469+ byte_range: byte_range.clone(),
470470+ char_range: char_range.clone(),
471471+ html,
472472+ offset_map,
473473+ source_hash,
474474+ });
475475+ }
476476+477477+ // Insert gap paragraphs for EXTRA whitespace between blocks.
478478+ // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that.
479479+ // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot.
480480+ const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n
481481+482482+ let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2);
483483+ let mut prev_end_char = 0usize;
484484+ let mut prev_end_byte = 0usize;
485485+486486+ for para in paragraphs {
487487+ // Check for gap before this paragraph - only if MORE than minimum break
488488+ let gap_size = para.char_range.start.saturating_sub(prev_end_char);
489489+ if gap_size > MIN_PARAGRAPH_BREAK_INCR {
490490+ // Gap covers the EXTRA whitespace beyond the minimum break
491491+ let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR;
492492+ let gap_end_char = para.char_range.start;
493493+ let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR;
494494+ let gap_end_byte = para.byte_range.start;
495495+496496+ // Position-based ID: deterministic, stable across cache states
497497+ let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char);
498498+ let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}');
499499+500500+ paragraphs_with_gaps.push(ParagraphRender {
501501+ byte_range: gap_start_byte..gap_end_byte,
502502+ char_range: gap_start_char..gap_end_char,
503503+ html: gap_html,
504504+ offset_map: vec![OffsetMapping {
505505+ byte_range: gap_start_byte..gap_end_byte,
506506+ char_range: gap_start_char..gap_end_char,
507507+ node_id: gap_node_id,
508508+ char_offset_in_node: 0,
509509+ child_index: None,
510510+ utf16_len: 1,
511511+ }],
512512+ source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)),
513513+ });
514514+ }
515515+516516+ prev_end_char = para.char_range.end;
517517+ prev_end_byte = para.byte_range.end;
518518+ paragraphs_with_gaps.push(para);
519519+ }
520520+521521+ // Add trailing gap if needed
522522+ let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
523523+ if has_trailing_newlines {
524524+ let doc_end_char = rope.len_chars();
525525+ let doc_end_byte = rope.len_bytes();
526526+527527+ if doc_end_char > prev_end_char {
528528+ // Position-based ID for trailing gap
529529+ let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char);
530530+ let trailing_html = format!(r#"<span id="{}">{}</span>"#, trailing_node_id, '\u{200B}');
531531+532532+ paragraphs_with_gaps.push(ParagraphRender {
533533+ byte_range: prev_end_byte..doc_end_byte,
534534+ char_range: prev_end_char..doc_end_char,
535535+ html: trailing_html,
536536+ offset_map: vec![OffsetMapping {
537537+ byte_range: prev_end_byte..doc_end_byte,
538538+ char_range: prev_end_char..doc_end_char,
539539+ node_id: trailing_node_id,
540540+ char_offset_in_node: 0,
541541+ child_index: None,
542542+ utf16_len: 1,
543543+ }],
544544+ source_hash: 0,
545545+ });
546546+ }
547547+ }
548548+549549+ let new_cache = RenderCache {
550550+ paragraphs: new_cached,
551551+ next_node_id: node_id_offset,
552552+ };
553553+554554+ (paragraphs_with_gaps, new_cache)
555555+}