diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index acf85796..2d5b7455 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -322,6 +322,7 @@ struct Writer { out: String, lists: Vec<(bool, usize)>, // (ordered, counter) in_pre: bool, + in_code: bool, pre_diagram: bool, /// Inside a `
`, which Mermaid's own pages use instead of a `pre`. div_diagram: bool, @@ -402,11 +403,13 @@ impl Writer { last_space = false; } } - if self.in_math { + if self.in_math || self.in_code { // Keep the TeX as written between dollar signs. self.push(&s); } else { - self.push(&s); + let quoted = crate::render::escape_markdown(&s); + let quoted = if self.out.is_empty() || self.out.ends_with('\n') { crate::render::escape_block_start("ed) } else { quoted }; + self.push("ed); } } @@ -508,7 +511,10 @@ impl Writer { ("i" | "em", _) => self.push("*"), ("s" | "del" | "strike", _) => self.push("~~"), ("u" | "sub" | "sup", closing) => self.push(&format!("<{}{name}>", if closing { "/" } else { "" })), - ("code", _) if !self.in_pre => self.push("`"), + ("code", closing) if !self.in_pre => { + self.in_code = !closing; + self.push("`"); + } ("pre", false) => { self.block_break(); // `diagram` is how NoteCraft has always written one; `mermaid` is how Mermaid's own @@ -611,7 +617,7 @@ impl Writer { self.in_cell = false; let cell = self.cell.trim().to_string(); if let Some(row) = self.row.as_mut() { - row.push(cell); + row.push(crate::render::escape_table_cell(&cell)); } } _ => {} diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 6a1fd312..f75dfaa0 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -79,7 +79,27 @@ pub fn parse_inline(text: &str) -> Vec { const MAX_DEPTH: usize = 8; +// CommonMark consumes a backslash only before ASCII punctuation. +fn escapes(chars: &[char], at: usize) -> bool { + chars.get(at) == Some(&'\\') && chars.get(at + 1).is_some_and(char::is_ascii_punctuation) +} + fn find(chars: &[char], from: usize, pat: &[char]) -> Option { + let mut at = from; + while !pat.is_empty() && at + pat.len() <= chars.len() { + if escapes(chars, at) { + at += 2; + } else if chars.get(at..at + pat.len()) == Some(pat) { + return Some(at); + } else { + at += 1; + } + } + None +} + +// Backslashes stay literal inside code and legacy link destinations. +fn find_raw(chars: &[char], from: usize, pat: &[char]) -> Option { if pat.is_empty() || chars.len() < pat.len() { return None; } @@ -208,6 +228,10 @@ fn font_size_close(chars: &[char], start: usize) -> Option { let mut depth = 1usize; let mut at = start; while at < chars.len() { + if escapes(chars, at) { + at += 2; + continue; + } if chars.get(at..at + close.len()) == Some(close.as_slice()) { depth = depth.saturating_sub(1); if depth == 0 { @@ -228,6 +252,10 @@ fn inline_tag_close(chars: &[char], start: usize, open: &[char], close: &[char]) let mut depth = 1usize; let mut at = start; while at < chars.len() { + if escapes(chars, at) { + at += 2; + continue; + } if chars.get(at..at + close.len()) == Some(close) { depth -= 1; if depth == 0 { @@ -292,14 +320,14 @@ fn inline(chars: &[char], st: Style, link: Option, depth: usize) -> Vec< } } match c { - '\\' if next.is_some() => { + '\\' if escapes(chars, i) => { if let Some(n) = next { buf.push(n); } i += 2; } '`' => { - if let Some(end) = find(chars, i + 1, &['`']) { + if let Some(end) = find_raw(chars, i + 1, &['`']) { push_text(&mut out, &mut buf, &st, &link); let code: String = slice(chars, i + 1, end).iter().collect(); out.push(Span { @@ -436,22 +464,33 @@ fn parse_link(chars: &[char], start: usize) -> Option<(String, String, usize)> { // Find the matching label bracket, not the `]` belonging to an image nested // inside the label (`[![icon](icon) name](file)`). let mut depth = 0usize; - let close = (start + 1..chars.len()).find(|&i| match chars.get(i) { - Some('[') => { - depth += 1; - false + let mut escaped = false; + let close = (start + 1..chars.len()).find(|&i| { + if escaped { + escaped = false; + return false; } - Some(']') if depth > 0 => { - depth -= 1; - false + if escapes(chars, i) { + escaped = true; + return false; + } + match chars.get(i) { + Some('[') => { + depth += 1; + false + } + Some(']') if depth > 0 => { + depth -= 1; + false + } + Some(']') => true, + _ => false, } - Some(']') => true, - _ => false, })?; if chars.get(close + 1) != Some(&'(') { return None; } - let end = find(chars, close + 2, &[')'])?; + let end = find_raw(chars, close + 2, &[')'])?; let label: String = slice(chars, start + 1, close).iter().collect(); let url: String = slice(chars, close + 2, end).iter().collect::().trim().to_string(); if url.contains(char::is_whitespace) { @@ -468,8 +507,26 @@ fn is_table_separator(line: &str) -> bool { fn cells(line: &str) -> Vec { let t = line.trim(); let t = t.strip_prefix('|').unwrap_or(t); - let t = t.strip_suffix('|').unwrap_or(t); - t.split('|').map(|c| c.trim().to_string()).collect() + let border = t.ends_with('|') && t[..t.len() - 1].chars().rev().take_while(|c| *c == '\\').count() % 2 == 0; + let t = if border { &t[..t.len() - 1] } else { t }; + let mut cells = Vec::new(); + let mut cell = String::new(); + let mut chars = t.chars().peekable(); + while let Some(c) = chars.next() { + // Table quoting is an outer layer, including inside code, maths and destinations. + if c == '\\' && chars.peek().is_some_and(|c| matches!(c, '\\' | '|')) { + if let Some(next) = chars.next() { + cell.push(next); + } + } else if c == '|' { + cells.push(cell.trim().to_string()); + cell.clear(); + } else { + cell.push(c); + } + } + cells.push(cell.trim().to_string()); + cells } fn list_marker(line: &str) -> Option<(bool, usize, usize, &str)> { diff --git a/crates/formats/src/render.rs b/crates/formats/src/render.rs index c9058e51..446f9b37 100644 --- a/crates/formats/src/render.rs +++ b/crates/formats/src/render.rs @@ -26,7 +26,7 @@ pub fn blocks_to_markdown(blocks: &[Block]) -> String { out.push('\n'); } Block::Paragraph(spans) => { - out.push_str(&spans_to_markdown(spans)); + out.push_str(&escape_block_start(&spans_to_markdown(spans))); out.push('\n'); } Block::Item { ordered, number, task, depth, spans } => { @@ -39,7 +39,7 @@ pub fn blocks_to_markdown(blocks: &[Block]) -> String { if let Some(done) = task { out.push_str(if *done { "[x] " } else { "[ ] " }); } - out.push_str(&spans_to_markdown(spans)); + out.push_str(&escape_task_box(&spans_to_markdown(spans))); out.push('\n'); } Block::Quote(spans) => { @@ -144,7 +144,7 @@ fn row_to_markdown(cells: &[Vec]) -> String { let mut s = String::from("|"); for c in cells { s.push(' '); - s.push_str(&spans_to_markdown(c).replace('|', "\\|")); + s.push_str(&escape_table_cell(&spans_to_markdown(c))); s.push_str(" |"); } s.push('\n'); @@ -153,7 +153,28 @@ fn row_to_markdown(cells: &[Vec]) -> String { /// Inline spans as Markdown. The order of the marks matches what the parser reads back. pub fn spans_to_markdown(spans: &[Span]) -> String { - marked(spans, 0) + marked(spans, 0, &Literal::for_spans(spans)) +} + +/// Quotes the table's delimiters around an already serialized inline source. +pub(crate) fn escape_table_cell(text: &str) -> String { + text.replace('\\', "\\\\").replace('|', "\\|") +} + +/// Live-editor text and a character mask identifying literal punctuation. Formatting marks +/// remain editable, while persistence-only backslashes never enter the text widget. +pub fn editable_spans(spans: &[Span]) -> (String, Vec) { + let literal = Literal { editor: true, ..Literal::default() }; + let text = marked(spans, 0, &literal); + let mut masked = spans.to_vec(); + for span in &mut masked { + if plain_text(span) { + span.text = span.text.chars().map(|c| if matches!(c, '\\' | '*' | '~' | '$' | '`' | '[' | ']' | '<') { '\0' } else { c }).collect(); + } + } + let masked = marked(&masked, 0, &literal); + let mask = text.chars().zip(masked.chars()).map(|(a, b)| a != b).collect(); + (text, mask) } /// The marks in the order they are opened: bold outermost, so `***x***` reads back the same way. @@ -171,9 +192,9 @@ fn plain_text(s: &Span) -> bool { /// Writes `spans` with the marks from `level` on. Neighbours that share a mark are written under one /// pair of it (`**a *b* c**`, not `**a **` `***b***` `** c**`), and the white space at the edge of a /// run goes outside its marks, which is what CommonMark needs for them to open and close. -fn marked(spans: &[Span], level: usize) -> String { +fn marked(spans: &[Span], level: usize, literal: &Literal) -> String { let Some((marks, has, clear)) = MARKS.get(level) else { - return spans.iter().map(leaf).collect(); + return spans.iter().map(|s| leaf(s, literal)).collect(); }; let mut out = String::new(); let mut i = 0; @@ -193,7 +214,7 @@ fn marked(spans: &[Span], level: usize) -> String { s }) .collect(); - let text = marked(&inner, level + 1); + let text = marked(&inner, level + 1, literal); let core = text.trim(); if core.is_empty() { out.push_str(&text); @@ -205,7 +226,7 @@ fn marked(spans: &[Span], level: usize) -> String { out.push_str(text.get(text.len() - trail..).unwrap_or_default()); } } else { - out.push_str(&marked(run, level + 1)); + out.push_str(&marked(run, level + 1, literal)); } i = j; } @@ -213,7 +234,7 @@ fn marked(spans: &[Span], level: usize) -> String { } /// One span with nothing but its code, picture, maths or link written round it. -fn leaf(s: &Span) -> String { +fn leaf(s: &Span, literal: &Literal) -> String { let styled = |text: String| inline_style(s, inline_marks(s, text)); if let Some(src) = &s.image { let image = format!("![{}]({src})", s.text); @@ -226,7 +247,7 @@ fn leaf(s: &Span) -> String { if s.math { return styled(format!("${}$", s.text)); } - let text = if s.code { format!("`{}`", s.text) } else { s.text.clone() }; + let text = if s.code { format!("`{}`", s.text) } else { literal.escape(&s.text) }; let text = match &s.link { Some(target) => format!("[{text}]({target})"), None => text, @@ -234,6 +255,126 @@ fn leaf(s: &Span) -> String { styled(text) } +/// Which delimiters a run of spans must quote so its words read back as words. +/// +/// The inline parser only treats `\\` before punctuation, `` ` ``, `*`, `~~`, `$`, `[`/`]` and `![` +/// as syntax, and each of those only forms markup when it has a partner (a second `$`, a closing +/// `*`, a mark written for a styled neighbour, a link's `](`). A delimiter with no possible +/// partner in the run is left alone, so ordinary text (`a_b`, `x < y`, `$5`, `2*3`) is written +/// and edited exactly as typed, and only text that would otherwise turn into markup gains a +/// backslash. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct Literal { + star: bool, + tilde: bool, + dollar: bool, + tick: bool, + bracket: bool, + tag: bool, + editor: bool, +} + +impl Literal { + /// Quote every delimiter: for text whose neighbours are not known, such as words inserted + /// into the middle of a line that is written already. + const ALL: Literal = Literal { star: true, tilde: true, dollar: true, tick: true, bracket: true, tag: true, editor: false }; + + /// The quoting needed by `spans` written together as one line. + fn for_spans(spans: &[Span]) -> Literal { + let words = || spans.iter().filter(|s| plain_text(s)).flat_map(|s| s.text.chars()); + let count = |c: char| words().filter(|&x| x == c).count(); + let emphasis = spans.iter().any(|s| plain_text(s) && (s.bold || s.italic)); + let strike = spans.iter().any(|s| plain_text(s) && s.strike); + let brackets = count('[') + count(']'); + let linked = spans.iter().any(|s| s.link.is_some() || s.image.is_some()); + // A bracket only starts a link when a `](` follows it; marks never fall between the two. + let joined: String = words().collect(); + Literal { + star: count('*') >= 2 || (count('*') >= 1 && emphasis), + tilde: count('~') >= 2 || (count('~') >= 1 && strike), + dollar: count('$') >= 2 || (count('$') >= 1 && spans.iter().any(|s| s.math)), + tick: count('`') >= 2 || (count('`') >= 1 && spans.iter().any(|s| s.code)), + editor: false, + tag: ["", "", "", "", "", "", ""] + .iter() + .any(|tag| joined.to_ascii_lowercase().contains(tag)), + bracket: (count('[') >= 1 && joined.contains("](")) || (brackets >= 1 && linked), + } + } + + /// How many characters each character of `text` takes when written: 2 if it is quoted. + fn widths(&self, text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + chars + .iter() + .enumerate() + .map(|(i, &c)| { + let quoted = match c { + // A backslash escapes the punctuation after it. One at the end of a run could + // meet a mark or a neighbour's punctuation, so it is quoted too. + '\\' => chars.get(i + 1).is_none_or(|n| n.is_ascii_punctuation()), + '<' => self.tag, + '*' => self.star, + '~' => self.tilde, + '$' => self.dollar, + '`' => self.tick, + '[' | ']' => self.bracket, + _ => false, + }; + 1 + usize::from(quoted) + }) + .collect() + } + + /// `text` with the delimiters this run needs quoted. + fn escape(&self, text: &str) -> String { + if self.editor { + return text.to_string(); + } + let mut out = String::with_capacity(text.len()); + for (c, width) in text.chars().zip(self.widths(text)) { + if width == 2 { + out.push('\\'); + } + out.push(c); + } + out + } +} + +/// Quote literal inline text whose neighbours are not known, so none of it reads as markup. +pub(crate) fn escape_markdown(text: &str) -> String { + Literal::ALL.escape(text) +} + +/// Words at the start of a list item that read like a task box (`[x] shipped`) stay words. +pub(crate) fn escape_task_box(text: &str) -> String { + if ["[ ] ", "[x] ", "[X] "].iter().any(|m| text.starts_with(m)) { format!("\\{text}") } else { text.into() } +} + +/// A paragraph must not turn into a heading, list, quote, rule or picture when serialized. +pub(crate) fn escape_block_start(text: &str) -> String { + let t = text.trim_start(); + let rule = t.trim_end(); + let hashes = t.bytes().take_while(|c| *c == b'#').count(); + let heading = (1..=6).contains(&hashes) && t.as_bytes().get(hashes) == Some(&b' '); + let digits = t.bytes().take_while(u8::is_ascii_digit).count(); + let ordered = digits > 0 && t.get(digits..).is_some_and(|s| s.starts_with(". ") || s.starts_with(") ")); + let bullet = ["- ", "+ ", "* "].iter().any(|m| t.starts_with(m)); + let ruled = rule.len() >= 3 && b"-_*".iter().any(|m| rule.bytes().all(|c| c == *m)); + let picture = rule.starts_with("'); + // A line that is a `div` tag would open or close a box around the page's blocks. + let tag = rule.starts_with("') { + let at = text.len() - t.len() + if ordered { digits } else { 0 }; + let mut out = text.to_owned(); + out.insert(at, '\\'); + out + } else { + text.into() + } +} + fn inline_marks(s: &Span, mut text: String) -> String { if s.underline { text = format!("{text}"); diff --git a/crates/formats/tests/format_parity.rs b/crates/formats/tests/format_parity.rs index 65c1c10e..4ed01695 100644 --- a/crates/formats/tests/format_parity.rs +++ b/crates/formats/tests/format_parity.rs @@ -178,3 +178,28 @@ fn every_page_model_block_round_trips_through_both_formats_and_boxes() { assert_eq!(cross_format, expected, "HTML content converted to CMGFM maps to the same semantic model"); assert_eq!(cross_layout, Some(containers), "layout survives conversion between formats"); } + +#[test] +fn all_demo_pages_keep_their_model_across_repeated_saves() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/demo/DemoNotebook"); + for notebook in ["html", "cmgfm"] { + for (path, (format, source)) in pages(&root.join(notebook)) { + let body = match format { + PageFormat::Html => notecraft_formats::html::inner(&source), + PageFormat::Cmgfm => notecraft_formats::frontmatter::split_front(&source).1, + }; + let original = boxes::read(format, &body); + let mut current = original.clone(); + let mut previous = None; + for _ in 0..3 { + let body = boxes::write(format, ¤t.0, current.1.as_deref()); + if let Some(previous) = previous { + assert_eq!(body, previous, "{notebook}/{} serialization", path.display()); + } + current = boxes::read(format, &body); + assert_eq!(current, original, "{notebook}/{} semantics", path.display()); + previous = Some(body); + } + } + } +} diff --git a/crates/formats/tests/literal_text.rs b/crates/formats/tests/literal_text.rs new file mode 100644 index 00000000..8c7e3a81 --- /dev/null +++ b/crates/formats/tests/literal_text.rs @@ -0,0 +1,125 @@ +use notecraft_formats::{Block, Span, blocks_to_html, blocks_to_markdown}; +use notecraft_formats::{html, markdown}; + +#[test] +fn literal_punctuation_and_block_starts_round_trip_in_both_formats() { + for text in + ["a * b ** c [x](y) $z$ & `tick` \\ end", "# title", "1. list", "- bullet", "---", "----", "-----", "> quote", "مرحبا mundo 世界"] + { + let blocks = vec![Block::Paragraph(vec![Span { text: text.into(), ..Default::default() }])]; + assert_eq!(markdown::parse(&blocks_to_markdown(&blocks)), blocks, "Markdown: {text}"); + assert_eq!(markdown::parse(&html::to_markdown(&blocks_to_html(&blocks))), blocks, "HTML: {text}"); + } +} + +#[test] +fn escaped_delimiters_at_style_boundaries_stay_literal() { + for span in [ + Span { text: "bold*".into(), bold: true, ..Default::default() }, + Span { text: "italic*".into(), italic: true, ..Default::default() }, + Span { text: "bracket]".into(), link: Some("https://example.org".into()), ..Default::default() }, + ] { + let blocks = vec![Block::Paragraph(vec![span])]; + assert_eq!(markdown::parse(&blocks_to_markdown(&blocks)), blocks); + assert_eq!(markdown::parse(&html::to_markdown(&blocks_to_html(&blocks))), blocks); + } +} + +#[test] +fn backslash_inside_inline_code_does_not_escape_closing_tick() { + let spans = markdown::parse_inline("`path\\` after"); + assert_eq!(spans[0].text, "path\\"); + assert!(spans[0].code); + assert_eq!(spans[1].text, " after"); +} + +#[test] +fn hyphens_with_trailing_whitespace_remain_paragraphs() { + for text in ["--- ", "----\t", " ----- "] { + let blocks = vec![Block::Paragraph(vec![Span { text: text.into(), ..Default::default() }])]; + for body in [blocks_to_markdown(&blocks), html::to_markdown(&blocks_to_html(&blocks))] { + let parsed = markdown::parse(&body); + assert!(matches!(parsed.first(), Some(Block::Paragraph(_))), "{text:?} became {parsed:?}"); + } + } +} + +#[test] +fn nonheading_hashtag_keeps_the_native_tag_prefix() { + let blocks = vec![Block::Paragraph(vec![Span { text: "#important Keep this".into(), ..Default::default() }])]; + let body = blocks_to_markdown(&blocks); + assert_eq!(body, "#important Keep this\n"); + assert_eq!(markdown::parse(&body), blocks); +} + +#[test] +fn ordinary_punctuation_is_saved_exactly_as_typed() { + for text in + ["snake_case and x < y > z", "costs $5", "2*3 is six", "C:\\Users\\reader", "~approx", "see [1]", "https://e.com/a_b", "On **File > Open**"] + { + let blocks = vec![Block::Paragraph(markdown::parse_inline(text))]; + let written = blocks_to_markdown(&blocks); + assert_eq!(written, format!("{text}\n"), "{text} must not gain backslashes"); + assert_eq!(markdown::parse(&written), blocks); + } +} + +#[test] +fn text_that_would_become_markup_is_quoted_and_reads_back() { + for text in + ["$5 or $6", "2*3*4", "a ~~b~~ c", "`a` and `b`", "[x](y)", "C:\\path\\*", "> not a quote", "* not a bullet", "___", ""] + { + let blocks = vec![Block::Paragraph(vec![Span { text: text.into(), ..Default::default() }])]; + assert_eq!(markdown::parse(&blocks_to_markdown(&blocks)), blocks, "Markdown: {text}"); + assert_eq!(markdown::parse(&html::to_markdown(&blocks_to_html(&blocks))), blocks, "HTML: {text}"); + } +} + +#[test] +fn a_backslash_before_a_letter_is_a_backslash() { + // CommonMark: only punctuation can be escaped. Pages written before quoting existed kept + // Windows paths as typed; they read back as typed. + let spans = markdown::parse_inline("C:\\Users\\reader and \\*literal\\*"); + let text: String = spans.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(text, "C:\\Users\\reader and *literal*"); +} + +#[test] +fn literal_inline_tags_survive_inside_styled_text() { + for text in ["xy", "literal words", "xy", "x"] { + for span in + [Span { text: text.into(), underline: true, ..Default::default() }, Span { text: text.into(), font_size: Some(18), ..Default::default() }] + { + let original = vec![Block::Paragraph(vec![span])]; + for format in notecraft_model::PageFormat::ALL { + let mut blocks = original.clone(); + for _ in 0..3 { + blocks = notecraft_formats::boxes::read(format, ¬ecraft_formats::boxes::write(format, &blocks, None)).0; + assert_eq!(blocks, original, "{format:?}: {text:?}"); + } + } + } + } +} + +#[test] +fn table_pipes_do_not_split_literal_or_formatted_cells() { + for span in [ + Span { text: "a|b".into(), ..Default::default() }, + Span { text: r"a\|b".into(), ..Default::default() }, + Span { text: "a|b".into(), code: true, ..Default::default() }, + Span { text: r"a\|b".into(), code: true, ..Default::default() }, + Span { text: r"a\|b".into(), math: true, ..Default::default() }, + Span { text: "label".into(), link: Some(r"path\with|pipe".into()), ..Default::default() }, + ] { + let original = vec![Block::Table { head: vec![vec![span]], rows: vec![] }]; + for format in notecraft_model::PageFormat::ALL { + let mut blocks = original.clone(); + for _ in 0..3 { + let body = notecraft_formats::boxes::write(format, &blocks, None); + blocks = notecraft_formats::boxes::read(format, &body).0; + assert_eq!(blocks, original, "{format:?}: {body}"); + } + } + } +} diff --git a/crates/formats/tests/structure_on_save.rs b/crates/formats/tests/structure_on_save.rs new file mode 100644 index 00000000..b9222396 --- /dev/null +++ b/crates/formats/tests/structure_on_save.rs @@ -0,0 +1,62 @@ +//! What a person typed stays what it was after the page is saved and opened again: words that +//! look like the syntax of a block (a task box, a heading mark, a quote mark, a box's closing tag) +//! must not turn into that block, and links written before quoting existed keep working. +use notecraft_formats::{Block, Span, boxes, markdown}; +use notecraft_model::{Container, PageFormat}; + +fn words(text: &str) -> Vec { + vec![Span { text: text.into(), ..Default::default() }] +} + +/// Saved and read back the way the app does it, in both page formats. +fn reopened(format: PageFormat, blocks: &[Block], boxes: Option<&[Container]>) -> (Vec, Option>) { + boxes::read(format, &boxes::write(format, blocks, boxes)) +} + +#[test] +fn a_windows_folder_link_stays_a_link() { + for line in ["[Folder](C:\\Notes\\)", "see [the notes](C:\\Users\\reader\\Notes\\) here"] { + let spans = markdown::parse_inline(line); + let link = spans.iter().find(|s| s.link.is_some()).unwrap_or_else(|| panic!("no link in {line:?}: {spans:?}")); + assert!(link.link.as_deref().is_some_and(|to| to.ends_with("Notes\\")), "{line:?} -> {spans:?}"); + for format in PageFormat::ALL { + let blocks = vec![Block::Paragraph(spans.clone())]; + assert_eq!(reopened(format, &blocks, None).0, blocks, "{format:?}: {line:?}"); + } + } +} + +#[test] +fn bullet_text_that_looks_like_a_task_box_stays_text() { + for format in PageFormat::ALL { + for (task, text) in [(None, "[x] shipped"), (None, "[ ] not a box"), (Some(false), "[X] still words")] { + let blocks = vec![Block::Item { ordered: false, number: 0, task, depth: 0, spans: words(text) }]; + assert_eq!(reopened(format, &blocks, None).0, blocks, "{format:?}: {text:?}"); + } + } +} + +#[test] +fn heading_and_quote_text_keep_their_leading_marks() { + for format in PageFormat::ALL { + let blocks = vec![ + Block::Heading { level: 2, spans: words("#hashtag plans") }, + Block::Quote(words("> quoted inside a quote")), + Block::Paragraph(words("after")), + ]; + assert_eq!(reopened(format, &blocks, None).0, blocks, "{format:?}"); + } +} + +#[test] +fn a_closing_box_tag_typed_in_a_box_stays_in_that_box() { + for format in PageFormat::ALL { + for text in ["
", "
"] { + let blocks = vec![Block::Paragraph(words(text)), Block::Paragraph(words("next paragraph"))]; + let one_box = [Container::new(10.0, 20.0, 300.0, 2)]; + let (read, boxes) = reopened(format, &blocks, Some(&one_box)); + assert_eq!(read, blocks, "{format:?}: {text:?}"); + assert_eq!(boxes.map(|b| b.len()), Some(1), "{format:?}: {text:?} must not split or end the box"); + } + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8875dc35..03cf7df6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -460,7 +460,7 @@ impl Editor { caret.focus = true; caret.place = Some(selection.1); if let Some(target) = self.blocks.get_mut(block) { - pageedit::set_caret_text(target, &caret.text); + caret.write(target); } } } @@ -3096,7 +3096,7 @@ impl NoteCraftApp { return result.unwrap_or_default(); } let Some(caret) = ed.caret.as_ref() else { return pageedit::FontSelection::default() }; - let mut result = pageedit::selection_fonts(&caret.text, caret.selection).unwrap_or_default(); + let mut result = ed.blocks.get(caret.block).and_then(|block| caret.fonts(block)).unwrap_or_default(); if caret.selection.0 == caret.selection.1 && let Some(pending) = ed.pending_format.as_ref().filter(|p| p.block == caret.block && p.at == caret.selection.0) { @@ -3249,8 +3249,7 @@ impl NoteCraftApp { _ => { for i in range { if let Some(b) = ed.blocks.get_mut(i).filter(|b| words(b)) { - let text = pageedit::without_marks(&pageedit::block_text(b)); - pageedit::set_block_text(b, &text); + pageedit::clear_marks(b); } } } @@ -3295,8 +3294,16 @@ impl NoteCraftApp { ed.blocks.insert(at, notecraft_formats::Block::Paragraph(Vec::new())); let marks = ed.blocks_inserted(at, 1); ed.rebuild_body(); - ed.caret = - Some(pageedit::Caret { block: at, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None }); + ed.caret = Some(pageedit::Caret { + block: at, + text: String::new(), + focus: true, + selection: (0, 0), + place: None, + cell: None, + span: None, + inline: None, + }); Self::mark_dirty_from_blocks(ed); self.save_block_marks(marks); } @@ -3946,6 +3953,7 @@ impl NoteCraftApp { place: None, cell: Some((0, 0)), span: None, + inline: None, }); Self::mark_dirty_from_blocks(ed); self.save_block_marks(marks); @@ -4036,7 +4044,7 @@ impl NoteCraftApp { }; let text = pageedit::cell_text(block, to).unwrap_or_default(); ed.rebuild_body(); - ed.caret = Some(pageedit::Caret { block: at, text, focus: true, selection: (0, 0), place: None, cell: Some(to), span: None }); + ed.caret = Some(pageedit::Caret { block: at, text, focus: true, selection: (0, 0), place: None, cell: Some(to), span: None, inline: None }); Self::mark_dirty_from_blocks(ed); } @@ -4119,6 +4127,7 @@ impl NoteCraftApp { if len > 0 && lo == 0 && hi == len && pageedit::toggle_mark(&mut ed.blocks, caret.block..caret.block + 1, mark) { if let Some(b) = ed.blocks.get(caret.block) { caret.text = pageedit::caret_text(b); + caret.inline = None; caret.selection = (0, caret.text.chars().count()); } ed.rebuild_body(); @@ -4128,7 +4137,7 @@ impl NoteCraftApp { let (text, _) = pageedit::wrap_selection(&caret.text, caret.selection, marks); caret.text = text; if let Some(b) = ed.blocks.get_mut(caret.block) { - pageedit::set_block_text(b, &caret.text); + caret.write(b); } ed.rebuild_body(); Self::mark_dirty_from_blocks(ed); @@ -4162,7 +4171,13 @@ impl NoteCraftApp { marker.push('X'); } let (probe, _) = pageedit::insert_at_caret(&caret.text, (lo, lo), &marker); - let inherited = notecraft_formats::markdown::parse_inline(&probe).into_iter().find(|span| span.text.contains(&marker)); + let inherited = ed + .blocks + .get(caret.block) + .map(|block| caret.parse(block, &probe)) + .unwrap_or_default() + .into_iter() + .find(|span| span.text.contains(&marker)); let unsafe_context = inherited.is_none_or(|span| { span.code || span.math @@ -4230,6 +4245,7 @@ impl NoteCraftApp { ed.pending_format = None; if let (Some(caret), Some(block)) = (ed.caret.as_mut(), ed.blocks.get(block)) { caret.text = pageedit::caret_text(block); + caret.inline = None; caret.selection = (0, caret.text.chars().count()); caret.focus = true; ed.restore_format_selection = Some((caret.block, caret.selection.0, caret.selection.1)); @@ -4262,7 +4278,9 @@ impl NoteCraftApp { self.status_is_error = false; return; } - let Some((text, selection)) = pageedit::format_source_selection(&caret.text, caret.selection, format) else { + let Some(caret) = ed.caret.as_mut() else { return }; + let Some(block) = ed.blocks.get(caret.block) else { return }; + let Some((text, selection)) = caret.format(block, format) else { self.status = crate::tl_id!("ui.inline_mark_whole_line").into(); self.status_is_error = false; return; @@ -4277,7 +4295,7 @@ impl NoteCraftApp { caret.focus = true; ed.restore_format_selection = Some((caret.block, selection.0, selection.1)); if let Some(block) = ed.blocks.get_mut(caret.block) { - pageedit::set_caret_text(block, &caret.text); + caret.write(block); } ed.pending_format = None; ed.rebuild_body(); @@ -4363,6 +4381,7 @@ impl NoteCraftApp { ed.pending_format = None; if let (Some(caret), Some(block)) = (ed.caret.as_mut(), ed.blocks.get(block)) { caret.text = pageedit::caret_text(block); + caret.inline = None; caret.selection = (0, caret.text.chars().count()); caret.focus = true; ed.restore_format_selection = Some((caret.block, caret.selection.0, caret.selection.1)); @@ -4446,6 +4465,7 @@ impl NoteCraftApp { } if let Some(block) = ed.blocks.get(caret.block) { caret.text = pageedit::caret_text(block); + caret.inline = None; caret.selection = (0, caret.text.chars().count()); caret.focus = true; ed.restore_format_selection = Some((caret.block, caret.selection.0, caret.selection.1)); @@ -4456,7 +4476,7 @@ impl NoteCraftApp { caret.text = text; caret.selection = selection; if let Some(block) = ed.blocks.get_mut(caret.block) { - pageedit::set_block_text(block, &caret.text); + caret.write(block); } } ed.rebuild_body(); @@ -4589,7 +4609,7 @@ impl NoteCraftApp { caret.selection = selection; caret.place = Some(selection.1); if let Some(b) = ed.blocks.get_mut(caret.block) { - pageedit::set_block_text(b, &caret.text); + caret.write(b); } ed.rebuild_body(); Self::mark_dirty_from_blocks(ed); @@ -4751,13 +4771,14 @@ impl NoteCraftApp { self.say(crate::tl_id!("ui.select_some_words_first")); return; } - let copied = pageedit::blocks_to_paste(&picked); + let Some(copied) = pageedit::copied_span(&ed.blocks, open) else { return }; self.pending_copy = Some(notecraft_formats::render::blocks_to_text(&copied)); self.pending_copy_html = Some(notecraft_formats::render::blocks_to_html(&copied)); if which == "cut" { let (block, at) = pageedit::delete_span(&mut ed.blocks, open); let text = ed.blocks.get(block).map(pageedit::caret_text).unwrap_or_default(); - ed.caret = Some(pageedit::Caret { block, text, focus: true, selection: (at, at), place: Some(at), cell: None, span: None }); + ed.caret = + Some(pageedit::Caret { block, text, focus: true, selection: (at, at), place: Some(at), cell: None, span: None, inline: None }); ed.across = None; ed.rebuild_body(); Self::mark_dirty_from_blocks(ed); @@ -4777,7 +4798,7 @@ impl NoteCraftApp { self.status_is_error = false; return; } - let copied = pageedit::blocks_to_paste(&picked); + let Some(copied) = ed.blocks.get(caret.block).and_then(|block| caret.copied_blocks(block)) else { return }; self.pending_copy = Some(notecraft_formats::render::blocks_to_text(&copied)); self.pending_copy_html = Some(notecraft_formats::render::blocks_to_html(&copied)); if which == "cut" { @@ -4787,7 +4808,7 @@ impl NoteCraftApp { caret.place = Some(at); caret.focus = true; if let Some(b) = ed.blocks.get_mut(caret.block) { - pageedit::set_block_text(b, &caret.text); + caret.write(b); } ed.rebuild_body(); Self::mark_dirty_from_blocks(ed); @@ -4815,10 +4836,8 @@ impl NoteCraftApp { fn clear_formatting(&mut self) { let Some(ed) = self.editor.as_mut() else { return }; let Some(caret) = ed.caret.as_mut() else { return }; - caret.text = pageedit::without_marks(&caret.text); - caret.selection = (0, caret.text.chars().count()); if let Some(b) = ed.blocks.get_mut(caret.block) { - pageedit::set_block_text(b, &caret.text); + caret.clear_marks(b); } ed.rebuild_body(); Self::mark_dirty_from_blocks(ed); @@ -4842,6 +4861,7 @@ impl NoteCraftApp { place: None, cell: None, span: None, + inline: None, }); Self::mark_dirty_from_blocks(ed); self.save_block_marks(marks); @@ -4859,7 +4879,7 @@ impl NoteCraftApp { caret.place = Some(at); caret.focus = true; if let Some(b) = ed.blocks.get_mut(caret.block) { - pageedit::set_block_text(b, &caret.text); + caret.write(b); } ed.rebuild_body(); Self::mark_dirty_from_blocks(ed); @@ -6996,6 +7016,7 @@ impl NoteCraftApp { place: None, cell: None, span: None, + inline: None, }); ctx.request_repaint(); Ok(json!({"block": n, "text": ed.caret.as_ref().map(|c| c.text.clone())})) @@ -7966,8 +7987,16 @@ mod alert_tests { (ed.boxes[index].x, ed.boxes[index].y) = (400.0, 2000.0); ed.arranged = true; ed.rebuild_body(); - ed.caret = - Some(pageedit::Caret { block: line, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None }); + ed.caret = Some(pageedit::Caret { + block: line, + text: String::new(), + focus: true, + selection: (0, 0), + place: None, + cell: None, + span: None, + inline: None, + }); line }; for _ in 0..3 { @@ -8047,6 +8076,7 @@ mod alert_tests { place: None, cell: None, span: None, + inline: None, }); // Made here rather than by clicks and typing, so it is remembered here too, the // way the window remembers a box the writer made. @@ -8129,6 +8159,7 @@ mod keep_boxes_tests { place: None, cell: None, span: None, + inline: None, }); ed.rebuild_body(); NoteCraftApp::remember_step(ed); @@ -8263,3 +8294,114 @@ mod keep_boxes_tests { } } } + +#[cfg(test)] +mod literal_formatting_tests { + use super::*; + use notecraft_formats::{Block, Span}; + + #[test] + fn clear_formatting_keeps_literal_punctuation_through_save_and_reopen() { + let temp = notecraft_store::testing::TempDir::new("clear-literal-formatting"); + let settings = notecraft_store::Settings::at(temp.path().join("settings.toml")); + let mut session = Session::new(temp.path(), settings).expect("session"); + let sample = notecraft_engine::sample::create_sample(&mut session, None).expect("sample"); + let text = r"*literal* $5 or $6 [x](y) C:\Users\name"; + let original = vec![Block::Paragraph(vec![Span { text: text.into(), bold: true, ..Default::default() }])]; + let body = notecraft_formats::blocks_to_html(&original); + session.run("page.save", ¶ms(&[("path", &sample.html_page.to_string_lossy()), ("body", &body)])).expect("save"); + let mut app = NoteCraftApp::new(session); + app.open_page(&sample.html_page); + let editor = app.editor.as_mut().expect("opened page"); + editor.caret = Some(pageedit::Caret { text: pageedit::caret_text(&editor.blocks[0]), ..Default::default() }); + app.clear_formatting(); + app.save_now(); + let page = notecraft_store::page::load_page(&sample.html_page).expect("reopen"); + let blocks = notecraft_formats::boxes::read(page.format, &page.body).0; + assert_eq!(blocks, vec![Block::Paragraph(vec![Span { text: text.into(), ..Default::default() }])]); + } + + fn opened_literal_page(blocks: &[Block], format: notecraft_model::PageFormat) -> (notecraft_store::testing::TempDir, NoteCraftApp, PathBuf) { + let temp = notecraft_store::testing::TempDir::new("literal-command-regression"); + let settings = notecraft_store::Settings::at(temp.path().join("settings.toml")); + let mut session = Session::new(temp.path(), settings).expect("session"); + let sample = notecraft_engine::sample::create_sample(&mut session, None).expect("sample"); + let path = match format { + notecraft_model::PageFormat::Html => sample.html_page, + notecraft_model::PageFormat::Cmgfm => sample.md_page, + }; + let body = notecraft_formats::boxes::write(format, blocks, None); + session.run("page.save", ¶ms(&[("path", &path.to_string_lossy()), ("body", &body)])).expect("save"); + let mut app = NoteCraftApp::new(session); + app.open_page(&path); + (temp, app, path) + } + + #[test] + fn clearing_cell_formatting_then_leaving_preserves_the_cell_text() { + for format in notecraft_model::PageFormat::ALL { + let text = "*literal* $5 or $6"; + let original = vec![Block::Table { + head: vec![ + vec![Span { text: text.into(), bold: true, ..Default::default() }], + vec![Span { text: "keep".into(), italic: true, ..Default::default() }], + ], + rows: Vec::new(), + }]; + let (_temp, mut app, path) = opened_literal_page(&original, format); + let ed = app.editor.as_mut().expect("editor"); + ed.caret = + Some(pageedit::Caret { text: pageedit::cell_text(&ed.blocks[0], (0, 0)).expect("cell"), cell: Some((0, 0)), ..Default::default() }); + app.clear_formatting(); + let ed = app.editor.as_mut().expect("editor"); + ed.caret.as_mut().expect("caret").write(&mut ed.blocks[0]); + assert_eq!(pageedit::cell_text(&ed.blocks[0], (0, 0)).as_deref(), Some(text)); + app.save_now(); + let page = notecraft_store::page::load_page(&path).expect("reopen"); + let blocks = notecraft_formats::boxes::read(format, &page.body).0; + let Block::Table { head, .. } = &blocks[0] else { panic!("table") }; + assert_eq!(head[0], vec![Span { text: text.into(), ..Default::default() }]); + let Block::Table { head: before, .. } = &original[0] else { panic!("table") }; + assert_eq!(head[1], before[1]); + } + } + + #[test] + fn copy_and_cut_keep_one_line_literal_text_and_rich_styles() { + for format in notecraft_model::PageFormat::ALL { + let text = "*literal* $5 or $6"; + let original = vec![Block::Paragraph(vec![Span { text: text.into(), bold: true, ..Default::default() }])]; + let (_temp, mut app, _path) = opened_literal_page(&original, format); + let ed = app.editor.as_mut().expect("editor"); + let source = pageedit::caret_text(&ed.blocks[0]); + ed.caret = Some(pageedit::Caret { selection: (0, source.chars().count()), text: source, ..Default::default() }); + app.clipboard("copy"); + assert_eq!(app.pending_copy.as_deref(), Some(text)); + assert_eq!( + notecraft_formats::markdown::parse(¬ecraft_formats::html::to_markdown(app.pending_copy_html.as_deref().expect("html"))), + original + ); + assert_eq!(app.editor.as_ref().expect("editor").blocks, original); + app.clipboard("cut"); + assert_eq!(app.pending_copy.as_deref(), Some(text)); + assert_eq!(pageedit::block_text(&app.editor.as_ref().expect("editor").blocks[0]), ""); + } + } + + #[test] + fn copying_across_blocks_keeps_literal_delimiters() { + let blocks = vec![ + Block::Paragraph(vec![Span { text: "*left*".into(), ..Default::default() }]), + Block::Paragraph(vec![Span { text: "$5 or $6".into(), ..Default::default() }]), + ]; + let (_temp, mut app, _path) = opened_literal_page(&blocks, notecraft_model::PageFormat::Html); + let ed = app.editor.as_mut().expect("editor"); + ed.across = Some(pageedit::Across { from: (0, 0), to: (1, pageedit::block_text(&ed.blocks[1]).len()) }); + app.clipboard("copy"); + assert_eq!(app.pending_copy.as_deref(), Some("*left*\n$5 or $6")); + assert_eq!( + notecraft_formats::markdown::parse(¬ecraft_formats::html::to_markdown(app.pending_copy_html.as_deref().expect("html"))), + blocks + ); + } +} diff --git a/crates/ui-egui/src/containers.rs b/crates/ui-egui/src/containers.rs index e9068d8f..4ddf6068 100644 --- a/crates/ui-egui/src/containers.rs +++ b/crates/ui-egui/src/containers.rs @@ -990,8 +990,16 @@ pub fn show( }; if let Some(target) = target { let text = pageedit::text_for_caret(blocks, target); - *caret = - Some(pageedit::Caret { block: target, text, focus: true, selection: (0, 0), place: down.then_some(0), cell: None, span: None }); + *caret = Some(pageedit::Caret { + block: target, + text, + focus: true, + selection: (0, 0), + place: down.then_some(0), + cell: None, + span: None, + inline: None, + }); object_sel = None; out.changed.caret = true; log::debug!( @@ -1009,7 +1017,16 @@ pub fn show( }); if let Some(target) = target { let text = pageedit::text_for_caret(blocks, target); - *caret = Some(pageedit::Caret { block: target, text, focus: true, selection: (0, 0), place: Some(0), cell: None, span: None }); + *caret = Some(pageedit::Caret { + block: target, + text, + focus: true, + selection: (0, 0), + place: Some(0), + cell: None, + span: None, + inline: None, + }); object_sel = None; out.changed.caret = true; log::debug!("keyboard moved after embedded object: image_block={block} caret_block={target}"); @@ -1109,7 +1126,16 @@ pub fn show( if let Some(c) = boxes.get_mut(i) { c.blocks += 1; } - *caret = Some(pageedit::Caret { block: at, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = Some(pageedit::Caret { + block: at, + text: String::new(), + focus: true, + selection: (0, 0), + place: None, + cell: None, + span: None, + inline: None, + }); out.changed.blocks = true; out.changed.caret = true; out.boxes = true; @@ -1682,7 +1708,16 @@ fn start_a_box(blocks: &mut Vec, boxes: &mut Vec, caret: &mut let box_idx = boxes.len(); boxes.push(Container { x: at.x, y: at.y, width, blocks: 1 }); log::debug!("CREATE_BOX: i={} at_pos=({:.0},{:.0}) width={:.0} holds_block={} (triggered by click)", box_idx, at.x, at.y, width, block_idx); - *caret = Some(pageedit::Caret { block: block_idx, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = Some(pageedit::Caret { + block: block_idx, + text: String::new(), + focus: true, + selection: (0, 0), + place: None, + cell: None, + span: None, + inline: None, + }); } #[cfg(test)] @@ -1879,7 +1914,7 @@ more } fn caret_at(block: usize) -> Option { - Some(Caret { block, text: String::new(), focus: false, selection: (0, 0), place: None, cell: None, span: None }) + Some(Caret { block, text: String::new(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }) } /// Issue #21: a box clicked into and left with nothing written in it goes, and the caret, diff --git a/crates/ui-egui/src/pageedit.rs b/crates/ui-egui/src/pageedit.rs index 1daf2638..0f815c23 100644 --- a/crates/ui-egui/src/pageedit.rs +++ b/crates/ui-egui/src/pageedit.rs @@ -14,7 +14,20 @@ use crate::preview; use crate::theme::Tokens; use egui::{Key, Sense, Ui, vec2}; use notecraft_formats::markdown::{Block, Span, parse_inline}; -use notecraft_formats::render::spans_to_markdown; +mod inline_edit; +pub use inline_edit::InlineEdit; + +fn spans_to_editor(spans: &[Span]) -> String { + notecraft_formats::render::editable_spans(spans).0 +} + +fn spans_of(block: &Block) -> Option<&[Span]> { + match block { + Block::Heading { spans, .. } | Block::Paragraph(spans) | Block::Quote(spans) | Block::Item { spans, .. } => Some(spans), + _ => None, + } +} + use notecraft_model::Tag; /// Where a selection that a right click collapsed is to be put back: `(block, primary, secondary)`. @@ -46,6 +59,75 @@ pub struct Caret { /// The far corner of a block of cells dragged across, with `cell` as the near one. /// `None` while one cell is being written in, which is the ordinary case. pub span: Option<(usize, usize)>, + /// Literal/image identity for this visit to the editor, independent of storage quoting. + pub inline: Option, +} + +impl Caret { + fn ensure_inline(&mut self, block: &Block) -> &mut InlineEdit { + self.inline.get_or_insert_with(|| { + self.cell.and_then(|cell| cell_spans(block, cell)).map(InlineEdit::from_spans).unwrap_or_else(|| InlineEdit::new(block, true)) + }) + } + + pub fn write(&mut self, block: &mut Block) { + let text = self.text.clone(); + if let Some(cell) = self.cell { + let spans = self.ensure_inline(block).parse(&text); + if let Some(value) = cell_spans_mut(block, cell) { + *value = spans; + } + } else { + self.ensure_inline(block).write(block, &text); + } + } + + pub fn parse(&self, block: &Block, text: &str) -> Vec { + self.inline.clone().unwrap_or_else(|| InlineEdit::new(block, true)).parse(text) + } + + /// A clipboard fragment from the semantic line, including literal/image identity. + pub fn copied_blocks(&self, block: &Block) -> Option> { + let a = self.selection.0.min(self.selection.1); + let b = self.selection.0.max(self.selection.1); + if a == b { + return None; + } + match block { + Block::Code { lang, .. } => Some(vec![Block::Code { lang: lang.clone(), text: self.text.chars().skip(a).take(b - a).collect() }]), + Block::MathBlock(_) => Some(vec![Block::MathBlock(self.text.chars().skip(a).take(b - a).collect())]), + _ => { + let mut caret = self.clone(); + let spans = caret.ensure_inline(block).selected(&self.text, a..b)?; + Some(vec![Block::Paragraph(spans)]) + } + } + } + + /// Clear only the active text/cell and hydrate the same editing target afterwards. + pub fn clear_marks(&mut self, block: &mut Block) { + self.write(block); + if let Some(cell) = self.cell { + if let Some(spans) = cell_spans_mut(block, cell) { + clear_span_marks(spans); + } + self.text = cell_text(block, cell).unwrap_or_default(); + } else { + clear_marks(block); + self.text = caret_text(block); + } + self.inline = None; + self.selection = (0, self.text.chars().count()); + } + + pub fn format(&mut self, block: &Block, format: InlineFormat) -> Option<(String, (usize, usize))> { + let (text, selection) = (self.text.clone(), self.selection); + self.ensure_inline(block).format(&text, selection, format) + } + + pub fn fonts(&self, block: &Block) -> Option { + self.inline.clone().unwrap_or_else(|| InlineEdit::new(block, true)).fonts(&self.text, self.selection) + } } /// The block of cells a caret covers, as (top-left, bottom-right), or `None` when it is @@ -321,11 +403,11 @@ pub fn selection_fonts(text: &str, selection: (usize, usize)) -> Option Option<()> { +fn insert_span_marker(spans: &mut [Span], offset: usize, marker: char, after_boundary: bool) -> Option<()> { let mut at = 0; for span in spans { let len = span.text.chars().count(); - if offset <= at + len { + if offset < at + len || (offset == at + len && !after_boundary) { let byte = span.text.char_indices().nth(offset - at).map_or(span.text.len(), |(byte, _)| byte); span.text.insert(byte, marker); return Some(()); @@ -354,9 +436,9 @@ pub fn format_source_selection(text: &str, selection: (usize, usize), format: In } let wanted = span_characters(&spans)?; let end_marker = ('\u{e000}'..='\u{f8ff}').find(|c| *c != marker && !text.contains(*c))?; - insert_span_marker(&mut spans, b, end_marker)?; - insert_span_marker(&mut spans, a, marker)?; - let serialized = spans_to_markdown(&spans); + insert_span_marker(&mut spans, b, end_marker, false)?; + insert_span_marker(&mut spans, a, marker, true)?; + let serialized = spans_to_editor(&spans); let start_byte = serialized.find(marker)?; let end_byte = serialized.find(end_marker)?; if end_byte < start_byte { @@ -516,7 +598,7 @@ pub fn set_kind(blocks: &mut [Block], at: usize, kind: &str) -> bool { } let spans = match block { Block::Heading { spans, .. } | Block::Paragraph(spans) | Block::Quote(spans) | Block::Item { spans, .. } => spans.clone(), - _ => parse_inline(&block_text(block)), + _ => vec![Span { text: block_text(block), ..Default::default() }], }; let new = match kind { "h1" => Block::Heading { level: 1, spans }, @@ -538,7 +620,11 @@ pub fn set_kind(blocks: &mut [Block], at: usize, kind: &str) -> bool { // Asking for what it already is turns it back into ordinary text, like a toggle. let same = std::mem::discriminant(&new) == std::mem::discriminant(block) && block_kind(block) == kind; if let Some(b) = blocks.get_mut(at) { - *b = if same { Block::Paragraph(parse_inline(&block_text(&new))) } else { new }; + *b = if same { + Block::Paragraph(spans_of(&new).map(<[Span]>::to_vec).unwrap_or_else(|| vec![Span { text: block_text(&new), ..Default::default() }])) + } else { + new + }; } renumber(blocks); true @@ -554,8 +640,9 @@ pub fn set_tag(blocks: &mut [Block], at: usize, tag: Tag) -> bool { } if tag == Tag::ToDo { let already = matches!(block, Block::Item { task: Some(_), .. }); - let text = Tag::set(&block_text(block), None); - let spans = parse_inline(&text); + let mut inline = InlineEdit::new(block, true); + let text = inline.text().to_owned(); + let spans = inline.parse(&text); if let Some(b) = blocks.get_mut(at) { *b = if already { Block::Paragraph(spans) } else { Block::Item { ordered: false, number: 0, task: Some(false), depth: 0, spans } }; } @@ -626,6 +713,29 @@ pub fn indent(blocks: &mut [Block], at: usize, deeper: bool) -> bool { true } +/// Removes inline formatting without interpreting literal text as Markdown source. +pub fn clear_marks(block: &mut Block) { + let spans = match block { + Block::Heading { spans, .. } | Block::Paragraph(spans) | Block::Quote(spans) | Block::Item { spans, .. } => spans, + _ => return, + }; + clear_span_marks(spans); +} + +fn clear_span_marks(spans: &mut Vec) { + let mut cleared: Vec = Vec::new(); + for span in std::mem::take(spans) { + if span.image.is_some() || span.math { + cleared.push(span); + } else if let Some(last) = cleared.last_mut().filter(|last| last.image.is_none() && !last.math) { + last.text.push_str(&span.text); + } else { + cleared.push(Span { text: span.text, ..Default::default() }); + } + } + *spans = cleared; +} + /// The same words without their marks: no bold, italic, strikethrough or code. pub fn without_marks(text: &str) -> String { let spans = parse_inline(text); @@ -806,7 +916,7 @@ pub struct Changed { /// The text of a block as it is edited: the inline marks only. pub fn block_text(block: &Block) -> String { match block { - Block::Heading { spans, .. } | Block::Paragraph(spans) | Block::Quote(spans) | Block::Item { spans, .. } => spans_to_markdown(spans), + Block::Heading { spans, .. } | Block::Paragraph(spans) | Block::Quote(spans) | Block::Item { spans, .. } => spans_to_editor(spans), Block::Code { text, .. } => text.clone(), Block::MathBlock(tex) => tex.clone(), Block::Image { alt, .. } => alt.clone(), @@ -835,45 +945,6 @@ fn inline_image_navigation_edge(text: &str, range: egui::text::CCursorRange, rig if b == a + 1 && text.chars().nth(a) == Some(INLINE_IMAGE_CHAR) { Some(if right { b } else { a }) } else { None } } -fn logical_inline_text(text: &str) -> String { - let spans = parse_inline(text); - let mut logical = String::new(); - let mut source_at = 0; - for span in spans { - let source = spans_to_markdown(std::slice::from_ref(&span)); - let Some(relative) = text.get(source_at..).and_then(|rest| rest.find(&source)) else { - return text.to_string(); - }; - let start = source_at + relative; - logical.push_str(&text[source_at..start]); - if span.image.is_some() { - logical.push(INLINE_IMAGE_CHAR); - } else { - logical.push_str(&text[start..start + source.len()]); - } - source_at = start + source.len(); - } - logical.push_str(&text[source_at..]); - logical -} - -fn markdown_from_logical_text(original: &str, logical: &str) -> String { - let images: Vec = - parse_inline(original).into_iter().filter(|span| span.image.is_some()).map(|span| spans_to_markdown(std::slice::from_ref(&span))).collect(); - let mut image = images.into_iter(); - let mut markdown = String::new(); - for character in logical.chars() { - if character == INLINE_IMAGE_CHAR { - if let Some(value) = image.next() { - markdown.push_str(&value); - } - } else { - markdown.push(character); - } - } - markdown -} - /// Clicking under the page writes at its end: in the last empty block if there is one, /// else in a new one. The canvas of containers uses it for the room under the boxes. pub fn click_below(blocks: &mut Vec, caret: &mut Option) -> bool { @@ -975,7 +1046,6 @@ fn table( ui.spacing_mut().item_spacing = egui::Vec2::ZERO; for (c, &w) in screen_widths.iter().enumerate() { let cell = (r, c); - let text = cell_text(block, cell).unwrap_or_default(); ui.allocate_ui_with_layout(vec2(w, 0.0), egui::Layout::top_down(egui::Align::LEFT), |ui| { ui.set_min_width(w); ui.set_max_width(w); @@ -987,6 +1057,8 @@ fn table( ui.set_max_width(inner); if here == Some(cell) && picked.is_none() { let Some(caret) = caret.as_mut() else { return }; + caret.ensure_inline(block); + let previous_selection = caret.selection; let id = ui.make_persistent_id(("table-cell", i, r, c)); let at = (i, caret.text.clone(), caret.selection); let out = steady_blink(ui, (at.0, &at.1, at.2), |ui| { @@ -1001,14 +1073,20 @@ fn table( out.response.request_focus(); caret.focus = false; } + if let Some(range) = out.cursor_range { + caret.selection = (range.primary.index.0, range.secondary.index.0); + } if out.response.changed() { + if let Some(inline) = caret.inline.as_mut() { + inline.update_at(&caret.text, previous_selection, caret.selection); + } action = Some(Action::SetCell(i, cell, caret.text.clone())); } } else { let strong = r == 0; let colour = if strong { t.strong } else { ui.style().visuals.text_color() }; - let spans = notecraft_formats::markdown::parse_inline(&text); - let job = preview::job(ui, &spans, 14.5 * zoom, colour, t, page); + let spans = cell_spans(block, cell).unwrap_or_default(); + let job = preview::job(ui, spans, 14.5 * zoom, colour, t, page); let resp = ui.add(egui::Label::new(job).sense(Sense::click()).selectable(false).wrap()); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); @@ -1203,24 +1281,17 @@ fn frame_block(ui: &Ui, top: f32, editing: bool, t: &Tokens, zoom: f32) { /// the marker is drawn as a mark beside the line and showing it as words again the moment /// the caret arrives makes the line jump. pub fn caret_text(block: &Block) -> String { - logical_inline_text(Tag::split(&block_text(block)).1) + InlineEdit::new(block, true).text().to_owned() } /// Puts the caret's text back into a block, keeping the tag the block already carries. pub fn set_caret_text(block: &mut Block, text: &str) { - let current = block_text(block); - let (tag, body) = Tag::split(¤t); - let text = markdown_from_logical_text(body, text); - let whole = match tag { - Some(t) => Tag::set(&text, Some(t)), - None => text.to_string(), - }; - set_block_text(block, &whole); + InlineEdit::new(block, true).write(block, text); } /// Puts edited text back into a block, keeping what the block is. pub fn set_block_text(block: &mut Block, text: &str) { - let spans = parse_inline(text); + let spans = InlineEdit::new(block, false).parse(text); match block { Block::Heading { spans: s, .. } | Block::Paragraph(s) | Block::Quote(s) | Block::Item { spans: s, .. } => *s = spans, Block::Code { text: t, .. } => *t = text.to_string(), @@ -1291,20 +1362,23 @@ pub fn is_one_line_of_words(block: &Block) -> bool { } /// The words of one cell, as they are written: (0, c) is a heading cell. -pub fn cell_text(block: &Block, cell: (usize, usize)) -> Option { +fn cell_spans(block: &Block, (row, column): (usize, usize)) -> Option<&[Span]> { let Block::Table { head, rows } = block else { return None }; - let (r, c) = cell; - let spans = if r == 0 { head.get(c)? } else { rows.get(r - 1)?.get(c)? }; - Some(spans_to_markdown(spans)) + if row == 0 { head.get(column).map(Vec::as_slice) } else { rows.get(row - 1)?.get(column).map(Vec::as_slice) } +} + +fn cell_spans_mut(block: &mut Block, (row, column): (usize, usize)) -> Option<&mut Vec> { + let Block::Table { head, rows } = block else { return None }; + if row == 0 { head.get_mut(column) } else { rows.get_mut(row - 1)?.get_mut(column) } +} + +pub fn cell_text(block: &Block, cell: (usize, usize)) -> Option { + Some(spans_to_editor(cell_spans(block, cell)?)) } -/// Puts words back into one cell. pub fn set_cell_text(block: &mut Block, cell: (usize, usize), text: &str) { - let Block::Table { head, rows } = block else { return }; - let (r, c) = cell; - let spans = if r == 0 { head.get_mut(c) } else { rows.get_mut(r - 1).and_then(|row| row.get_mut(c)) }; - if let Some(spans) = spans { - *spans = parse_inline(text); + if let Some(spans) = cell_spans_mut(block, cell) { + *spans = InlineEdit::from_spans(spans).parse(text); } } @@ -1519,7 +1593,7 @@ pub fn show_blocks( { let (block, at) = delete_span(blocks, open); let text = blocks.get(block).map(caret_text).unwrap_or_default(); - *caret = Some(Caret { block, text, focus: true, selection: (at, at), place: Some(at), cell: None, span: None }); + *caret = Some(Caret { block, text, focus: true, selection: (at, at), place: Some(at), cell: None, span: None, inline: None }); *across = None; changed.blocks = true; changed.caret = true; @@ -1553,8 +1627,11 @@ pub fn show_blocks( let (size, strong) = size_of(&block); let size = size * zoom; let Some(c) = caret.as_mut() else { continue }; + c.ensure_inline(&block); + let previous_selection = c.selection; + let inline = c.inline.clone().unwrap_or_default(); let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, width: f32| { - let job = pagetext::inline_job_with_fonts(ui.ctx(), text.as_str(), size, strong, t, width, page); + let job = inline.layout(text.as_str(), |text| pagetext::inline_job_with_fonts(ui.ctx(), text, size, strong, t, width, page)); ui.fonts_mut(|f| f.layout_job(job)) }; let mut at_start = false; @@ -1593,7 +1670,9 @@ pub fn show_blocks( if wrote_changed { let typed = c.text.clone(); if let Some(b) = blocks.get_mut(i) { - set_caret_text(b, &typed); + let selection = c.selection; + c.ensure_inline(b).update_at(&typed, previous_selection, selection); + c.write(b); } changed.blocks = true; } @@ -1844,7 +1923,9 @@ pub fn show_blocks( // follow the caret without waiting for it to leave the block. let typed = c.text.clone(); if let Some(b) = blocks.get_mut(i) { - set_caret_text(b, &typed); + let selection = c.selection; + c.ensure_inline(b).update_at(&typed, previous_selection, selection); + c.write(b); } changed.blocks = true; } @@ -2072,6 +2153,29 @@ pub fn span_text(blocks: &[Block], across: Across) -> String { .join("\n\n") } +/// A multi-block clipboard fragment without reparsing literal editor text as markup. +/// Across positions are UTF-8 byte boundaries, while InlineEdit uses character offsets. +pub fn copied_span(blocks: &[Block], across: Across) -> Option> { + let mut result = Vec::new(); + for index in across.blocks() { + let block = blocks.get(index)?; + let text = block_text(block); + let Some((a, b)) = across.covering(index, text.len()) else { continue }; + let picked = text.get(a..b)?; + let fragment = match block { + Block::Code { lang, .. } => Block::Code { lang: lang.clone(), text: picked.to_owned() }, + Block::MathBlock(_) => Block::MathBlock(picked.to_owned()), + _ => { + let a = text.get(..a)?.chars().count(); + let b = text.get(..b)?.chars().count(); + Block::Paragraph(InlineEdit::new(block, false).selected(&text, a..b)?) + } + }; + result.push(fragment); + } + (!result.is_empty()).then_some(result) +} + /// Takes out everything a selection across blocks covers and joins what is left. /// /// What is left of the first block and what is left of the last become one paragraph, the @@ -2085,8 +2189,13 @@ pub fn delete_span(blocks: &mut Vec, across: Across) -> (usize, usize) { let kept_head = head.get(..from.min(head.len())).unwrap_or("").to_string(); let kept_tail = tail.get(to.min(tail.len())..).unwrap_or("").to_string(); let at = kept_head.chars().count(); + let mut kept = blocks.get(first).map(|b| InlineEdit::new(b, false)).unwrap_or_default().slice(0..at); + let tail_start = tail.chars().count().saturating_sub(kept_tail.chars().count()); + let end = blocks.get(last).map(|b| InlineEdit::new(b, false)).unwrap_or_default(); + kept.join(end.slice(tail_start..tail.chars().count())); if let Some(b) = blocks.get_mut(first) { - set_block_text(b, &format!("{kept_head}{kept_tail}")); + let text = kept.text().to_owned(); + kept.write(b, &text); } if last > first { let end = last.min(blocks.len().saturating_sub(1)); @@ -2334,7 +2443,8 @@ pub struct Wrote { /// line reads in when it has no mark of its own, the same as when it is not being written in. pub fn write_rtl(ui: &mut Ui, c: &mut Caret, size: f32, t: &Tokens, id: egui::Id, page: Option) -> Wrote { let width = ui.available_width().max(32.0); - let job = pagetext::inline_job_with_fonts(ui.ctx(), &c.text, size, false, t, width, page); + let inline = c.inline.clone().unwrap_or_default(); + let job = inline.layout(&c.text, |text| pagetext::inline_job_with_fonts(ui.ctx(), text, size, false, t, width, page)); let pieces = job.sections.into_iter().map(|section| (section.byte_range.start.0..section.byte_range.end.0, section.format)).collect::>(); let (drawn, used) = crate::bidi::runs(ui, &c.text, &pieces, size, width, page); // The block's own id, the same one the egui text box uses for a line without @@ -2528,11 +2638,11 @@ pub(crate) fn paint_rtl_input_marker(ui: &Ui, caret: egui::Rect) { ui.painter().add(egui::Shape::convex_polygon(points, ui.visuals().text_color(), egui::Stroke::NONE)); } -fn commit(blocks: &mut [Block], caret: &Option) { +fn commit(blocks: &mut [Block], caret: &mut Option) { if let Some(c) = caret && let Some(b) = blocks.get_mut(c.block) { - set_caret_text(b, &c.text); + c.write(b); } } @@ -2541,7 +2651,8 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha match action { Action::Move(to) => { let text = text_for_caret(blocks, to); - *caret = blocks.get(to).map(|_| Caret { block: to, text, focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = + blocks.get(to).map(|_| Caret { block: to, text, focus: true, selection: (0, 0), place: None, cell: None, span: None, inline: None }); changed.caret = true; } Action::SetTask(at, done) => { @@ -2551,7 +2662,8 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha } Action::MoveTo(to, at) => { let text = text_for_caret(blocks, to); - *caret = blocks.get(to).map(|_| Caret { block: to, text, focus: true, selection: (0, 0), place: at, cell: None, span: None }); + *caret = + blocks.get(to).map(|_| Caret { block: to, text, focus: true, selection: (0, 0), place: at, cell: None, span: None, inline: None }); changed.caret = true; } Action::Leave => { @@ -2578,7 +2690,7 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha let land = (start + n).saturating_sub(1).min(blocks.len().saturating_sub(1)); let text = blocks.get(land).map(block_text).unwrap_or_default(); let end = text.chars().count(); - *caret = Some(Caret { block: land, text, focus: true, selection: (end, end), place: Some(end), cell: None, span: None }); + *caret = Some(Caret { block: land, text, focus: true, selection: (end, end), place: Some(end), cell: None, span: None, inline: None }); changed.blocks = true; changed.caret = true; } @@ -2588,7 +2700,8 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha if let Some(b) = blocks.get_mut(at) { *b = Block::Paragraph(Vec::new()); } - *caret = Some(Caret { block: at, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = + Some(Caret { block: at, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None, inline: None }); renumber(blocks); changed.blocks = true; changed.caret = true; @@ -2599,18 +2712,34 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha let cut = cut.min(text.chars().count()); let before: String = text.chars().take(cut).collect(); let after: String = text.chars().skip(cut).collect(); + let source = caret + .as_ref() + .and_then(|caret| caret.inline.clone()) + .or_else(|| blocks.get(at).map(|block| InlineEdit::new(block, true))) + .unwrap_or_default(); + let mut left = source.slice(0..cut); + let mut right = source.slice(cut..text.chars().count()); let mut new = blocks.get(at).map(next_block).unwrap_or(Block::Paragraph(Vec::new())); if let Some(b) = blocks.get_mut(at) { - set_block_text(b, &before); + left.write(b, &before); // A heading splits into a heading and a paragraph, as it does in Word. if matches!(b, Block::Heading { .. }) { new = Block::Paragraph(Vec::new()); } } - set_block_text(&mut new, &after); + right.write(&mut new, &after); blocks.insert(at + 1, new); renumber(blocks); - *caret = Some(Caret { block: at + 1, text: after, focus: true, selection: (0, 0), place: Some(0), cell: None, span: None }); + *caret = Some(Caret { + block: at + 1, + text: after, + focus: true, + selection: (0, 0), + place: Some(0), + cell: None, + span: None, + inline: Some(right), + }); changed.blocks = true; changed.caret = true; } @@ -2620,9 +2749,9 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha // The first block: a heading becomes a paragraph, nothing is lost. if let Some(Block::Heading { .. }) = blocks.first() { if let Some(b) = blocks.get_mut(0) { - *b = Block::Paragraph(parse_inline(&text)); + *b = Block::Paragraph(spans_of(b).unwrap_or_default().to_vec()); } - *caret = Some(Caret { block: 0, text, focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = Some(Caret { block: 0, text, focus: true, selection: (0, 0), place: None, cell: None, span: None, inline: None }); changed.blocks = true; } return; @@ -2630,15 +2759,30 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha let previous = blocks.get(at - 1).cloned(); match previous { Some(p) if is_writable(&p) => { - let mut joined = block_text(&p); - let at_end = joined.chars().count(); - joined.push_str(&text); + let mut source = InlineEdit::new(&p, true); + let at_end = source.text().chars().count(); + let current = caret + .as_ref() + .and_then(|caret| caret.inline.clone()) + .or_else(|| blocks.get(at).map(|block| InlineEdit::new(block, true))) + .unwrap_or_default(); + source.join(current); + let joined = source.text().to_owned(); if let Some(b) = blocks.get_mut(at - 1) { - set_block_text(b, &joined); + source.write(b, &joined); } blocks.remove(at); renumber(blocks); - *caret = Some(Caret { block: at - 1, text: joined, focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = Some(Caret { + block: at - 1, + text: joined, + focus: true, + selection: (0, 0), + place: None, + cell: None, + span: None, + inline: Some(source), + }); let _ = at_end; changed.blocks = true; changed.caret = true; @@ -2655,13 +2799,13 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha } Action::EnterCell(i, cell) => { let text = blocks.get(i).and_then(|b| cell_text(b, cell)).unwrap_or_default(); - *caret = Some(Caret { block: i, text, focus: true, selection: (0, 0), place: None, cell: Some(cell), span: None }); + *caret = Some(Caret { block: i, text, focus: true, selection: (0, 0), place: None, cell: Some(cell), span: None, inline: None }); changed.caret = true; } Action::Select(i, from, to) => { let text = blocks.get(i).and_then(|b| cell_text(b, from)).unwrap_or_default(); // No focus asked for: a block of cells is picked, not written in. - *caret = Some(Caret { block: i, text, focus: false, selection: (0, 0), place: None, cell: Some(from), span: Some(to) }); + *caret = Some(Caret { block: i, text, focus: false, selection: (0, 0), place: None, cell: Some(from), span: Some(to), inline: None }); changed.caret = true; } Action::ClearCells(i) => { @@ -2683,13 +2827,27 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha rows.push(vec![Vec::new(); columns]); changed.blocks = true; let cell = (rows.len(), 0); - *caret = Some(Caret { block: i, text: String::new(), focus: true, selection: (0, 0), place: None, cell: Some(cell), span: None }); + *caret = Some(Caret { + block: i, + text: String::new(), + focus: true, + selection: (0, 0), + place: None, + cell: Some(cell), + span: None, + inline: None, + }); changed.caret = true; } } Action::SetCell(i, cell, text) => { if let Some(b) = blocks.get_mut(i) { - set_cell_text(b, cell, &text); + if let Some(caret) = caret.as_mut().filter(|caret| caret.block == i && caret.cell == Some(cell)) { + caret.text = text; + caret.write(b); + } else { + set_cell_text(b, cell, &text); + } changed.blocks = true; } } @@ -2698,7 +2856,16 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha match last_writable { // An empty last block is where the caret goes; otherwise a new one is added. Some(i) if blocks.get(i).map(block_text).unwrap_or_default().is_empty() => { - *caret = Some(Caret { block: i, text: String::new(), focus: true, selection: (0, 0), place: None, cell: None, span: None }); + *caret = Some(Caret { + block: i, + text: String::new(), + focus: true, + selection: (0, 0), + place: None, + cell: None, + span: None, + inline: None, + }); } _ => { blocks.push(Block::Paragraph(Vec::new())); @@ -2710,6 +2877,7 @@ fn apply(action: Action, blocks: &mut Vec, caret: &mut Option, cha place: None, cell: None, span: None, + inline: None, }); changed.blocks = true; } @@ -2751,7 +2919,7 @@ fn char_under_drawn(ui: &Ui, block: &Block, resp: &egui::Response, pos: egui::Po }; // The walk below counts the marks span by span. That count is only right when the marks // it counts are the ones the caret's text really has, so the two are compared first. - if spans_to_markdown(&spans) != caret_text(block) { + if spans_to_editor(&spans) != caret_text(block) { return None; } let drawn: String = spans.iter().map(|s| s.text.as_str()).collect(); @@ -2774,6 +2942,21 @@ fn char_under_drawn(ui: &Ui, block: &Block, resp: &egui::Response, pos: egui::Po /// A character of the drawn words, as a character of the same line written with its marks. fn over_marks(spans: &[Span], drawn: usize) -> usize { + let source = spans_to_editor(spans); + if drawn >= spans.iter().map(|span| span.text.chars().count()).sum() { + return source.chars().count(); + } + if let Some(marker) = ('\u{e000}'..='\u{f8ff}').find(|c| !source.contains(*c)) { + let mut marked = spans.to_vec(); + if insert_span_marker(&mut marked, drawn, marker, true).is_some() { + let probe = spans_to_editor(&marked); + if probe.replace(marker, "") == source + && let Some(at) = probe.find(marker) + { + return probe[..at].chars().count(); + } + } + } let mut seen = 0usize; let mut marked = 0usize; for s in spans { @@ -3079,7 +3262,7 @@ mod tests { let spans = parse_inline(line); let drawn: String = spans.iter().map(|s| s.text.as_str()).collect(); assert_eq!(drawn, "see the format page and this too", "what the page shows"); - assert_eq!(spans_to_markdown(&spans), line, "and what the caret writes in"); + assert_eq!(spans_to_editor(&spans), line, "and what the caret writes in"); // Every drawn character comes back as the same character in the marked line. let marked: Vec = line.chars().collect(); @@ -3365,7 +3548,7 @@ A note to self. #[test] fn a_block_of_cells_is_the_same_block_whichever_way_it_was_dragged() { - let caret = |cell, span| Caret { block: 0, text: String::new(), focus: false, selection: (0, 0), place: None, cell, span }; + let caret = |cell, span| Caret { block: 0, text: String::new(), focus: false, selection: (0, 0), place: None, cell, span, inline: None }; assert_eq!(cell_range(&caret(Some((1, 0)), Some((2, 3)))), Some(((1, 0), (2, 3)))); assert_eq!(cell_range(&caret(Some((2, 3)), Some((1, 0)))), Some(((1, 0), (2, 3))), "dragged back the other way"); assert_eq!(cell_range(&caret(Some((2, 0)), Some((1, 3)))), Some(((1, 0), (2, 3))), "and across the diagonal"); @@ -3482,7 +3665,8 @@ A note to self. #[test] fn splitting_joining_and_indenting() { let mut b = blocks("- one\n- two\n"); - let mut caret = Some(Caret { block: 0, text: "one".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 0, text: "one".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }); let mut changed = Changed::default(); apply(Action::Split(0), &mut b, &mut caret, &mut changed); assert_eq!(b.len(), 3, "a new item after the first"); @@ -3491,7 +3675,8 @@ A note to self. // Enter in the middle of a line splits it; the caret goes with the second half. let mut split = blocks("one two\n"); - let mut caret = Some(Caret { block: 0, text: "one two".into(), focus: false, selection: (3, 3), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 0, text: "one two".into(), focus: false, selection: (3, 3), place: None, cell: None, span: None, inline: None }); apply(Action::Split(0), &mut split, &mut caret, &mut changed); assert_eq!(block_text(split.first().expect("first")), "one"); assert_eq!(block_text(split.get(1).expect("second")), " two"); @@ -3499,7 +3684,8 @@ A note to self. // Enter at the very start pushes the words down and leaves an empty line above. let mut start = blocks("words\n"); - let mut caret = Some(Caret { block: 0, text: "words".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 0, text: "words".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }); apply(Action::Split(0), &mut start, &mut caret, &mut changed); assert_eq!(start.len(), 2); assert_eq!(block_text(start.first().expect("first")), "", "an empty line above"); @@ -3508,7 +3694,8 @@ A note to self. // Enter at the end of a heading starts a paragraph, not another heading. let mut head = blocks("# Title\n"); - let mut caret = Some(Caret { block: 0, text: "Title".into(), focus: false, selection: (5, 5), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 0, text: "Title".into(), focus: false, selection: (5, 5), place: None, cell: None, span: None, inline: None }); apply(Action::Split(0), &mut head, &mut caret, &mut changed); assert!(matches!(head.first(), Some(Block::Heading { .. }))); assert!(matches!(head.get(1), Some(Block::Paragraph(_))), "{:?}", head.get(1)); @@ -3524,7 +3711,8 @@ A note to self. // Backspace at the start joins the text onto the block above. let mut two = blocks("first\n\nsecond\n"); - let mut caret = Some(Caret { block: 1, text: "second".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 1, text: "second".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }); apply(Action::JoinBack(1), &mut two, &mut caret, &mut changed); assert_eq!(two.len(), 1); assert_eq!(block_text(two.first().expect("one")), "firstsecond"); @@ -3532,7 +3720,8 @@ A note to self. // Backspace at the start of the first block turns a heading into a paragraph. let mut head = blocks("# Title\n"); - let mut caret = Some(Caret { block: 0, text: "Title".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 0, text: "Title".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }); apply(Action::JoinBack(0), &mut head, &mut caret, &mut changed); assert!(matches!(head.first(), Some(Block::Paragraph(_)))); assert_eq!(block_text(head.first().expect("one")), "Title"); @@ -3793,11 +3982,13 @@ code #[test] fn the_caret_never_points_outside_the_page() { let mut b = blocks("one\n"); - let mut caret = Some(Caret { block: 9, text: "junk".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 9, text: "junk".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }); let mut changed = Changed::default(); apply(Action::Move(5), &mut b, &mut caret, &mut changed); assert_eq!(caret, None, "a move to a block that is not there leaves no caret"); - let mut caret = Some(Caret { block: 0, text: "one".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None }); + let mut caret = + Some(Caret { block: 0, text: "one".into(), focus: false, selection: (0, 0), place: None, cell: None, span: None, inline: None }); apply(Action::JoinBack(0), &mut b, &mut caret, &mut changed); assert_eq!(b.len(), 1); } @@ -3838,3 +4029,6 @@ code assert!(matches!(blocks[1], Block::Heading { .. })); } } + +#[cfg(test)] +mod regression_tests; diff --git a/crates/ui-egui/src/pageedit/inline_edit.rs b/crates/ui-egui/src/pageedit/inline_edit.rs new file mode 100644 index 00000000..bffcd1da --- /dev/null +++ b/crates/ui-egui/src/pageedit/inline_edit.rs @@ -0,0 +1,326 @@ +//! Retains literal punctuation and atomic images while the existing Markdown-style editor +//! edits its visible source. Storage quoting belongs to the formats crate, never the widget. + +use std::collections::BTreeMap; +use std::ops::Range; + +use notecraft_formats::markdown::{Block, Span, parse_inline}; +use notecraft_formats::render::editable_spans; +use notecraft_model::Tag; + +use super::{FontSelection, INLINE_IMAGE_CHAR, InlineFormat, block_text, format_source_selection, selection_fonts, spans_to_editor}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InlineEdit { + text: String, + literal: Vec, + images: Vec<(usize, Span)>, +} + +#[derive(Clone)] +enum Atom { + Literal(char), + Image(Span), +} + +type Projection = (String, Vec<(char, Atom)>); + +impl InlineEdit { + pub(super) fn from_spans(spans: &[Span]) -> Self { + let (text, literal) = editable_spans(spans); + Self { text, literal, images: Vec::new() } + } + + pub(super) fn new(block: &Block, caret: bool) -> Self { + let fallback = [Span { text: block_text(block), ..Default::default() }]; + let spans = super::spans_of(block).unwrap_or(&fallback); + let mut result = Self::from_spans(spans); + if caret { + let prefix = result.text.chars().count() - Tag::split(&result.text).1.chars().count(); + result = result.slice(prefix..result.literal.len()); + let mut after = 0; + for span in spans.iter().filter(|span| span.image.is_some()) { + let source = spans_to_editor(std::slice::from_ref(span)); + let found = result.text.get(after..).and_then(|rest| { + rest.match_indices(&source).map(|(relative, _)| after + relative).find(|start| { + let a = result.text[..*start].chars().count(); + !result.literal[a..a + source.chars().count()].iter().any(|literal| *literal) + }) + }); + if let Some(start) = found { + let a = result.text[..start].chars().count(); + let b = a + source.chars().count(); + result.replace_metadata(a..b, 1); + result.text.replace_range(start..start + source.len(), &INLINE_IMAGE_CHAR.to_string()); + result.images.push((a, span.clone())); + after = start + INLINE_IMAGE_CHAR.len_utf8(); + } + } + } + result + } + + pub(super) fn text(&self) -> &str { + &self.text + } + + pub(super) fn slice(&self, range: Range) -> Self { + let start = range.start.min(self.literal.len()); + let end = range.end.min(self.literal.len()).max(start); + Self { + text: self.text.chars().skip(start).take(end - start).collect(), + literal: self.literal[start..end].to_vec(), + images: self.images.iter().filter(|(at, _)| *at >= start && *at < end).map(|(at, span)| (at - start, span.clone())).collect(), + } + } + + pub(super) fn join(&mut self, other: Self) { + let offset = self.literal.len(); + self.text.push_str(&other.text); + self.literal.extend(other.literal); + self.images.extend(other.images.into_iter().map(|(at, span)| (at + offset, span))); + } + + fn replace_metadata(&mut self, range: Range, inserted: usize) { + self.literal.splice(range.clone(), std::iter::repeat_n(false, inserted)); + self.images.retain_mut(|(at, _)| { + if *at >= range.end { + *at = *at - (range.end - range.start) + inserted; + true + } else { + *at < range.start + } + }); + } + + fn update(&mut self, text: &str) { + if self.text == text { + return; + } + let before: Vec = self.text.chars().collect(); + let after: Vec = text.chars().collect(); + let prefix = before.iter().zip(&after).take_while(|(a, b)| a == b).count(); + let suffix = before[prefix..].iter().rev().zip(after[prefix..].iter().rev()).take_while(|(a, b)| a == b).count(); + self.replace_metadata(prefix..before.len() - suffix, after.len() - prefix - suffix); + self.text = text.to_owned(); + } + + /// Use the widget's actual replacement range, including equal characters at its edges. + /// A text diff alone cannot distinguish replacing '*literal*' from retaining its stars. + pub(super) fn update_at(&mut self, text: &str, before_selection: (usize, usize), after_selection: (usize, usize)) { + let before: Vec = self.text.chars().collect(); + let after: Vec = text.chars().collect(); + let mut a = before_selection.0.min(before_selection.1).min(before.len()); + let mut b = before_selection.0.max(before_selection.1).min(before.len()); + if a == b && after.len() < before.len() { + a = after_selection.0.min(after_selection.1).min(before.len()); + b = (a + before.len() - after.len()).min(before.len()); + } + if let Some(inserted) = after.len().checked_add(b - a).and_then(|len| len.checked_sub(before.len())) + && after.get(..a) == before.get(..a) + && after.get(a + inserted..) == before.get(b..) + { + self.replace_metadata(a..b, inserted); + self.text = text.to_owned(); + return; + } + self.update(text); + } + + // A temporary single-character projection lets existing parsing/formatting keep its + // character offsets. Choose markers absent from user text, and restore before output. + fn project(&mut self, text: &str) -> Option { + self.update(text); + let mut mapping = Vec::new(); + let mut available = + ('\u{e000}'..='\u{f8ff}').chain('\u{f0000}'..='\u{ffffd}').chain('\u{100000}'..='\u{10fffd}').filter(|c| !text.contains(*c)); + let mut projected = String::new(); + for (at, (c, literal)) in text.chars().zip(&self.literal).enumerate() { + let image = self.images.iter().find(|(index, _)| *index == at).map(|(_, span)| span); + if let Some(image) = image { + let marker = available.next()?; + mapping.push((marker, Atom::Image(image.clone()))); + projected.push(marker); + } else if *literal { + if let Some((marker, _)) = mapping.iter().find(|(_, atom)| matches!(atom, Atom::Literal(original) if *original == c)) { + projected.push(*marker); + } else { + let marker = available.next()?; + mapping.push((marker, Atom::Literal(c))); + projected.push(marker); + } + } else { + projected.push(c); + } + } + Some((projected, mapping)) + } + + fn restored_char(c: char, mapping: &[(char, Atom)]) -> char { + match mapping.iter().find(|(marker, _)| *marker == c).map(|(_, atom)| atom) { + Some(Atom::Literal(original)) => *original, + Some(Atom::Image(_)) => INLINE_IMAGE_CHAR, + None => c, + } + } + + fn restore(text: &str, mapping: &[(char, Atom)]) -> String { + text.chars().map(|c| Self::restored_char(c, mapping)).collect() + } + + fn restore_spans(spans: Vec, mapping: &[(char, Atom)]) -> Vec { + let mut result = Vec::new(); + for span in spans { + if span.text.is_empty() { + result.push(span); + continue; + } + let mut text = String::new(); + for c in span.text.chars() { + if let Some((_, Atom::Image(image))) = mapping.iter().find(|(marker, _)| *marker == c) { + if !text.is_empty() { + result.push(Span { text: std::mem::take(&mut text), ..span.clone() }); + } + result.push(image.clone()); + } else { + text.push(Self::restored_char(c, mapping)); + } + } + if !text.is_empty() { + result.push(Span { text, ..span }); + } + } + result + } + + // Collision-free fallback for text containing every private-use marker. + fn escaped_source(&self) -> String { + let mut source = String::new(); + for (at, (c, literal)) in self.text.chars().zip(&self.literal).enumerate() { + if let Some((_, image)) = self.images.iter().find(|(index, _)| *index == at) { + source.push_str(¬ecraft_formats::render::spans_to_markdown(std::slice::from_ref(image))); + } else { + if *literal { + source.push('\\'); + } + source.push(c); + } + } + source + } + + fn parse_tagged(&mut self, text: &str, tag: Option) -> Vec { + if let Some((projected, mapping)) = self.project(text) { + let source = tag.map_or(projected.clone(), |tag| Tag::set(&projected, Some(tag))); + Self::restore_spans(parse_inline(&source), &mapping) + } else { + let source = self.escaped_source(); + parse_inline(&tag.map_or(source.clone(), |tag| Tag::set(&source, Some(tag)))) + } + } + + /// Select from the complete projected line so surrounding formatting still applies. + /// Markers are absent from the input and are discarded before anything leaves this helper. + pub(super) fn selected(&mut self, text: &str, range: Range) -> Option> { + let (projected, mapping) = self.project(text)?; + let chars: Vec = projected.chars().collect(); + let start = range.start.min(chars.len()); + let end = range.end.min(chars.len()); + if start >= end { + return None; + } + let mut markers = ('\u{e000}'..='\u{f8ff}').chain('\u{f0000}'..='\u{ffffd}').filter(|c| !projected.contains(*c)); + let open = markers.next()?; + let close = markers.next()?; + let mut marked = String::new(); + for at in 0..=chars.len() { + if at == start { + marked.push(open); + } + if at == end { + marked.push(close); + } + if let Some(c) = chars.get(at) { + marked.push(*c); + } + } + let mut selected = Vec::new(); + let mut inside = false; + let mut opened = false; + let mut closed = false; + for span in parse_inline(&marked) { + let mut text = String::new(); + for c in span.text.chars() { + if c == open { + inside = true; + opened = true; + } else if c == close { + inside = false; + closed = true; + } else if inside { + text.push(c); + } + } + if !text.is_empty() { + selected.push(Span { text, ..span }); + } + } + let spans = Self::restore_spans(selected, &mapping); + (opened && closed && !spans.is_empty()).then_some(spans) + } + + pub(super) fn parse(&mut self, text: &str) -> Vec { + self.parse_tagged(text, None) + } + + pub(super) fn write(&mut self, block: &mut Block, text: &str) { + let tag = Tag::split(&block_text(block)).0; + let spans = self.parse_tagged(text, tag); + match block { + Block::Heading { spans: value, .. } | Block::Paragraph(value) | Block::Quote(value) | Block::Item { spans: value, .. } => *value = spans, + Block::Code { text: value, .. } | Block::MathBlock(value) => *value = text.to_owned(), + Block::Image { alt, .. } => *alt = text.to_owned(), + Block::Table { .. } | Block::Rule => {} + } + } + + pub(super) fn format(&mut self, text: &str, selection: (usize, usize), format: InlineFormat) -> Option<(String, (usize, usize))> { + if text.contains(INLINE_IMAGE_CHAR) { + return None; + } + let (projected, mapping) = self.project(text)?; + let (formatted, selection) = format_source_selection(&projected, selection, format)?; + self.literal = formatted.chars().map(|c| mapping.iter().any(|(marker, _)| *marker == c)).collect(); + self.text = Self::restore(&formatted, &mapping); + Some((self.text.clone(), selection)) + } + + pub(super) fn fonts(&self, text: &str, selection: (usize, usize)) -> Option { + if text.contains(INLINE_IMAGE_CHAR) { + return None; + } + let (projected, _) = self.clone().project(text)?; + selection_fonts(&projected, selection) + } + + pub(super) fn layout(&self, text: &str, layout: impl FnOnce(&str) -> egui::text::LayoutJob) -> egui::text::LayoutJob { + let Some((projected, mapping)) = self.clone().project(text) else { + return layout(text); + }; + let mut job = layout(&projected); + let restored = Self::restore(&job.text, &mapping); + let offsets: BTreeMap = job + .text + .char_indices() + .map(|(byte, _)| byte) + .chain([job.text.len()]) + .zip(restored.char_indices().map(|(byte, _)| byte).chain([restored.len()])) + .collect(); + for section in &mut job.sections { + section.byte_range.start.0 = offsets.get(§ion.byte_range.start.0).copied().unwrap_or(restored.len()); + section.byte_range.end.0 = offsets.get(§ion.byte_range.end.0).copied().unwrap_or(restored.len()); + } + job.text = restored; + job + } +} diff --git a/crates/ui-egui/src/pageedit/regression_tests.rs b/crates/ui-egui/src/pageedit/regression_tests.rs new file mode 100644 index 00000000..515c05a2 --- /dev/null +++ b/crates/ui-egui/src/pageedit/regression_tests.rs @@ -0,0 +1,161 @@ +//! Regression coverage for literal text at persistence and live-editing boundaries. +use super::*; +use notecraft_model::PageFormat; + +fn literal(text: &str) -> Block { + Block::Paragraph(vec![Span { text: text.into(), ..Default::default() }]) +} + +#[test] +fn entering_and_leaving_literal_text_does_not_add_storage_escapes() { + let original = r"世界 *literal* $5 or $6 [x](y) C:\Users\angel \*"; + let mut block = literal(original); + for suffix in ["!", "!!", "!!!"] { + assert_eq!(caret_text(&block), format!("{original}{}", "!".repeat(suffix.len() - 1))); + set_caret_text(&mut block, &format!("{original}{suffix}")); + assert_eq!(block, literal(&format!("{original}{suffix}"))); + for format in PageFormat::ALL { + assert_eq!(preview::blocks_for(format, &to_body(format, &[block.clone()])), [block.clone()]); + } + } +} + +#[test] +fn literal_image_syntax_stays_text_beside_a_real_image() { + let mut block = Block::Paragraph(vec![ + Span { text: "![picture](asset.png) ".into(), ..Default::default() }, + Span { text: "picture".into(), image: Some("asset.png".into()), ..Default::default() }, + ]); + let wanted = "![picture](asset.png) \u{fffc}"; + assert_eq!(caret_text(&block), wanted); + set_caret_text(&mut block, &format!("{wanted}!")); + let Block::Paragraph(spans) = block else { panic!("paragraph") }; + assert_eq!(spans.iter().filter(|s| s.image.is_some()).count(), 1); + assert_eq!(spans[0].text, "![picture](asset.png) "); +} + +#[test] +fn unicode_click_offsets_include_font_tags_but_no_literal_escapes() { + let spans = vec![ + Span { text: "*literal* ".into(), ..Default::default() }, + Span { text: "مرحبا".into(), font_size: Some(18), underline: true, ..Default::default() }, + ]; + let source = block_text(&Block::Paragraph(spans.clone())); + for (at, character) in "*literal* مرحبا".chars().enumerate() { + assert_eq!(source.chars().nth(over_marks(&spans, at)), Some(character), "character {at}"); + } +} + +#[test] +fn changing_kind_and_task_tag_preserves_literal_spans() { + let original = literal(r"*literal* $5 or $6 [x](y) C:\Users\name"); + let mut blocks = vec![original.clone()]; + assert!(set_kind(&mut blocks, 0, "bullet")); + assert!(set_kind(&mut blocks, 0, "bullet")); + assert_eq!(blocks.as_slice(), std::slice::from_ref(&original)); + assert!(set_tag(&mut blocks, 0, Tag::ToDo)); + assert!(set_tag(&mut blocks, 0, Tag::ToDo)); + assert_eq!(blocks, [original]); +} + +#[test] +fn splitting_and_joining_preserves_both_literal_halves() { + let original = "before *literal* $5 or $6"; + let mut blocks = vec![literal(original)]; + let mut caret = Some(Caret { text: original.into(), selection: (7, 7), ..Default::default() }); + let mut changed = Changed::default(); + apply(Action::Split(0), &mut blocks, &mut caret, &mut changed); + assert_eq!(blocks, [literal("before "), literal("*literal* $5 or $6")]); + apply(Action::JoinBack(1), &mut blocks, &mut caret, &mut changed); + assert_eq!(block_text(&blocks[0]), original); + let Block::Paragraph(spans) = &blocks[0] else { panic!("paragraph") }; + assert!(spans.iter().all(|s| !s.italic && !s.math && s.link.is_none())); +} + +#[test] +fn editing_and_reentering_a_table_cell_preserves_literal_text() { + let original = r"*literal* C:\Users\name $5 or $6"; + let mut blocks = vec![Block::Table { head: vec![vec![Span { text: original.into(), ..Default::default() }]], rows: vec![] }]; + let mut caret = None; + let mut changed = Changed::default(); + for suffix in ["!", "!!"] { + apply(Action::EnterCell(0, (0, 0)), &mut blocks, &mut caret, &mut changed); + let text = format!("{original}{suffix}"); + caret.as_mut().expect("cell caret").text = text.clone(); + apply(Action::SetCell(0, (0, 0), text.clone()), &mut blocks, &mut caret, &mut changed); + apply(Action::Leave, &mut blocks, &mut caret, &mut changed); + assert_eq!(cell_text(&blocks[0], (0, 0)), Some(text.clone())); + let Block::Table { head, .. } = &blocks[0] else { panic!("table") }; + assert_eq!(head[0], [Span { text, ..Default::default() }]); + } +} + +fn frame(ctx: &egui::Context, blocks: &mut Vec, caret: &mut Option, events: Vec) { + let tokens = crate::theme::Theme { mode: crate::theme::Mode::Light, accent: crate::theme::Accent::Default }.tokens(); + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| { + show(ui, blocks, caret, std::path::Path::new("."), &tokens, None, false, &mut Default::default(), &Default::default(), &mut None, ""); + }); + }); + output.textures_delta.clear(); + fn check(shape: &egui::Shape) { + match shape { + egui::Shape::Text(text) if text.galley.job.text.contains("*literal*") => { + assert!(text.galley.job.sections.iter().all(|s| !s.format.italics), "literal stars must stay literal in the widget"); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + check(shape) + } + } + _ => {} + } + } + for shape in &output.shapes { + check(&shape.shape) + } +} + +#[test] +fn the_real_widget_keeps_literals_across_edits_and_accepts_new_markdown() { + let original = "世界 *literal* $5 or $6 [x](y) "; + let mut blocks = vec![literal(original)]; + let mut caret = Some(Caret { text: caret_text(&blocks[0]), focus: true, ..Default::default() }); + let ctx = egui::Context::default(); + frame(&ctx, &mut blocks, &mut caret, vec![]); + for c in "**bold**".chars() { + frame(&ctx, &mut blocks, &mut caret, vec![egui::Event::Text(c.to_string())]); + } + let mut changed = Changed::default(); + apply(Action::Leave, &mut blocks, &mut caret, &mut changed); + let Block::Paragraph(spans) = &blocks[0] else { panic!("paragraph") }; + assert_eq!(spans.iter().map(|s| s.text.as_str()).collect::(), format!("{original}bold")); + assert!(spans.iter().any(|s| s.bold && s.text == "bold")); + apply(Action::Move(0), &mut blocks, &mut caret, &mut changed); + assert_eq!(caret.as_ref().expect("returned caret").text, format!("{original}**bold**")); +} + +#[test] +fn replacing_a_selected_literal_uses_the_new_characters_identity() { + let mut blocks = vec![literal("*literal*")]; + let mut caret = Some(Caret { text: caret_text(&blocks[0]), focus: true, ..Default::default() }); + let ctx = egui::Context::default(); + frame(&ctx, &mut blocks, &mut caret, vec![]); + ctx.data_mut(|data| data.insert_temp(restore_selection_id(), (0usize, 0usize, 9usize))); + frame(&ctx, &mut blocks, &mut caret, vec![]); + frame(&ctx, &mut blocks, &mut caret, vec![egui::Event::Text("**bold**".into())]); + let Block::Paragraph(spans) = &blocks[0] else { panic!("paragraph") }; + assert_eq!(spans.as_slice(), &[Span { text: "bold".into(), bold: true, ..Default::default() }]); +} + +#[test] +fn deleting_across_blocks_keeps_literal_identity_from_the_last_block() { + let literal = "*literal* $5 or $6"; + let mut blocks = vec![ + Block::Paragraph(vec![Span { text: "before left".into(), ..Default::default() }]), + Block::Paragraph(vec![Span { text: literal.into(), ..Default::default() }]), + ]; + let (block, at) = delete_span(&mut blocks, Across { from: (0, "before ".len()), to: (1, 0) }); + assert_eq!((block, at), (0, "before ".chars().count())); + assert_eq!(blocks, vec![Block::Paragraph(vec![Span { text: format!("before {literal}"), ..Default::default() }])]); +} diff --git a/docs/testing-literal-text.md b/docs/testing-literal-text.md new file mode 100644 index 00000000..be89fa4f --- /dev/null +++ b/docs/testing-literal-text.md @@ -0,0 +1,34 @@ +# Literal text and editor regression checks + +The page writers quote literal text only where the supported Markdown syntax would otherwise change it. The live editor retains literal and inline-image identity separately, so storage escapes do not become new characters when a line takes focus. New Markdown typed during an edit still formats normally. + +## Automated checks + +Use the repository toolchain pin, including rustfmt and clippy. + +```sh +cargo test -p notecraft-formats --locked +cargo test -p notecraft-ui-egui --lib --locked +cargo clippy -p notecraft-formats -p notecraft-ui-egui --all-targets -- -D warnings +cargo xtask ci +``` + +The format suite covers literal delimiters, backslashes before letters and punctuation, styled boundaries, table pipes, native block prefixes and box boundaries. The demo parity suite reads all bundled pages and checks three successive saves. Editor tests cover actual text widgets, re-entry, exact replacement ranges, Unicode selections, inline images, table cells, split/join, cross-block deletion, tags, block kinds, Clear Formatting and clipboard fragments. Clipboard fragment tests inspect the queued product output and do not read or write the system clipboard. + +When comparing branches, use separate Cargo target directories. A shared target can reuse a same-name workspace crate from another checkout based on source timestamps. If seeding cached third-party artifacts, remove all workspace-owned package artifacts before testing the new checkout. + +## Manual acceptance + +1. Create a disposable HTML page. In its code view, put this paragraph inside the body: + +```html +

Cost $5 or $6, *literal*, [x](y), C:\Users\reader

+``` + +2. Return to the page, click that line, append words, leave it and click it again. The literal punctuation and path stay unchanged; persistence backslashes must not appear in the working text. +3. Select and replace a literal run, then type a new bold or italic run. The replacement uses its new characters, while untouched literal text keeps its identity. Check a mixed Unicode line and a line using the font controls. +4. Split and join the line, switch its block kind or tag, and clear formatting. Copy and Cut selections within and across paragraphs. The selected words must reach the destination, including their literal punctuation. +5. Put literal delimiters in a table cell. Edit it, clear its formatting, move to another cell and return. Its text must remain present. +6. Save, close and reopen three times. Repeat with a CMGFM page and with text inside a positioned box. Confirm the same text and layout on every cycle. + +This change does not extend the existing inline-code grammar to code spans containing embedded backticks. That independent parser limitation remains. diff --git a/docs/testing.md b/docs/testing.md index f5e8094a..d6c40efc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -116,3 +116,5 @@ No test reads or writes the system clipboard. The app takes its clipboard as a b - macOS and Linux: the code avoids OS features, but it has only been built and run on Windows. - Real cloud sync, beyond the one OneDrive test recorded in the prototype's README. - Long documents and very large attachments. + +For focused serialization and editor checks, see [Literal text regression checks](testing-literal-text.md).