From 93b10bd37ee11ff825b00eee35959d1025791ffe Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:08:13 +0000 Subject: [PATCH] Type tool: a drag after several clicks extends the selection by words, lines or paragraphs (#981) Double-clicking selected a word, but holding the button and moving did nothing. `text.extendTo` now takes `unit`: after a `text.selectAt`, the selection grows by whole units from the unit first selected, in either direction, as InDesign does. The Type tool sends the unit its clicks chose with every drag. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh --- crates/engine/src/cmd/text.rs | 92 ++++++++++++++++++++++++++++++----- crates/engine/src/lib.rs | 4 ++ crates/tools/src/tests.rs | 14 +++++- crates/tools/src/text.rs | 18 ++++--- 4 files changed, 109 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f35..b238a599d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -21,7 +21,15 @@ pub fn specs() -> Vec { 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") }), @@ -368,11 +376,26 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { 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.revision = st.revision.wrapping_add(1); + 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, 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); @@ -381,15 +404,8 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { // 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.revision = st.revision.wrapping_add(1); - 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 @@ -453,6 +469,20 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { 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.revision = st.revision.wrapping_add(1); + 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)); @@ -1974,6 +2004,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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 745e45647..85f592d6c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -293,6 +293,9 @@ pub struct Session { pub(crate) transforms: (Vec<(String, Value)>, Vec, 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, /// Character formatting chosen at a bare caret, for the text typed there next. pub(crate) typing_format: Option, /// Text an input method is composing (a dead key's accent), shown in the story as an @@ -334,6 +337,7 @@ impl Session { loaded: None, transforms: Default::default(), text_drag: false, + text_unit: None, typing_format: None, composition: None, library: None, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index aacfb8d3f..9c838c4c2 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1028,8 +1028,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:?}"); } #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e2fe043de..f6ab646ba 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -19,8 +19,9 @@ pub struct TypeTool { cell_drag: Option<(u64, Point)>, /// Snap guides while drawing a frame. guides: Vec, - /// 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>, } /// Are whole rows or whole columns of a table selected? @@ -49,7 +50,7 @@ impl Tool for TypeTool { self.drawing = false; self.selecting = None; self.cell_drag = None; - self.unit_selected = false; + self.unit = None; // Text frames take the caret (locked ones too, as in InDesign); an empty frame // becomes one, unless it's locked: then the press draws a new text frame over it. if let Some((_, id)) = cx.hit(ev.pos) @@ -68,13 +69,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" }; @@ -84,12 +85,17 @@ impl Tool for TypeTool { } PointerKind::Drag => { let Some(a) = self.start else { return vec![] }; - 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![];