"] {
+ 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 ".into(), ..Default::default() },
+ Span { text: "picture".into(), image: Some("asset.png".into()), ..Default::default() },
+ ]);
+ let wanted = " \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, " ");
+}
+
+#[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).