mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![];
|
||||
|
||||
Reference in New Issue
Block a user