From 6f83ed464d4dcb865769192847271d3813e2d815 Mon Sep 17 00:00:00 2001 From: fchinu Date: Tue, 20 Oct 2026 19:46:15 +0200 Subject: [PATCH 1/5] Format Painter: apply on text selection, keep bullets on partial lines The painter only applied when a shape was clicked. It now also applies when a text range is selected by drag, double-click or triple-click. Paragraph formatting (bullets, alignment) is copied only onto paragraphs the selection covers whole. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/format.rs | 13 +++++- crates/engine/src/tests_keys.rs | 71 +++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 22 +++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 75952c99..fd82a123 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1170,6 +1170,15 @@ pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) { } } +/// Does the text selection start at a paragraph's start and reach its end? +fn covers_whole_paragraph(s: &Session) -> bool { + let Ok(st) = s.doc() else { return false }; + let Some(t) = &st.selection.text else { return false }; + let (a, b) = t.ordered(); + let n = super::text::body_of(st, t).and_then(|body| body.paragraphs.get(b.0)).map_or(0, |x| x.char_len()); + a.1 == 0 && (b.0 > a.0 || b.1 >= n) +} + fn painter_apply(s: &mut Session, p: &Value) -> Result { let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) }; if !sticky { @@ -1179,7 +1188,9 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result { if let Some(r) = painted.run.clone() { format_run(s, &move |rp| *rp = r.clone())?; } - if let Some(pp) = painted.para.clone() { + // Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the + // selection covers whole; painting part of a bulleted line changes just its text. + if let Some(pp) = painted.para.clone().filter(|_| covers_whole_paragraph(s)) { format_para(s, &move |para| para.props = pp.clone())?; } return ok(); diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs index 19a91ae8..b519c452 100644 --- a/crates/engine/src/tests_keys.rs +++ b/crates/engine/src/tests_keys.rs @@ -437,3 +437,74 @@ fn select_into_another_table_cell_settles_the_composition() { let cell = |c| t.cell(0, c).map(|x| x.text.text()).unwrap_or_default(); assert_eq!((cell(0), cell(1)), ("abhello".to_string(), "X0123456789".to_string())); } + +/// Bold the first paragraph, select part of it and arm the Format Painter. +fn painter_armed(sticky: bool) -> (Session, u32) { + let (mut s, id) = editing("Hello\nworld", (0, 0)); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("format.painter", &json!({"sticky": sticky})).unwrap(); + (s, id) +} + +fn bold_at(s: &mut Session, at: Pos) -> bool { + s.execute("text.select", &json!({"anchor": [at.0, at.1], "caret": [at.0, at.1 + 1]})).unwrap(); + s.execute("format.state", &json!({})).unwrap()["bold"] == true +} + +fn pointer(s: &mut Session, kind: crate::tools::PointerKind, x: f64, y: f64) { + s.pointer(crate::tools::PointerEvent { kind, x, y, tol: 4.0, ..Default::default() }).unwrap(); +} + +#[test] +fn format_painter_applies_to_a_dragged_text_selection() { + use crate::tools::PointerKind::{Down, Drag, Up}; + let (mut s, _) = painter_armed(false); + assert!(!bold_at(&mut s, (1, 0))); + s.execute("text.move", &json!({"to": "start"})).unwrap(); + // Drag across the second line's text. + pointer(&mut s, Down, 68.0, 90.0); + pointer(&mut s, Drag, 110.0, 90.0); + pointer(&mut s, Up, 110.0, 90.0); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert!(t.is_range(), "the drag should select text"); + assert!(s.painter.is_none(), "the painter is used up"); + assert!(bold_at(&mut s, (1, 0))); +} + +#[test] +fn format_painter_applies_to_a_triple_clicked_paragraph() { + use crate::tools::PointerKind::TripleClick; + let (mut s, _) = painter_armed(true); + s.execute("text.select", &json!({"anchor": [1, 2], "caret": [1, 2]})).unwrap(); + pointer(&mut s, TripleClick, 70.0, 90.0); + assert!(s.painter.is_some(), "a sticky painter stays armed"); + assert!(bold_at(&mut s, (1, 0))); +} + +#[test] +fn format_painter_waits_for_a_range_on_a_plain_click() { + use crate::tools::PointerKind::{Down, Up}; + let (mut s, _) = painter_armed(false); + pointer(&mut s, Down, 68.0, 90.0); + pointer(&mut s, Up, 68.0, 90.0); + assert!(s.painter.is_some()); +} + +#[test] +fn format_painter_on_part_of_a_bulleted_line_keeps_its_bullet() { + use crate::tools::PointerKind::{Down, Drag, Up}; + let (mut s, _) = painter_armed(false); + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 5]})).unwrap(); + s.execute("format.bullets", &json!({"on": true})).unwrap(); + let bulleted = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["bullets"] == true; + assert!(bulleted(&mut s)); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap(); + s.execute("format.painter", &json!({})).unwrap(); + pointer(&mut s, Down, 68.0, 90.0); + pointer(&mut s, Drag, 100.0, 90.0); + pointer(&mut s, Up, 100.0, 90.0); + assert!(s.painter.is_none()); + assert!(bold_at(&mut s, (1, 0))); + assert!(bulleted(&mut s), "the bullet stays"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a7190b73..029a1d50 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -474,11 +474,15 @@ impl Session { PointerKind::Down => self.down(p, ev.mods, tol), PointerKind::Drag => self.drag(p, ev.mods, tol), PointerKind::Up => self.up(p, ev.mods), - PointerKind::DoubleClick => self.double_click(p, tol), + PointerKind::DoubleClick => { + let r = self.double_click(p, tol)?; + self.paint_text_selection().map(|a| a.unwrap_or(r)) + } PointerKind::ContextClick => self.context_click(p, tol), PointerKind::TripleClick => { if self.active().is_some_and(|d| d.selection.text.is_some()) { - self.execute("text.selectParagraph", &json!({})) + let r = self.execute("text.selectParagraph", &json!({}))?; + self.paint_text_selection().map(|a| a.unwrap_or(r)) } else { Ok(Value::Null) } @@ -486,6 +490,14 @@ impl Session { } } + /// Format Painter applies once a text range is selected (drag, word or paragraph click). + fn paint_text_selection(&mut self) -> Result> { + if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) { + return self.execute("format.painterApply", &json!({})).map(Some); + } + Ok(None) + } + pub(crate) fn begin(&mut self, label: &str) { if let Some(st) = self.active_mut() && st.interaction.is_none() @@ -1104,6 +1116,12 @@ impl Session { let _ = cmd::text::fit_text_box(self, id); self.refit_after_resize(id)?; } + Some(Drag::TextSelect { .. }) => { + if let Some(applied) = self.paint_text_selection()? { + out = applied; + } + self.commit(); + } Some(_) => self.commit(), None => {} } From f71ce3fa9dcf7f97e8c97e244a8572e4b6056354 Mon Sep 17 00:00:00 2001 From: fchinu Date: Wed, 21 Oct 2026 13:46:05 +0200 Subject: [PATCH 2/5] Format Painter: paint paragraph formatting per covered paragraph Paragraph properties now go only onto paragraphs the selection covers whole, so partly covered end paragraphs and the paragraph after a triple-click keep their bullets. Double and triple clicks apply the painter only when they changed the text selection. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/format.rs | 38 ++++++++++++++++++----- crates/engine/src/tests_keys.rs | 53 +++++++++++++++++++++++++++++++++ crates/engine/src/tools.rs | 18 +++++++++-- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index fd82a123..8bbc0c23 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1170,13 +1170,28 @@ pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) { } } -/// Does the text selection start at a paragraph's start and reach its end? -fn covers_whole_paragraph(s: &Session) -> bool { - let Ok(st) = s.doc() else { return false }; - let Some(t) = &st.selection.text else { return false }; +/// The paragraphs the text selection covers whole: it starts at or before their start and +/// reaches their end. A paragraph the selection only touches the start of (it ends at offset +/// 0, as after a triple-click) is not covered. +fn covered_paragraphs(s: &Session) -> Vec { + let Ok(st) = s.doc() else { return vec![] }; + let Some(t) = &st.selection.text else { return vec![] }; + let Some(body) = super::text::body_of(st, t) else { return vec![] }; let (a, b) = t.ordered(); - let n = super::text::body_of(st, t).and_then(|body| body.paragraphs.get(b.0)).map_or(0, |x| x.char_len()); - a.1 == 0 && (b.0 > a.0 || b.1 >= n) + (a.0..=b.0) + .filter(|&pi| { + let Some(para) = body.paragraphs.get(pi) else { return false }; + let starts = pi > a.0 || a.1 == 0; + let ends = if pi < b.0 { + true + } else if b.0 > a.0 && b.1 == 0 { + false + } else { + b.1 >= para.char_len() + }; + starts && ends + }) + .collect() } fn painter_apply(s: &mut Session, p: &Value) -> Result { @@ -1190,8 +1205,15 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result { } // Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the // selection covers whole; painting part of a bulleted line changes just its text. - if let Some(pp) = painted.para.clone().filter(|_| covers_whole_paragraph(s)) { - format_para(s, &move |para| para.props = pp.clone())?; + let covered = covered_paragraphs(s); + if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) { + super::text::edit_text(s, |body, _| { + for &pi in &covered { + if let Some(para) = body.paragraphs.get_mut(pi) { + para.props = pp.clone(); + } + } + })?; } return ok(); } diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs index b519c452..27845045 100644 --- a/crates/engine/src/tests_keys.rs +++ b/crates/engine/src/tests_keys.rs @@ -508,3 +508,56 @@ fn format_painter_on_part_of_a_bulleted_line_keeps_its_bullet() { assert!(bold_at(&mut s, (1, 0))); assert!(bulleted(&mut s), "the bullet stays"); } + +/// Three bulleted paragraphs "one", "two", "three" and the painter armed from bold plain text. +fn painter_over_bullets() -> Session { + let (mut s, _) = editing("one\ntwo\nthree\nplain", (0, 0)); + s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 5]})).unwrap(); + s.execute("format.bullets", &json!({"on": true})).unwrap(); + s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap(); + s.execute("format.painter", &json!({})).unwrap(); + s +} + +fn bulleted(s: &mut Session, para: usize) -> bool { + s.execute("text.select", &json!({"anchor": [para, 0], "caret": [para, 0]})).unwrap(); + s.execute("format.state", &json!({})).unwrap()["bullets"] == true +} + +#[test] +fn format_painter_paints_only_the_paragraphs_a_selection_covers() { + // Mid paragraph 0 to mid paragraph 2: only paragraph 1 is covered, so only it loses its bullet. + let mut s = painter_over_bullets(); + s.execute("text.select", &json!({"anchor": [0, 1], "caret": [2, 2]})).unwrap(); + s.execute("format.painterApply", &json!({})).unwrap(); + assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [true, false, true]); + // Start of paragraph 0 to mid paragraph 2: the partly covered last paragraph keeps its bullet. + let mut s = painter_over_bullets(); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 2]})).unwrap(); + s.execute("format.painterApply", &json!({})).unwrap(); + assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, false, true]); +} + +#[test] +fn format_painter_triple_click_leaves_the_next_paragraph_alone() { + use crate::tools::PointerKind::TripleClick; + let mut s = painter_over_bullets(); + s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + pointer(&mut s, TripleClick, 70.0, 70.0); + assert!(s.painter.is_none()); + assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, true, true]); +} + +#[test] +fn format_painter_ignores_a_double_click_that_selects_nothing() { + use crate::tools::PointerKind::DoubleClick; + let mut s = painter_over_bullets(); + s.execute("text.select", &json!({"anchor": [0, 0], "caret": [1, 2]})).unwrap(); + let before = sel(&s); + pointer(&mut s, DoubleClick, 900.0, 520.0); + assert_eq!(sel(&s), before); + assert!(s.painter.is_some(), "a click that left the selection alone keeps the painter"); + assert!(bulleted(&mut s, 0)); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 029a1d50..b44b6816 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -475,14 +475,16 @@ impl Session { PointerKind::Drag => self.drag(p, ev.mods, tol), PointerKind::Up => self.up(p, ev.mods), PointerKind::DoubleClick => { + let before = self.text_selection(); let r = self.double_click(p, tol)?; - self.paint_text_selection().map(|a| a.unwrap_or(r)) + self.paint_if_selection_changed(before, r) } PointerKind::ContextClick => self.context_click(p, tol), PointerKind::TripleClick => { if self.active().is_some_and(|d| d.selection.text.is_some()) { + let before = self.text_selection(); let r = self.execute("text.selectParagraph", &json!({}))?; - self.paint_text_selection().map(|a| a.unwrap_or(r)) + self.paint_if_selection_changed(before, r) } else { Ok(Value::Null) } @@ -498,6 +500,18 @@ impl Session { Ok(None) } + fn text_selection(&self) -> Option<((usize, usize), (usize, usize))> { + self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.anchor, t.caret)) + } + + /// A double or triple click paints only the range it selected, not an older one it left alone. + fn paint_if_selection_changed(&mut self, before: Option<((usize, usize), (usize, usize))>, r: Value) -> Result { + if self.text_selection() == before { + return Ok(r); + } + self.paint_text_selection().map(|a| a.unwrap_or(r)) + } + pub(crate) fn begin(&mut self, label: &str) { if let Some(st) = self.active_mut() && st.interaction.is_none() From 6e201094c60b8f3bffb2cafc001cd7c539e4efa3 Mon Sep 17 00:00:00 2001 From: fchinu Date: Fri, 23 Oct 2026 09:19:16 +0200 Subject: [PATCH 3/5] Format Painter: compare the whole text target when a click selects text A double or triple click that moves the selection to another shape or cell at the same offsets now counts as a change and paints. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tools.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index b44b6816..0857ff9c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -34,6 +34,9 @@ pub struct Mods { pub cmd: bool, } +/// A text selection's shape, table cell, notes flag, anchor and caret. +type TextTarget = (ShapeId, Option<(usize, usize)>, bool, (usize, usize), (usize, usize)); + #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PointerEvent { @@ -500,12 +503,13 @@ impl Session { Ok(None) } - fn text_selection(&self) -> Option<((usize, usize), (usize, usize))> { - self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.anchor, t.caret)) + /// Where the text selection is: its shape, cell or notes, anchor and caret. + fn text_selection(&self) -> Option { + self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret)) } /// A double or triple click paints only the range it selected, not an older one it left alone. - fn paint_if_selection_changed(&mut self, before: Option<((usize, usize), (usize, usize))>, r: Value) -> Result { + fn paint_if_selection_changed(&mut self, before: Option, r: Value) -> Result { if self.text_selection() == before { return Ok(r); } From da3e89695f44a594954545bc77ada4dbcd6b51e8 Mon Sep 17 00:00:00 2001 From: fchinu Date: Fri, 23 Oct 2026 09:34:39 +0200 Subject: [PATCH 4/5] Format Painter: paint a drag release only if the drag changed the selection A Shift-press at the caret inside the edited text starts a text drag without changing the selection; releasing it no longer repaints the existing range. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests_keys.rs | 20 ++++++++++++++++++++ crates/engine/src/tools.rs | 13 +++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs index 27845045..b8cf7379 100644 --- a/crates/engine/src/tests_keys.rs +++ b/crates/engine/src/tests_keys.rs @@ -561,3 +561,23 @@ fn format_painter_ignores_a_double_click_that_selects_nothing() { assert!(s.painter.is_some(), "a click that left the selection alone keeps the painter"); assert!(bulleted(&mut s, 0)); } + +#[test] +fn format_painter_ignores_a_shift_press_that_leaves_the_selection() { + use crate::tools::PointerKind::{Down, Up}; + let (mut s, _) = painter_armed(false); + // Find the caret position under the point, then select from the line start to it. + pointer(&mut s, Down, 110.0, 90.0); + pointer(&mut s, Up, 110.0, 90.0); + let k = caret(&s); + assert!(k.1 > 0); + s.execute("text.select", &json!({"anchor": [k.0, 0], "caret": [k.0, k.1]})).unwrap(); + // Shift-pressing at the caret keeps the range, so there is nothing new to paint. + let shift = Mods { shift: true, ..NONE }; + for kind in [Down, Up] { + s.pointer(crate::tools::PointerEvent { kind, x: 110.0, y: 90.0, mods: shift, tol: 4.0, ..Default::default() }).unwrap(); + } + assert_eq!(sel(&s), ((k.0, 0), k)); + assert!(s.painter.is_some(), "the painter stays armed"); + assert!(!bold_at(&mut s, (1, 0))); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 0857ff9c..3f707655 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -194,6 +194,8 @@ enum Drag { }, TextSelect { shape: ShapeId, + /// The text selection before the press, to tell whether the drag changed it. + before: Option, }, /// Dragging a table border: the right edge of column `index`, or the bottom edge of row /// `index`. `start` is the table-local press point, `size` the column's width or the row's @@ -582,8 +584,9 @@ impl Session { ToolKind::Select => {} } // Clicking inside the text being edited moves the caret / starts a text selection. + let before = self.text_selection(); if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { - self.tool.drag = Some(Drag::TextSelect { shape }); + self.tool.drag = Some(Drag::TextSelect { shape, before }); return Ok(json!({"caret": [pos.0, pos.1]})); } let hit = self.hit_test(p, tol); @@ -939,7 +942,7 @@ impl Session { }; self.tool.preview = Some(x); } - Drag::TextSelect { shape } => { + Drag::TextSelect { shape, .. } => { // The selection stays in the shape or table cell where it started. let st = self.doc()?; if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { @@ -1134,10 +1137,8 @@ impl Session { let _ = cmd::text::fit_text_box(self, id); self.refit_after_resize(id)?; } - Some(Drag::TextSelect { .. }) => { - if let Some(applied) = self.paint_text_selection()? { - out = applied; - } + Some(Drag::TextSelect { before, .. }) => { + out = self.paint_if_selection_changed(before, out)?; self.commit(); } Some(_) => self.commit(), From a5ca7d04b0be96dc189f480afa30c30021e5e335 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:13:32 -0400 Subject: [PATCH 5/5] Format Painter: walk only the paragraphs a text selection has An unclamped hostile selection end (text.select caret [1e18, 0]) made covered_paragraphs iterate for effectively forever. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/format.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 8bbc0c23..b92cd85d 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1178,7 +1178,8 @@ fn covered_paragraphs(s: &Session) -> Vec { let Some(t) = &st.selection.text else { return vec![] }; let Some(body) = super::text::body_of(st, t) else { return vec![] }; let (a, b) = t.ordered(); - (a.0..=b.0) + // A hostile selection can point past the body: walk only the paragraphs it has. + (a.0..=b.0.min(body.paragraphs.len().saturating_sub(1))) .filter(|&pi| { let Some(para) = body.paragraphs.get(pi) else { return false }; let starts = pi > a.0 || a.1 == 0;