From fc01666ac21856fc5b1263e237b6c72870d58e46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:58:11 +0200 Subject: [PATCH] feat(text): select sentence, paragraph and story on 3, 4 and 5 clicks (fixes #622) --- crates/engine/src/cmd/text.rs | 106 ++++++++++++++++++++++++++++++ crates/mcp/src/headless.rs | 2 +- crates/tools/src/lib.rs | 7 +- crates/tools/src/text.rs | 18 +++--- crates/ui-egui/src/canvas.rs | 118 +++++++++++++++++++++++++++++----- crates/ui-egui/src/control.rs | 2 +- 6 files changed, 227 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d..351fc37 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,6 +31,15 @@ pub fn specs() -> Vec { st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); ok() }), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { + select_around(s, p, "text.selectSentence", sentence_bounds) + }), + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| { + select_around(s, p, "text.selectParagraph", para_bounds) + }), + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| { + select_around(s, p, "text.selectStory", |t, _| (0, t.len())) + }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; @@ -627,6 +636,62 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) { (a, b) } +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + /// Type ▸ Story Direction. fn story_direction(s: &mut Session, p: &Value) -> Result { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -1896,3 +1961,44 @@ mod story_query_contract_tests { assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); } } + +#[cfg(test)] +mod multi_click_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eea81dd..de03535 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -123,7 +123,7 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?; + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks: 0 }, VIEW).map_err(|e| e.to_string())?; } let requests: Vec = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect(); Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests})) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f..0bda924 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -63,11 +63,16 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, + /// Presses in quick succession at about the same spot, this one included: 2 on a + /// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story). + /// 0 or 1 is a single press. + #[serde(default)] + pub clicks: u8, } impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default() } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 13a93f2..f166cf8 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -50,19 +50,21 @@ impl Tool for TypeTool { && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + // Clicks in a row select a word, a sentence, a paragraph, then the whole story. + let unit = match (ev.kind, ev.clicks) { + (PointerKind::DoubleClick, _) => Some("text.selectWord"), + (_, 3) => Some("text.selectSentence"), + (_, 4) => Some("text.selectParagraph"), + (_, 5..) => Some("text.selectStory"), + _ => None, + }; // Whole rows/columns selected: wait to see whether this is a drag. - if ev.kind == PointerKind::Down && !ev.mods.shift && whole_rows_or_cols(cx) { + if unit.is_none() && !ev.mods.shift && whole_rows_or_cols(cx) { self.cell_drag = Some((id.0, sp)); return vec![]; } self.selecting = Some(id.0); - let cmd = if ev.kind == PointerKind::DoubleClick { - "text.selectWord" - } else if ev.mods.shift { - "text.extendTo" - } else { - "text.placeCaret" - }; + let cmd = unit.unwrap_or(if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }); return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; } vec![] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e..0ab1d6e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1341,6 +1341,19 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } +/// Presses in a row, this one included: each within egui's double-click delay and distance of the +/// one before. Counted on the press, as text selection wants (egui counts releases, up to three). +fn count_click(ui: &egui::Ui, id: egui::Id, at: Pos2, now: f64) -> u8 { + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let last: Option<(f64, Pos2, u8)> = ui.data(|d| d.get_temp(id)); + let n = match last { + Some((t, p, n)) if now - t < delay && p.distance(at) < dist => n.saturating_add(1), + _ => 1, + }; + ui.data_mut(|d| d.insert_temp(id, (now, at, n))); + n +} + fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1383,20 +1396,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec = Vec::new(); let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); - let (pressed, released, origin, latest, dbl) = ui.input(|i| { - ( - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.pointer.press_origin(), - i.pointer.latest_pos(), - i.pointer.button_double_clicked(egui::PointerButton::Primary), - ) - }); + let (pressed, released, origin, latest, now) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); if pressed && !space && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { - events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); + let clicks = count_click(ui, egui::Id::new(("canvas_clicks", app.pane)), o, now); + let kind = if clicks == 2 { PointerKind::DoubleClick } else { PointerKind::Down }; + events.push(PointerEvent { kind, pos: pos(o), mods: m, clicks }); down = true; } if down @@ -1404,7 +1412,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r && resp.drag_delta() != egui::Vec2::ZERO && let Some(p) = latest { - events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 0 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. let hold_id = egui::Id::new(("canvas_hold", app.pane)); @@ -1414,8 +1422,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0); if !fired && still && now - start >= 0.5 { let p = pos(latest.unwrap_or(rect.center())); - events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m }); - events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m } }); + events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 }); ui.data_mut(|d| d.insert_temp(hold_id, (start, true))); } else { ui.data_mut(|d| d.insert_temp(hold_id, (start, fired))); @@ -1428,10 +1436,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } if down && released { let p = latest.unwrap_or(rect.center()); - events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 0 }); down = false; } else if !down && let Some(p) = resp.hover_pos() { - events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 0 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1798,6 +1806,86 @@ mod tests { } } + /// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a + /// row must land within the double-click delay). + fn typing(text: &str) -> (Harness<'static, DesignApp>, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": text})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, id) + } + + /// The screen point over the first line of frame `id` where a click puts the caret at a byte + /// in `bytes`. + fn screen_at(h: &mut Harness<'_, DesignApp>, id: u64, bytes: std::ops::Range) -> Pos2 { + let mut x = 74.0; + let p = loop { + assert!(x < 400.0, "no point on the first line hits {bytes:?}"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [x, 80.0]})).unwrap(); + if h.state().session.doc().unwrap().selection.text.is_some_and(|t| bytes.contains(&t.focus)) { + break Point::new(x, 80.0); + } + x += 1.0; + }; + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let app = h.state(); + let c = app.session.layout().to_canvas(SpreadRef::Doc(0), p); + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c) + } + + fn click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + } + + fn selected(h: &Harness<'_, DesignApp>) -> std::ops::Range { + h.state().session.doc().unwrap().selection.text.map(|t| t.range()).unwrap_or_default() + } + + #[test] + fn clicks_in_a_row_select_word_sentence_paragraph_and_story() { + let text = "One two. Three four! Five six.\nSecond para here."; + let (mut h, id) = typing(text); + // Inside "Three", the second sentence: its start is not the paragraph's. + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + let mut got = vec![]; + for _ in 0..5 { + click(&mut h, p); + let r = selected(&h); + got.push(text[r].to_string()); + } + assert_eq!(got[1..], ["Three", "Three four! ", "One two. Three four! Five six.", text], "{got:?}"); + assert!(got[0].is_empty(), "one click places the caret: {got:?}"); + } + + #[test] + fn slow_clicks_do_not_add_up() { + let (mut h, id) = typing("One two. Three four!"); + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + for _ in 0..3 { + click(&mut h, p); + h.run_steps(30); + } + assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640..68e9210 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -147,7 +147,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> designcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods }, view) { + if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks: 0 }, view) { return err(e); } app.after_engine();