From 6f83ed464d4dcb865769192847271d3813e2d815 Mon Sep 17 00:00:00 2001 From: fchinu Date: Tue, 20 Oct 2026 19:46:15 +0200 Subject: [PATCH] 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 => {} }