Merge pull request #1076 from fchinu/format_text

This commit is contained in:
Brandon Thomas
2026-10-23 17:22:14 -04:00
3 changed files with 221 additions and 6 deletions
+36 -2
View File
@@ -1170,6 +1170,31 @@ pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
}
}
/// 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();
// 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;
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> {
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
if !sticky {
@@ -1179,8 +1204,17 @@ 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() {
format_para(s, &move |para| para.props = pp.clone())?;
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
// selection covers whole; painting part of a bulleted line changes just its text.
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();
}
+144
View File
@@ -437,3 +437,147 @@ 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");
}
/// 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));
}
#[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)));
}
+41 -4
View File
@@ -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 {
@@ -191,6 +194,8 @@ enum Drag {
},
TextSelect {
shape: ShapeId,
/// The text selection before the press, to tell whether the drag changed it.
before: Option<TextTarget>,
},
/// 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
@@ -474,11 +479,17 @@ 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 before = self.text_selection();
let r = self.double_click(p, tol)?;
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()) {
self.execute("text.selectParagraph", &json!({}))
let before = self.text_selection();
let r = self.execute("text.selectParagraph", &json!({}))?;
self.paint_if_selection_changed(before, r)
} else {
Ok(Value::Null)
}
@@ -486,6 +497,27 @@ 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)
}
/// Where the text selection is: its shape, cell or notes, anchor and caret.
fn text_selection(&self) -> Option<TextTarget> {
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<TextTarget>, 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()
@@ -552,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);
@@ -909,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) {
@@ -1104,6 +1137,10 @@ impl Session {
let _ = cmd::text::fit_text_box(self, id);
self.refit_after_resize(id)?;
}
Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?;
self.commit();
}
Some(_) => self.commit(),
None => {}
}