mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
Type: a selected story stays selected when its text leaves the edited frame
When a frame threaded in before the edited frame takes all of the story's text, the edited frame is left with no text while the story stays selected. The other frames' text is now highlighted, Delete and Backspace replace the story, an Alt+arrow step changes the tracking of every frame, and Copy and Cut take the story. A story whose frames hold no text has nothing to copy, and Delete in it records no undo step.
This commit is contained in:
@@ -1757,6 +1757,84 @@ fn a_frame_threaded_into_the_selected_story_is_highlighted_and_takes_the_edit()
|
||||
}
|
||||
}
|
||||
|
||||
/// After Select All in the story of [`threaded_story_edited_in_its_second_frame`], a frame threaded
|
||||
/// in before the edited one takes the whole story, and the edited frame holds no text.
|
||||
fn selected_story_moved_into_a_new_frame(s: &mut Session, a: NodeId, b: NodeId) -> NodeId {
|
||||
let rect = [THREADED_AT[0], THREADED_AT[1], 400.0, 600.0];
|
||||
let c = NodeId(s.execute("text.thread.add", &json!({"from": a.0, "before": true, "rect": rect})).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(obj(s, a).plain_text().is_empty() && obj(s, b).plain_text().is_empty(), "the new frame holds the whole story");
|
||||
assert!(!obj(s, c).plain_text().is_empty());
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_story_that_left_the_edited_frame_is_highlighted_and_typing_replaces_it() {
|
||||
for through_eyedropper in [false, true] {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
if through_eyedropper {
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
}
|
||||
let c = selected_story_moved_into_a_new_frame(&mut s, a, b);
|
||||
assert!(third_frame_highlighted(&mut s), "through the Eyedropper: {through_eyedropper}");
|
||||
if through_eyedropper {
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
assert!(third_frame_highlighted(&mut s), "back in the Type tool");
|
||||
}
|
||||
s.tool_text("x", ViewInfo::default()).unwrap();
|
||||
assert_eq!([a, b, c].map(|f| obj(&s, f).plain_text()).concat(), "x", "through the Eyedropper: {through_eyedropper}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_replaces_a_story_that_left_the_edited_frame() {
|
||||
for k in [ToolKey::Backspace, ToolKey::Delete] {
|
||||
let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let c = selected_story_moved_into_a_new_frame(&mut s, a, b);
|
||||
let steps = undo_labels(&s).len();
|
||||
key(&mut s, k, Mods::default());
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!([a, b, c].map(|f| obj(&s, f).plain_text()).concat(), "", "{k:?} empties every frame");
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "{k:?}: one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!([a, b, c].map(|f| obj(&s, f).plain_text()).concat(), story);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_in_a_story_that_holds_no_text_records_no_undo_step() {
|
||||
for k in [ToolKey::Backspace, ToolKey::Delete] {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
key(&mut s, ToolKey::Delete, Mods::default());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(s.tool_options()["story"], json!([a.0, b.0]));
|
||||
let steps = undo_labels(&s).len();
|
||||
key(&mut s, k, Mods::default());
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!(undo_labels(&s).len(), steps, "{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_step_keeps_a_story_that_left_the_edited_frame_selected() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let c = selected_story_moved_into_a_new_frame(&mut s, a, b);
|
||||
// Alt+Right steps the tracking of the selected text, not the kerning at a caret.
|
||||
key(&mut s, ToolKey::Right, Mods { alt: true, ..Mods::default() });
|
||||
assert!(obj(&s, c).runs.iter().all(|r| r.style.tracking > 0.0), "every character of the story takes the step");
|
||||
// A key that does nothing leaves it selected as well.
|
||||
key(&mut s, ToolKey::BracketLeft, Mods::default());
|
||||
assert!(third_frame_highlighted(&mut s));
|
||||
// The story stays the target of a fill and of a switch to the Eyedropper.
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(all_red(&s, c), "the fill reaches the story");
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
assert!(third_frame_highlighted(&mut s), "the Eyedropper keeps the story");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_that_left_the_selected_story_is_not_highlighted_and_leaves_the_edit() {
|
||||
for mods in eyedropper_picks() {
|
||||
|
||||
@@ -231,8 +231,8 @@ impl TypeTool {
|
||||
return None;
|
||||
}
|
||||
let id = self.editing?;
|
||||
let (attribute, by) = plain::step_key(key, mods.shift, a == b, vertical)?;
|
||||
let story = !self.story_others(cx).is_empty();
|
||||
let (attribute, by) = plain::step_key(key, mods.shift, a == b && !story, vertical)?;
|
||||
let mut out = self.commit();
|
||||
let by = if mods.cmd { by * 5.0 } else { by };
|
||||
// With the whole story selected, `type.step` steps the text of every frame of it.
|
||||
@@ -454,6 +454,21 @@ impl TypeTool {
|
||||
cx.doc.story_of(id).into_iter().filter(|f| *f != id).collect()
|
||||
}
|
||||
|
||||
/// Whether the whole story is selected ([`Self::story_others`]) and its other frames hold text.
|
||||
/// The story stays selected when the edited frame holds no text, as after a frame threaded in
|
||||
/// before it took the text.
|
||||
fn story_selected(&self, cx: &ToolContext) -> bool {
|
||||
self.story_others(cx).into_iter().any(|f| Self::text(cx, f).is_some_and(|o| edit::runs_len(&o.runs) > 0))
|
||||
}
|
||||
|
||||
/// Put back the caret and anchor `selected` that a key clamped to the edited frame's text, when
|
||||
/// the key leaves the whole story selected.
|
||||
fn keep_story_offsets(&mut self, story: bool, selected: (usize, usize)) {
|
||||
if story {
|
||||
(self.caret, self.anchor) = selected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the caret (Shift keeps the anchor). Moving ends the typing session.
|
||||
fn move_to(&mut self, byte: usize, extend: bool) -> Vec<Action> {
|
||||
let out = self.commit();
|
||||
|
||||
@@ -197,11 +197,14 @@ impl Tool for TypeTool {
|
||||
};
|
||||
let text = t.plain_text();
|
||||
let len = text.len();
|
||||
let story = self.story_selected(cx);
|
||||
let selected = (self.caret, self.anchor);
|
||||
self.caret = self.caret.min(len);
|
||||
self.anchor = self.anchor.min(len);
|
||||
let (a, b) = self.sel();
|
||||
// Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps).
|
||||
if let Some(out) = self.step_attribute(cx, key, mods, a, b, t.vertical) {
|
||||
self.keep_story_offsets(story, selected);
|
||||
return out;
|
||||
}
|
||||
let word = mods.cmd || mods.alt;
|
||||
@@ -233,7 +236,7 @@ impl Tool for TypeTool {
|
||||
}
|
||||
ToolKey::Enter => self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None),
|
||||
ToolKey::Tab => self.replace(cx, "\t", None),
|
||||
ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None),
|
||||
ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None),
|
||||
ToolKey::Backspace if self.caret > 0 => {
|
||||
self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) };
|
||||
self.replace(cx, "", None)
|
||||
@@ -313,7 +316,10 @@ impl Tool for TypeTool {
|
||||
let to = if mods.cmd { len } else { vectorcraft_text::line_end_of(&lay, self.caret) };
|
||||
self.move_to(to, mods.shift)
|
||||
}
|
||||
_ => vec![],
|
||||
_ => {
|
||||
self.keep_story_offsets(story, selected);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option<Range<usize>>) -> Vec<Action> {
|
||||
@@ -678,7 +684,10 @@ impl Tool for TypeTool {
|
||||
for q in vectorcraft_text::selection_quads(&lay, a, b) {
|
||||
o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] });
|
||||
}
|
||||
// The whole story is selected: the other frames' text is highlighted too.
|
||||
}
|
||||
// The whole story is selected: the other frames' text is highlighted too, also when the
|
||||
// edited frame holds no text.
|
||||
if self.preedit.is_none() {
|
||||
for f in self.story_others(cx) {
|
||||
let Some(other) = Self::text(cx, f) else { continue };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), other);
|
||||
|
||||
@@ -540,11 +540,13 @@ static CLIP_RUNS: Mutex<Option<Value>> = Mutex::new(None);
|
||||
|
||||
fn text_copy(app: &mut VectorcraftApp) -> bool {
|
||||
let Some((id, a, b)) = text_editing(app) else { return false };
|
||||
if a == b {
|
||||
let story = text_story(app);
|
||||
// An empty selection has nothing to copy, unless the story's text is in the other frames.
|
||||
if a == b && (story.is_none() || app.session.active().is_none_or(|st| st.doc.story_text(id).is_empty())) {
|
||||
return false;
|
||||
}
|
||||
// After Select All in threaded text: the whole story.
|
||||
let range = if text_story(app).is_some() { json!({"id": id.0, "story": true}) } else { json!({"id": id.0, "start": a, "end": b}) };
|
||||
let range = if story.is_some() { json!({"id": id.0, "story": true}) } else { json!({"id": id.0, "start": a, "end": b}) };
|
||||
let Ok(r) = app.session.execute("text.getRange", &range) else { return false };
|
||||
let runs = r["runs"].clone();
|
||||
app.session.set_tool_option("copy", &runs);
|
||||
|
||||
@@ -1244,6 +1244,50 @@ fn snap_to_glyph_options_scroll_into_view_and_turn_snapping_on() {
|
||||
assert!(!app.session.prefs.glyph_snap.anchors, "the options take clicks once it is on");
|
||||
}
|
||||
|
||||
/// Copy and Cut take a selected story after a frame threaded in before the edited one took its whole
|
||||
/// text.
|
||||
#[test]
|
||||
fn copy_and_cut_take_a_story_that_left_the_edited_frame() {
|
||||
for cut in [false, true] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4);
|
||||
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
|
||||
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
|
||||
let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("text.thread.create", json!({})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0}));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let c = app.run("text.thread.add", json!({"from": a, "before": true, "rect": [700, 10, 400, 600]})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
assert_eq!((text(&app, a), text(&app, b), text(&app, c)), (String::new(), String::new(), story.clone()));
|
||||
assert!(intercept_text_command(&mut app, if cut { "edit.cut" } else { "edit.copy" }).is_some());
|
||||
let copied: Vec<String> = app
|
||||
.session
|
||||
.clipboard
|
||||
.nodes
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.plain_text()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(copied, std::slice::from_ref(&story), "cut: {cut}");
|
||||
assert_eq!(text(&app, c).is_empty(), cut);
|
||||
if cut {
|
||||
// A story that holds no text has nothing to copy, so Cut does not run its delete.
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
assert_eq!(text_story(&app).map(|frames| frames.len()), Some(3));
|
||||
assert!(!text_copy(&mut app));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After Select All in a frame of threaded text, the Character and Paragraph panels and Copy act on
|
||||
/// the text of every frame of the story.
|
||||
#[test]
|
||||
|
||||
+2
-2
@@ -5019,8 +5019,8 @@ one undo step. A caret move or a click ends the story selection. Switching to th
|
||||
and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of
|
||||
every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. While the story stays
|
||||
selected, its frames are the ones threaded with the edited frame at that moment: a frame that `text.thread.add` adds is
|
||||
highlighted and takes the edit, and a frame that leaves the thread is not highlighted and keeps its text. Once a thread
|
||||
is removed, typing replaces the text of the edited frame only.
|
||||
highlighted and takes the edit, and a frame that leaves the thread is not highlighted and keeps its text. The story
|
||||
stays selected when a frame added before the edited frame takes all of its text. Once a thread is removed, typing replaces the text of the edited frame only.
|
||||
|
||||
```json
|
||||
{"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}}
|
||||
|
||||
Reference in New Issue
Block a user