mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
Merge review fixes: text editing and paste
This commit is contained in:
Generated
+1
@@ -991,6 +991,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen-test",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
caseless = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -81,13 +81,23 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
|
||||
});
|
||||
}
|
||||
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
|
||||
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
|
||||
let text = outside_text(&text)?;
|
||||
s.execute("text.insert", &json!({"text": text, "raw": true}))
|
||||
}
|
||||
|
||||
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
|
||||
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
|
||||
/// variables, since a stray one would be read as a marker without its payload.
|
||||
fn outside_text(text: &str) -> Result<String> {
|
||||
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
|
||||
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
|
||||
}
|
||||
|
||||
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
|
||||
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
|
||||
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected.
|
||||
fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result<Value> {
|
||||
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
|
||||
/// `slice` (the text copied here), the frame's story is that formatted copy.
|
||||
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
|
||||
use designcraft_geom::{Point, Rect, shapes};
|
||||
const LIMIT: f64 = 1.0e5;
|
||||
let sr = super::spread_param(p, "spread");
|
||||
@@ -96,7 +106,7 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result<Value> {
|
||||
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
|
||||
let lid = s.doc()?.active_layer;
|
||||
let cache = s.cache.clone();
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
|
||||
s.edit(|d, sel| {
|
||||
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
|
||||
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
|
||||
@@ -107,8 +117,14 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result<Value> {
|
||||
let max_h = margins.height().max(12.0);
|
||||
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
|
||||
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
|
||||
let para = designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
|
||||
let para = match slice.and_then(|sl| sl.paras.first()) {
|
||||
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
|
||||
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
|
||||
};
|
||||
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
|
||||
if let Some(sl) = slice {
|
||||
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
|
||||
}
|
||||
// Shrink to the composed text unless it already fills the frame.
|
||||
let composed = cache.get(d, sid, None);
|
||||
if !composed.is_overset()
|
||||
@@ -279,7 +295,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return paste_text(s, text.map(str::to_string), true);
|
||||
}
|
||||
if let Some(text) = text {
|
||||
return paste_text_frame(s, p, text);
|
||||
// What our own text copy put on the clipboard keeps its formatting.
|
||||
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
|
||||
return paste_text_frame(s, p, text, slice.as_deref());
|
||||
}
|
||||
let Some(clip) = s.clipboard.clone() else {
|
||||
return Err(super::bad("edit.paste", "clipboard is empty"));
|
||||
@@ -404,6 +422,47 @@ mod text_clipboard_tests {
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_text_never_pastes_markers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// Private-use characters from another app would read as footnote, table or variable markers.
|
||||
let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}";
|
||||
let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap();
|
||||
let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}");
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
let b = frame(&mut s, "Target");
|
||||
s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap();
|
||||
s.execute("edit.paste", &json!({"text": dirty})).unwrap();
|
||||
s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}");
|
||||
assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = frame(&mut s, "Bold words\nSecond");
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap();
|
||||
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap();
|
||||
let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({"text": copied})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap();
|
||||
assert_eq!(st.text, "Bold words\nSecond");
|
||||
assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold"));
|
||||
assert_eq!(st.char_format_at(7).over.font_style, None);
|
||||
assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]);
|
||||
d.doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_copy_paste_and_plain_paste() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1038,10 +1038,78 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rename_in(std::sync::Arc::make_mut(top), &name, &new);
|
||||
}
|
||||
}
|
||||
for st in d.stories.values_mut() {
|
||||
let mut copy = (**st).clone();
|
||||
if rename_in_story(&mut copy, &name, &new) {
|
||||
*st = std::sync::Arc::new(copy);
|
||||
}
|
||||
}
|
||||
Ok(json!({"name": new}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
|
||||
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
|
||||
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
|
||||
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> bool {
|
||||
fn fix(s: &mut String, from: &str, to: &str) -> bool {
|
||||
let hit = s == from;
|
||||
if hit {
|
||||
*s = to.to_string();
|
||||
}
|
||||
hit
|
||||
}
|
||||
let mut n = 0;
|
||||
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
|
||||
for r in &mut st.chars {
|
||||
let o = &mut r.format.over;
|
||||
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
|
||||
f(c);
|
||||
}
|
||||
}
|
||||
for pf in &mut st.paras {
|
||||
let a = &mut pf.para;
|
||||
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
|
||||
f(c);
|
||||
}
|
||||
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
|
||||
f(&mut r.color);
|
||||
}
|
||||
}
|
||||
let mut nested = false;
|
||||
for t in st.tables.values_mut() {
|
||||
let t = std::sync::Arc::make_mut(t);
|
||||
let o = &mut t.options;
|
||||
f(&mut o.border.color);
|
||||
for b in o.borders.iter_mut().flatten() {
|
||||
f(&mut b.color);
|
||||
}
|
||||
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
|
||||
f(&mut a.first_color);
|
||||
f(&mut a.next_color);
|
||||
}
|
||||
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
|
||||
f(&mut k.first_stroke.color);
|
||||
f(&mut k.next_stroke.color);
|
||||
}
|
||||
for c in &mut t.cells {
|
||||
f(&mut c.fill);
|
||||
for k in &mut c.strokes {
|
||||
f(&mut k.color);
|
||||
}
|
||||
nested |= rename_in_story(&mut c.text, from, to);
|
||||
}
|
||||
}
|
||||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to);
|
||||
}
|
||||
let changed = n > 0 || nested;
|
||||
if changed {
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Point fills and strokes of an item (and its children) at a renamed swatch.
|
||||
fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||||
if it.fill.swatch == from {
|
||||
@@ -1205,6 +1273,40 @@ mod color_tests {
|
||||
s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() {
|
||||
use std::sync::Arc;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap();
|
||||
s.execute("footnote.insert", &json!({"text": "note"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap();
|
||||
let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string();
|
||||
// The same unnamed colour on a cell, the cell's text and the footnote's text.
|
||||
s.edit(|d, _| {
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let t = Arc::make_mut(st.tables.values_mut().next().unwrap());
|
||||
t.cells[0].fill = old.clone();
|
||||
t.cells[0].text.chars[0].format.over.fill = Some(old.clone());
|
||||
Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let st = d.story(sid).unwrap();
|
||||
assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand"));
|
||||
let cell = &st.tables.values().next().unwrap().cells[0];
|
||||
assert_eq!(cell.fill, "Sand");
|
||||
assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand"));
|
||||
assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand"));
|
||||
d.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+110
-24
@@ -30,12 +30,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text Unit",
|
||||
[],
|
||||
None,
|
||||
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, paragraph or the whole story",
|
||||
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
|
||||
has_doc,
|
||||
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
|
||||
),
|
||||
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));
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text;
|
||||
let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize);
|
||||
@@ -323,7 +324,7 @@ fn hit_byte_with(
|
||||
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
|
||||
const ID: &str = "text.selectAt";
|
||||
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
|
||||
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
|
||||
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 st = s.doc()?;
|
||||
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
@@ -335,33 +336,49 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
|
||||
}
|
||||
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, r.end)),
|
||||
// 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 });
|
||||
st.revision += 1;
|
||||
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
|
||||
}
|
||||
|
||||
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character. At
|
||||
/// the end of a paragraph, what ends it.
|
||||
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
|
||||
/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split:
|
||||
/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base.
|
||||
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
|
||||
let at = |i: usize| s.get(i..).and_then(|r| r.chars().next()).filter(|c| *c != '\n');
|
||||
let i = if at(i).is_none() && i > 0 { prev_char(s, i) } else { i };
|
||||
let Some(c) = at(i) else { return (i, i) };
|
||||
let space = |c: char| c.is_whitespace() && c != '\n';
|
||||
if is_word(c) {
|
||||
word_bounds(s, i)
|
||||
} else if space(c) {
|
||||
let a = s[..i].char_indices().rev().take_while(|(_, c)| space(*c)).last().map_or(i, |(k, _)| k);
|
||||
let e = s[i..].char_indices().find(|(_, c)| !space(*c)).map_or(s.len(), |(k, _)| i + k);
|
||||
(a, e)
|
||||
} else {
|
||||
(i, i + c.len_utf8())
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let i = floor_char_boundary(s, i.min(s.len()));
|
||||
// Clusters never span a paragraph return, so the paragraph is enough.
|
||||
let ps = s[..i].rfind('\n').map_or(0, |k| k + 1);
|
||||
let pe = s[i..].find('\n').map_or(s.len(), |k| i + k);
|
||||
let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect();
|
||||
let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) };
|
||||
let kind = |c: &str| match c.chars().next() {
|
||||
Some(c) if is_word(c) => 1,
|
||||
Some(c) if c.is_whitespace() => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len()));
|
||||
let kd = g.get(k).map_or(0, |(_, c)| kind(c));
|
||||
if kd == 0 {
|
||||
return span(k, k);
|
||||
}
|
||||
let mut a = k;
|
||||
while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut e = k;
|
||||
while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||||
e += 1;
|
||||
}
|
||||
span(a, e)
|
||||
}
|
||||
|
||||
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
|
||||
@@ -397,6 +414,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||||
} else if s.text_drag {
|
||||
return Ok(json!({"story": sid.0, "dragging": true}));
|
||||
}
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
// Dragging from one cell into another selects cells.
|
||||
if extend
|
||||
@@ -721,12 +739,6 @@ pub fn prev_word(s: &str, i: usize) -> usize {
|
||||
}
|
||||
0
|
||||
}
|
||||
fn word_bounds(s: &str, i: usize) -> (usize, usize) {
|
||||
let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
|
||||
let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// 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"))?;
|
||||
@@ -807,6 +819,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"storyEnd" => text.len(),
|
||||
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
|
||||
};
|
||||
s.typing_format = None;
|
||||
let stm = s.doc_mut()?;
|
||||
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
|
||||
stm.revision += 1;
|
||||
@@ -1155,6 +1168,10 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// No undo step for nothing.
|
||||
if changed == 0 {
|
||||
return Err(bad(id, "nothing to change"));
|
||||
}
|
||||
ok()
|
||||
});
|
||||
r?;
|
||||
@@ -1435,6 +1452,69 @@ mod multi_click_tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn moving_the_caret_drops_the_pending_typing_format() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||||
let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
|
||||
let pending = |s: &mut Session| {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
};
|
||||
// Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the
|
||||
// same caret is in the text's own format.
|
||||
let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![
|
||||
Box::new(|s| {
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}),
|
||||
Box::new(|s| {
|
||||
s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
}),
|
||||
Box::new(|s| {
|
||||
s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
}),
|
||||
];
|
||||
for m in &moves {
|
||||
pending(&mut s);
|
||||
m(&mut s);
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(size_at(&s, 6), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Without a move it applies.
|
||||
pending(&mut s);
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(size_at(&s, 6), Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_selection_keeps_grapheme_clusters_whole() {
|
||||
use super::word_or_spaces_at as word;
|
||||
// Decomposed "café": the combining acute belongs to the word, wherever the click lands.
|
||||
let s = "un cafe\u{301} noir";
|
||||
let cafe = (3, 3 + "cafe\u{301}".len());
|
||||
assert_eq!(word(s, 4), cafe);
|
||||
assert_eq!(word(s, 7), cafe, "on the combining mark");
|
||||
// Devanagari with a virama (U+094D) and a dependent vowel sign.
|
||||
let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x";
|
||||
let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len();
|
||||
assert_eq!(word(s, 0), (0, w));
|
||||
assert_eq!(word(s, 9), (0, w), "on the virama");
|
||||
// An emoji ZWJ sequence and a variation selector stay one character.
|
||||
let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}";
|
||||
let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len());
|
||||
assert_eq!(word(s, 2), e);
|
||||
assert_eq!(word(s, 6), e, "on the joiner");
|
||||
assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole");
|
||||
// Empty paragraphs select nothing.
|
||||
assert_eq!(word("a\n\nb", 2), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
|
||||
let mut s = Session::new();
|
||||
@@ -1459,7 +1539,7 @@ mod multi_click_tests {
|
||||
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
|
||||
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
|
||||
assert_eq!(select("line", 9), (0, 19));
|
||||
assert_eq!(select("paragraph", 9), (0, 19));
|
||||
assert_eq!(select("paragraph", 9), (0, 20), "with its return");
|
||||
assert_eq!(select("paragraph", 22), (20, text.len()));
|
||||
assert_eq!(select("story", 9), (0, text.len()));
|
||||
// A second click inside the selected word selects again rather than starting a text drag.
|
||||
@@ -1477,6 +1557,7 @@ mod multi_click_tests {
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (7, 12));
|
||||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
|
||||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,6 +1590,11 @@ mod style_link_tests {
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&mut s, 0), "Italic");
|
||||
assert_eq!(style_at(&mut s, 7), "Regular");
|
||||
// Nothing to change: an error and no undo step.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("type.bold", &json!({"on": false})).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// At a caret the style is for what is typed next.
|
||||
let end = "plain words here".len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
|
||||
|
||||
@@ -1440,11 +1440,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
||||
{
|
||||
// Count presses in a row here: egui reports double and triple clicks only on release and
|
||||
// stops at three, but a press must know (word, line, paragraph, story).
|
||||
// Like egui, the next press must come within the double-click delay of the last release.
|
||||
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
|
||||
let (released_at, last_pos, last_n): (f64, Pos2, u8) = ui.data(|d| d.get_temp(clicks_id)).unwrap_or((f64::NEG_INFINITY, o, 0));
|
||||
let n = if now - released_at <= delay && (o - last_pos).length() <= dist { last_n.saturating_add(1) } else { 1 };
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, (f64::NEG_INFINITY, o, n)));
|
||||
let n = press_count(ui.data(|d| d.get_temp(clicks_id)), o, now, delay, dist);
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, Clicks { at: f64::NEG_INFINITY, pos: o, n }));
|
||||
events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n });
|
||||
down = true;
|
||||
}
|
||||
@@ -1476,8 +1474,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
||||
ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id));
|
||||
}
|
||||
if down && released {
|
||||
if let Some((_, at, n)) = ui.data(|d| d.get_temp::<(f64, Pos2, u8)>(clicks_id)) {
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, (now, at, n)));
|
||||
if let Some(c) = ui.data(|d| d.get_temp::<Clicks>(clicks_id)) {
|
||||
let dist = ui.ctx().options(|o| o.input_options.max_click_dist);
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, release_clicks(c, latest.unwrap_or(c.pos), now, dist)));
|
||||
}
|
||||
let p = latest.unwrap_or(rect.center());
|
||||
events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 1 });
|
||||
@@ -1799,6 +1798,31 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
|
||||
true
|
||||
}
|
||||
|
||||
/// Presses in a row on the canvas: the last press's count, and where and when it was released
|
||||
/// (`at` is −∞ while the button is down).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Clicks {
|
||||
at: f64,
|
||||
pos: Pos2,
|
||||
n: u8,
|
||||
}
|
||||
|
||||
/// The count of a press at `pos` at time `now`: one more than the last press when it comes within
|
||||
/// the double-click delay of the last release and near where that release was, else 1.
|
||||
fn press_count(last: Option<Clicks>, pos: Pos2, now: f64, delay: f64, dist: f32) -> u8 {
|
||||
match last {
|
||||
Some(c) if now - c.at <= delay && (pos - c.pos).length() <= dist => c.n.saturating_add(1),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The record a release at `pos` leaves: a release away from its press ended a drag, which is
|
||||
/// not a click, so the next press counts from one.
|
||||
fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks {
|
||||
let n = if (pos - press.pos).length() <= dist { press.n } else { 0 };
|
||||
Clicks { at: now, pos, n }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use designcraft_geom::Unit;
|
||||
@@ -1806,6 +1830,23 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_press_after_a_drag_is_not_a_double_click() {
|
||||
let (delay, dist) = (0.3, 6.0);
|
||||
let press = |last, x: f32, now| press_count(last, pos2(x, 0.0), now, delay, dist);
|
||||
// Click, click: a double click.
|
||||
let n = press(None, 0.0, 0.0);
|
||||
let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n }, pos2(0.0, 0.0), 0.1, dist);
|
||||
assert_eq!(press(Some(up), 1.0, 0.2), 2);
|
||||
// A drag from 0 to 100, then a fast press where it started or where it ended: a single click.
|
||||
let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(100.0, 0.0), 0.1, dist);
|
||||
assert_eq!(press(Some(up), 0.0, 0.2), 1);
|
||||
assert_eq!(press(Some(up), 100.0, 0.2), 1);
|
||||
// Too slow: a single click.
|
||||
let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(0.0, 0.0), 0.1, dist);
|
||||
assert_eq!(press(Some(up), 0.0, 1.0), 1);
|
||||
}
|
||||
|
||||
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
|
||||
Reference in New Issue
Block a user