diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 157da62b4..ed3e2eac3 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -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() { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 20a31deb5..0bc29a558 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -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 { let out = self.commit(); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 180a5e811..1e9b1be88 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -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>) -> Vec { @@ -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); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 7e68824c1..34a3e006f 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -540,11 +540,13 @@ static CLIP_RUNS: Mutex> = 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); diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 2a3519385..e6927f843 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -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 = 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] diff --git a/docs/mcp.md b/docs/mcp.md index 56d6e2d67..c3890b89f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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}}}