From 0349fcf38b8e880139b5a75b6bd950c88bcbf41f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:56:13 +0000 Subject: [PATCH 1/9] Paste: drop marker characters from text pasted from other apps --- crates/engine/src/cmd/edit.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8bf12f06f..d432aa798 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -81,7 +81,16 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< }); } let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; - s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true})) + let text = outside_text(&text)?; + s.execute("text.insert", &json!({"text": text, "raw": true})) +} + +/// Text from the system clipboard as story text: Unix line ends, and none of the private-use +/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and +/// variables, since a stray one would be read as a marker without its payload. +fn outside_text(text: &str) -> Result { + let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) } } /// Pasted text with no insertion point: a new text frame holding it, as wide as the first column @@ -96,7 +105,7 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result { .map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT))); let lid = s.doc()?.active_layer; let cache = s.cache.clone(); - let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let text = outside_text(text)?; s.edit(|d, sel| { let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?; let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first()); @@ -404,6 +413,25 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + #[test] + fn outside_text_never_pastes_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Private-use characters from another app would read as footnote, table or variable markers. + let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; + let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); + s.doc().unwrap().doc.check().unwrap(); + let b = frame(&mut s, "Target"); + s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); + assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn formatted_copy_paste_and_plain_paste() { let mut s = Session::new(); From b6622fd5815e8cb65095757353c822c3114d7714 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:57:09 +0000 Subject: [PATCH 2/9] Paste: copied text pasted as a new frame keeps its formatting --- crates/engine/src/cmd/edit.rs | 41 ++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d432aa798..9f52184f6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -95,8 +95,9 @@ fn outside_text(text: &str) -> Result { /// Pasted text with no insertion point: a new text frame holding it, as wide as the first column /// of the page's margin area and as tall as the text (at most the margin area's height), centred -/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. -fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result { +/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With +/// `slice` (the text copied here), the frame's story is that formatted copy. +fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result { use designcraft_geom::{Point, Rect, shapes}; const LIMIT: f64 = 1.0e5; let sr = super::spread_param(p, "spread"); @@ -105,7 +106,7 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result { .map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT))); let lid = s.doc()?.active_layer; let cache = s.cache.clone(); - let text = outside_text(text)?; + let text = if slice.is_some() { String::new() } else { outside_text(text)? }; s.edit(|d, sel| { let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?; let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first()); @@ -116,8 +117,14 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result { let max_h = margins.height().max(12.0); let c = at.unwrap_or(Point::new(column.center().x, margins.center().y)); let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); - let para = designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }; + let para = match slice.and_then(|sl| sl.paras.first()) { + Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() }, + None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }, + }; let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?; + if let Some(sl) = slice { + d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl); + } // Shrink to the composed text unless it already fills the frame. let composed = cache.get(d, sid, None); if !composed.is_overset() @@ -288,7 +295,9 @@ pub fn specs() -> Vec { return paste_text(s, text.map(str::to_string), true); } if let Some(text) = text { - return paste_text_frame(s, p, text); + // What our own text copy put on the clipboard keeps its formatting. + let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); + return paste_text_frame(s, p, text, slice.as_deref()); } let Some(clip) = s.clipboard.clone() else { return Err(super::bad("edit.paste", "clipboard is empty")); @@ -432,6 +441,28 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + #[test] + fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words\nSecond"); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); + let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); + let d = s.doc().unwrap(); + let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.text, "Bold words\nSecond"); + assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.char_format_at(7).over.font_style, None); + assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); + d.doc.check().unwrap(); + } + #[test] fn formatted_copy_paste_and_plain_paste() { let mut s = Session::new(); From 7b803f1ddc2644cff5958f5897307dc44df33ecc Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:58:28 +0000 Subject: [PATCH 3/9] Text: double-click word selection keeps grapheme clusters whole --- Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/text.rs | 71 +++++++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7899b2823..a9c1b23fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,6 +991,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "unicode-segmentation", "wasm-bindgen-test", "zip", ] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 62578ba2f..1de90d145 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,7 @@ log = { workspace = true } regex = { workspace = true } quick-xml = { workspace = true } caseless = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index e3ede0aa4..dd5eb3d03 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -346,22 +346,36 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { Ok(json!({"story": sid.0, "anchor": a, "focus": e})) } -/// The word at byte `i`; on spaces the run of spaces; on anything else that one character. At -/// the end of a paragraph, what ends it. +/// The word at byte `i`; on spaces the run of spaces; on anything else that one character +/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: +/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { - let at = |i: usize| s.get(i..).and_then(|r| r.chars().next()).filter(|c| *c != '\n'); - let i = if at(i).is_none() && i > 0 { prev_char(s, i) } else { i }; - let Some(c) = at(i) else { return (i, i) }; - let space = |c: char| c.is_whitespace() && c != '\n'; - if is_word(c) { - word_bounds(s, i) - } else if space(c) { - let a = s[..i].char_indices().rev().take_while(|(_, c)| space(*c)).last().map_or(i, |(k, _)| k); - let e = s[i..].char_indices().find(|(_, c)| !space(*c)).map_or(s.len(), |(k, _)| i + k); - (a, e) - } else { - (i, i + c.len_utf8()) + use unicode_segmentation::UnicodeSegmentation; + let i = floor_char_boundary(s, i.min(s.len())); + // Clusters never span a paragraph return, so the paragraph is enough. + let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); + let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); + let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); + let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; + let kind = |c: &str| match c.chars().next() { + Some(c) if is_word(c) => 1, + Some(c) if c.is_whitespace() => 2, + _ => 0, + }; + let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); + let kd = g.get(k).map_or(0, |(_, c)| kind(c)); + if kd == 0 { + return span(k, k); } + let mut a = k; + while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { + a -= 1; + } + let mut e = k; + while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { + e += 1; + } + span(a, e) } /// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. @@ -721,12 +735,6 @@ pub fn prev_word(s: &str, i: usize) -> usize { } 0 } -fn word_bounds(s: &str, i: usize) -> (usize, usize) { - let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i); - let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len()); - (a, b) -} - /// Type ▸ Story Direction. fn story_direction(s: &mut Session, p: &Value) -> Result { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -1435,6 +1443,29 @@ mod multi_click_tests { use crate::Session; + #[test] + fn word_selection_keeps_grapheme_clusters_whole() { + use super::word_or_spaces_at as word; + // Decomposed "café": the combining acute belongs to the word, wherever the click lands. + let s = "un cafe\u{301} noir"; + let cafe = (3, 3 + "cafe\u{301}".len()); + assert_eq!(word(s, 4), cafe); + assert_eq!(word(s, 7), cafe, "on the combining mark"); + // Devanagari with a virama (U+094D) and a dependent vowel sign. + let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; + let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); + assert_eq!(word(s, 0), (0, w)); + assert_eq!(word(s, 9), (0, w), "on the virama"); + // An emoji ZWJ sequence and a variation selector stay one character. + let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; + let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); + assert_eq!(word(s, 2), e); + assert_eq!(word(s, 6), e, "on the joiner"); + assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); + // Empty paragraphs select nothing. + assert_eq!(word("a\n\nb", 2), (2, 2)); + } + #[test] fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { let mut s = Session::new(); From 45144c5044c48e92f261423fb3a682688457ad11 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:59:21 +0000 Subject: [PATCH 4/9] Text: moving the caret drops the pending typing format --- crates/engine/src/cmd/text.rs | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index dd5eb3d03..45da38415 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -36,6 +36,7 @@ pub fn specs() -> Vec { ), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0)); + s.typing_format = None; let st = s.doc_mut()?; let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text; let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize); @@ -340,6 +341,7 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))), }; s.text_drag = false; + s.typing_format = None; let st = s.doc_mut()?; st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }); st.revision += 1; @@ -411,6 +413,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { } else if s.text_drag { return Ok(json!({"story": sid.0, "dragging": true})); } + s.typing_format = None; let st = s.doc_mut()?; // Dragging from one cell into another selects cells. if extend @@ -815,6 +818,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { "storyEnd" => text.len(), other => return Err(bad("text.move", format!("unknown dir `{other}`"))), }; + s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); stm.revision += 1; @@ -1443,6 +1447,46 @@ mod multi_click_tests { use crate::Session; + #[test] + fn moving_the_caret_drops_the_pending_typing_format() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + let pending = |s: &mut Session| { + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + }; + // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the + // same caret is in the text's own format. + let moves: Vec> = vec![ + Box::new(|s| { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + }), + Box::new(|s| { + s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + Box::new(|s| { + s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + ]; + for m in &moves { + pending(&mut s); + m(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), None); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Without a move it applies. + pending(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), Some(30.0)); + } + #[test] fn word_selection_keeps_grapheme_clusters_whole() { use super::word_or_spaces_at as word; From 6a0f0cf386ce9f6507d8a0889b3030def2904a1e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:59:53 +0000 Subject: [PATCH 5/9] Text: a quadruple click selects the paragraph with its return --- crates/engine/src/cmd/text.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 45da38415..03badbc48 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Select Text Unit", [], None, - "{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, paragraph or the whole story", + "{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story", has_doc, |s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word")) ), @@ -336,7 +336,8 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { let cs = s.cache.get(&st.doc, sid, None); line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) } - "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, r.end)), + // The paragraph with its return (the last paragraph has none). + "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), "story" => (0, text.len()), other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))), }; @@ -1534,7 +1535,7 @@ mod multi_click_tests { assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); assert_eq!(select("word", 18), (18, 19), "punctuation alone"); assert_eq!(select("line", 9), (0, 19)); - assert_eq!(select("paragraph", 9), (0, 19)); + assert_eq!(select("paragraph", 9), (0, 20), "with its return"); assert_eq!(select("paragraph", 22), (20, text.len())); assert_eq!(select("story", 9), (0, text.len())); // A second click inside the selected word selects again rather than starting a text drag. From d1638377816060f63cb788aa9959ba287dfaf4db Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 04:00:19 +0000 Subject: [PATCH 6/9] Text: text.selectAt needs a point --- crates/engine/src/cmd/text.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 03badbc48..46ef34f94 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -324,7 +324,7 @@ fn hit_byte_with( fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { const ID: &str = "text.selectAt"; let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; let st = s.doc()?; let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; @@ -1553,6 +1553,7 @@ mod multi_click_tests { let t = s.doc().unwrap().selection.text.unwrap(); assert_eq!((t.anchor, t.focus), (7, 12)); assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); } } From 52b58c05191591fdafa505f95e2bb0204e2374b5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 04:01:12 +0000 Subject: [PATCH 7/9] Text: Bold and Italic that change nothing add no undo step --- crates/engine/src/cmd/text.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 46ef34f94..de48e4884 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1168,6 +1168,10 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { }); } } + // No undo step for nothing. + if changed == 0 { + return Err(bad(id, "nothing to change")); + } ok() }); r?; @@ -1586,6 +1590,11 @@ mod style_link_tests { s.execute("type.bold", &json!({})).unwrap(); assert_eq!(style_at(&mut s, 0), "Italic"); assert_eq!(style_at(&mut s, 7), "Regular"); + // Nothing to change: an error and no undo step. + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert!(s.execute("type.bold", &json!({"on": false})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); // At a caret the style is for what is typed next. let end = "plain words here".len(); s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); From 9964025ffb199457992a3a49ba49016ad8e77f34 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 04:04:07 +0000 Subject: [PATCH 8/9] Swatches: Add to Swatches renames the colour in text, tables and notes --- crates/engine/src/cmd/style.rs | 102 +++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d0edea1f6..3eb241b28 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1038,10 +1038,78 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result { rename_in(std::sync::Arc::make_mut(top), &name, &new); } } + for st in d.stories.values_mut() { + let mut copy = (**st).clone(); + if rename_in_story(&mut copy, &name, &new) { + *st = std::sync::Arc::new(copy); + } + } Ok(json!({"name": new})) }) } +/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading, +/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story +/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed. +fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> bool { + fn fix(s: &mut String, from: &str, to: &str) -> bool { + let hit = s == from; + if hit { + *s = to.to_string(); + } + hit + } + let mut n = 0; + let mut f = |s: &mut String| n += usize::from(fix(s, from, to)); + for r in &mut st.chars { + let o = &mut r.format.over; + for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() { + f(c); + } + } + for pf in &mut st.paras { + let a = &mut pf.para; + for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() { + f(c); + } + for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() { + f(&mut r.color); + } + } + let mut nested = false; + for t in st.tables.values_mut() { + let t = std::sync::Arc::make_mut(t); + let o = &mut t.options; + f(&mut o.border.color); + for b in o.borders.iter_mut().flatten() { + f(&mut b.color); + } + for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() { + f(&mut a.first_color); + f(&mut a.next_color); + } + for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() { + f(&mut k.first_stroke.color); + f(&mut k.next_stroke.color); + } + for c in &mut t.cells { + f(&mut c.fill); + for k in &mut c.strokes { + f(&mut k.color); + } + nested |= rename_in_story(&mut c.text, from, to); + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to); + } + let changed = n > 0 || nested; + if changed { + st.rev = st.rev.wrapping_add(1); + } + changed +} + /// Point fills and strokes of an item (and its children) at a renamed swatch. fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { if it.fill.swatch == from { @@ -1205,6 +1273,40 @@ mod color_tests { s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); } + + #[test] + fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { + use std::sync::Arc; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); + let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); + // The same unnamed colour on a cell, the cell's text and the footnote's text. + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); + t.cells[0].fill = old.clone(); + t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); + Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); + let cell = &st.tables.values().next().unwrap().cells[0]; + assert_eq!(cell.fill, "Sand"); + assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); + assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); + d.check().unwrap(); + } } #[cfg(test)] From 6f55df43703a8ca38019c5acd6903d57f79dcdc9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 04:07:47 +0000 Subject: [PATCH 9/9] Canvas: a press right after a drag is not a double click --- crates/ui-egui/src/canvas.rs | 53 ++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d583697dc..181b947de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1440,11 +1440,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r { // Count presses in a row here: egui reports double and triple clicks only on release and // stops at three, but a press must know (word, line, paragraph, story). - // Like egui, the next press must come within the double-click delay of the last release. let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let (released_at, last_pos, last_n): (f64, Pos2, u8) = ui.data(|d| d.get_temp(clicks_id)).unwrap_or((f64::NEG_INFINITY, o, 0)); - let n = if now - released_at <= delay && (o - last_pos).length() <= dist { last_n.saturating_add(1) } else { 1 }; - ui.data_mut(|d| d.insert_temp(clicks_id, (f64::NEG_INFINITY, o, n))); + let n = press_count(ui.data(|d| d.get_temp(clicks_id)), o, now, delay, dist); + ui.data_mut(|d| d.insert_temp(clicks_id, Clicks { at: f64::NEG_INFINITY, pos: o, n })); events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } @@ -1476,8 +1474,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id)); } if down && released { - if let Some((_, at, n)) = ui.data(|d| d.get_temp::<(f64, Pos2, u8)>(clicks_id)) { - ui.data_mut(|d| d.insert_temp(clicks_id, (now, at, n))); + if let Some(c) = ui.data(|d| d.get_temp::(clicks_id)) { + let dist = ui.ctx().options(|o| o.input_options.max_click_dist); + ui.data_mut(|d| d.insert_temp(clicks_id, release_clicks(c, latest.unwrap_or(c.pos), now, dist))); } let p = latest.unwrap_or(rect.center()); events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 1 }); @@ -1799,6 +1798,31 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +/// Presses in a row on the canvas: the last press's count, and where and when it was released +/// (`at` is −∞ while the button is down). +#[derive(Clone, Copy, Debug)] +struct Clicks { + at: f64, + pos: Pos2, + n: u8, +} + +/// The count of a press at `pos` at time `now`: one more than the last press when it comes within +/// the double-click delay of the last release and near where that release was, else 1. +fn press_count(last: Option, pos: Pos2, now: f64, delay: f64, dist: f32) -> u8 { + match last { + Some(c) if now - c.at <= delay && (pos - c.pos).length() <= dist => c.n.saturating_add(1), + _ => 1, + } +} + +/// The record a release at `pos` leaves: a release away from its press ended a drag, which is +/// not a click, so the next press counts from one. +fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks { + let n = if (pos - press.pos).length() <= dist { press.n } else { 0 }; + Clicks { at: now, pos, n } +} + #[cfg(test)] mod tests { use designcraft_geom::Unit; @@ -1806,6 +1830,23 @@ mod tests { use super::*; + #[test] + fn a_press_after_a_drag_is_not_a_double_click() { + let (delay, dist) = (0.3, 6.0); + let press = |last, x: f32, now| press_count(last, pos2(x, 0.0), now, delay, dist); + // Click, click: a double click. + let n = press(None, 0.0, 0.0); + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n }, pos2(0.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 1.0, 0.2), 2); + // A drag from 0 to 100, then a fast press where it started or where it ended: a single click. + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(100.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 0.0, 0.2), 1); + assert_eq!(press(Some(up), 100.0, 0.2), 1); + // Too slow: a single click. + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(0.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 0.0, 1.0), 1); + } + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| {