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 <noreply@anthropic.com>
This commit is contained in:
fchinu
2026-10-20 19:46:15 +02:00
co-authored by Claude Sonnet 5.5
parent a31b990d32
commit 6f83ed464d
3 changed files with 103 additions and 3 deletions
+12 -1
View File
@@ -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<Value> {
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<Value> {
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();
+71
View File
@@ -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");
}
+20 -2
View File
@@ -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<Option<Value>> {
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 => {}
}