Merge PR #1000: Type tool: a drag after several clicks extends the selection by words, lines or paragraphs (#981)

Conflicts:
- crates/engine/src/cmd/text.rs: took #1000's unit_at split of select_at; its two revision bumps use the batch's bump_revision().
- crates/tools/src/text.rs: kept the batch's preedit and table-border fields, BorderDrag and the press-time border check; #1000's `unit` replaces `unit_selected`, and the drag branch checks the border first, then #1000's unit drag.
This commit is contained in:
aiongg
2026-10-16 04:01:55 +00:00
4 changed files with 109 additions and 19 deletions
+81 -11
View File
@@ -21,7 +21,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
release
),
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
cmd!(
noundo "text.extendTo",
"Extend Text Selection",
[],
None,
"{frame, point, unit?: word|line|paragraph|story} — moves the selection's focus to the point; with `unit`, after a text.selectAt, the selection grows by whole units from the unit first selected (a drag after a double, triple or quadruple click)",
has_doc,
|s, p| place(s, p, true)
),
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
select_at(s, p, "word")
}),
@@ -458,11 +466,26 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?;
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
let (a, e) = unit_at(s, sid, cell, b, unit, ID)?;
s.text_drag = false;
s.typing_format = None;
let sel = TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell };
s.text_unit = Some(sel);
let st = s.doc_mut()?;
st.selection = Selection::text(sel);
// Explicit reselection invalidates the Find scope even when the range is unchanged.
st.bump_revision();
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
}
/// The `unit` (word, line, paragraph or story) around byte `b` of story `sid` (a cell's story
/// with `cell`), as the multiple clicks select it.
fn unit_at(s: &Session, sid: StoryId, cell: Option<CellAddr>, b: usize, unit: &str, cmd: &str) -> Result<(usize, usize)> {
let st = s.doc()?;
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let text = story.text.as_str();
let b = floor_char_boundary(text, b);
let (a, e) = match unit {
Ok(match unit {
"word" => word_or_spaces_at(text, b),
"line" => {
let cs = s.cache.get(&st.doc, sid, None);
@@ -471,15 +494,8 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
// The paragraph with its return (the last paragraph has none).
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))),
"story" => (0, text.len()),
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
};
s.text_drag = false;
s.typing_format = None;
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
// Explicit reselection invalidates the Find scope even when the range is unchanged.
st.bump_revision();
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
other => return Err(bad(cmd, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
})
}
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
@@ -543,6 +559,20 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
}
let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
// A drag after several clicks: whole units from the one first selected, in either direction.
if extend
&& let Some(unit) = str_param(p, "unit")
&& let Some(u) = s.text_unit.filter(|u| u.story == sid && u.cell == cell)
{
let (ua, ue) = (u.anchor.min(u.focus), u.anchor.max(u.focus));
let (na, ne) = unit_at(s, sid, cell, b, unit, "text.extendTo")?;
let (anchor, focus) = if na < ua { (ue, na) } else { (ua, ne.max(ue)) };
s.typing_format = None;
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell });
st.bump_revision();
return Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus}));
}
// Drag and drop: a press inside the selected text keeps it (to be dragged).
if !extend {
s.text_drag = s.doc()?.selection.text.is_some_and(|t| t.story == sid && t.cell == cell && !t.range().is_empty() && t.range().contains(&b));
@@ -2529,6 +2559,46 @@ mod multi_click_tests {
assert_eq!(word("a\n\nb", 2), (2, 2));
}
/// Issue #981: holding the button after a double, triple or quadruple click and moving extends
/// the selection a word, line or paragraph at a time, from the unit first selected, either way.
#[test]
fn a_drag_after_several_clicks_extends_the_selection_by_units() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let text = "Hello brave world.\nSecond paragraph here.";
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap();
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
let at = |s: &Session, b: usize| {
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap();
json!([g.x + g.adv * 0.75, l.baseline - 3.0])
};
let sel = |s: &Session| s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)).unwrap();
let extend = |s: &mut Session, b: usize, unit: &str| {
let p = at(s, b);
s.execute("text.extendTo", &json!({"frame": fid, "point": p, "unit": unit})).unwrap();
sel(s)
};
// Double-click "brave", drag into "world.": whole words, anchored at the start of "brave".
s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap();
assert_eq!(sel(&s), (7, 12));
assert_eq!(extend(&mut s, 14, "word"), (7, 18), "forward: through \"world\"");
assert_eq!(extend(&mut s, 9, "word"), (7, 12), "back inside the word: just the word");
assert_eq!(extend(&mut s, 1, "word"), (12, 0), "backward: from \"Hello\" to the end of \"brave\"");
assert_eq!(&text[0..12], "Hello brave");
// Triple-click the first line, drag into the second: both lines.
s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "line"})).unwrap();
assert_eq!(sel(&s), (0, 19));
assert_eq!(extend(&mut s, 22, "line"), (0, text.len()));
// Without `unit` a drag still moves the focus a character at a time.
s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap();
s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14)})).unwrap();
assert_eq!(sel(&s).0, 7);
assert!(sel(&s).1 > 12 && sel(&s).1 < 18, "{:?}", sel(&s));
assert!(s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14), "unit": "sentence"})).is_err());
}
#[test]
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
let mut s = Session::new();
+4
View File
@@ -350,6 +350,9 @@ pub struct Session {
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
/// Drag and drop text editing: the press landed in the selected text.
pub(crate) text_drag: bool,
/// The word, line, paragraph or story the last `text.selectAt` selected: a drag that follows a
/// double, triple or quadruple click extends from it by whole units (#981).
pub(crate) text_unit: Option<designcraft_doc::TextSel>,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// The open Object Library (File › New / Open Library).
@@ -398,6 +401,7 @@ impl Session {
loaded: None,
transforms: Default::default(),
text_drag: false,
text_unit: None,
typing_format: None,
library: None,
conveyor: Vec::new(),
+12 -2
View File
@@ -1062,8 +1062,18 @@ fn clicks_in_a_row_edit_and_select_text() {
}
// A double click sent as such (control channel) selects a word too.
assert_eq!(unit(t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y))).as_deref(), Some("word"));
// Moving a little while pressed keeps the unit selected.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty());
// Moving while pressed extends the selection by the unit the clicks chose (#981).
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y));
assert!(matches!(&a[..], [Action::Exec(id, p)] if id == "text.extendTo" && p["unit"] == "word" && p["frame"] == fid.0), "{a:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 40.0, y));
t.pointer(&cx, &press(3));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y));
assert!(matches!(&a[..], [Action::Exec(id, p)] if id == "text.extendTo" && p["unit"] == "line"), "{a:?}");
// One click, then a drag: a character at a time, as before.
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 40.0, y));
t.pointer(&cx, &press(1));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y));
assert!(matches!(&a[..], [Action::Exec(id, p)] if id == "text.extendTo" && p.get("unit").is_none()), "{a:?}");
}
/// A 100×100 frame at (100, 100) holding a 200×100 graphic that overhangs it.
+12 -6
View File
@@ -36,8 +36,9 @@ pub struct TypeTool {
cell_drag: Option<(u64, Point)>,
/// Snap guides while drawing a frame.
guides: Vec<Overlay>,
/// The press selected a word, line, paragraph or story (several clicks): drags keep it.
unit_selected: bool,
/// The press selected a word, line, paragraph or story (several clicks): a drag extends the
/// selection by that unit (#981).
unit: Option<&'static str>,
preedit: Option<Preedit>,
/// A press on a table row or column border: a drag resizes it.
border: Option<BorderDrag>,
@@ -223,7 +224,7 @@ impl Tool for TypeTool {
self.drawing = false;
self.selecting = None;
self.cell_drag = None;
self.unit_selected = false;
self.unit = None;
self.border = None;
if ev.kind == PointerKind::Down
&& let Some(b) = border_at(cx, ev.pos)
@@ -249,13 +250,13 @@ impl Tool for TypeTool {
// Two presses select a word (or a run of spaces), three a line, four a
// paragraph, five the story.
if clicks >= 2 {
self.unit_selected = true;
let unit = match clicks {
2 => "word",
3 => "line",
4 => "paragraph",
_ => "story",
};
self.unit = Some(unit);
return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))];
}
let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" };
@@ -272,12 +273,17 @@ impl Tool for TypeTool {
}
return b.drag(ev.pos, ev.mods.shift);
}
if self.cell_drag.is_some() || self.unit_selected {
if self.cell_drag.is_some() {
return vec![];
}
if let Some(fid) = self.selecting {
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
return vec![Action::Exec("text.extendTo".into(), json!({"frame": fid, "point": [sp.x, sp.y]}))];
let mut p = json!({"frame": fid, "point": [sp.x, sp.y]});
// After several clicks the drag grows the selection by whole units.
if let Some(unit) = self.unit {
p["unit"] = json!(unit);
}
return vec![Action::Exec("text.extendTo".into(), p)];
}
if (ev.pos - a).hypot() < cx.tol(3.0) && !self.drawing {
return vec![];