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()