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 <noreply@anthropic.com>
This commit is contained in:
fchinu
2026-10-21 13:46:05 +02:00
co-authored by Claude Sonnet 5.5
parent 6f83ed464d
commit f71ce3fa9d
3 changed files with 99 additions and 10 deletions
+30 -8
View File
@@ -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<usize> {
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<Value> {
@@ -1190,8 +1205,15 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
}
// 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();
}
+53
View File
@@ -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));
}
+16 -2
View File
@@ -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<Value> {
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()