feat(text): select sentence, paragraph and story on 3, 4 and 5 clicks (fixes #622)

This commit is contained in:
Marco Arnulfo 2026-10-12 23:58:11 +02:00
parent c3859c828b
commit fc01666ac2
6 changed files with 227 additions and 26 deletions

View File

@ -31,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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()));
}
}

View File

@ -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<Value> = 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}))

View File

@ -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;

View File

@ -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![]

View File

@ -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<PointerEvent> = 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<usize>) -> 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<usize> {
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()));

View File

@ -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();