feat(text): select sentence, paragraph and story on 3, 4 and 5 clicks (fixes #622)
This commit is contained in:
parent
c3859c828b
commit
fc01666ac2
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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![]
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue