diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index b58f62798..157da62b4 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1726,6 +1726,58 @@ fn an_eyedropper_pick_from_a_frame_of_the_selected_story_styles_the_other_frames } } +/// Whether a character in the frame that `text.thread.add` places at [`THREADED_AT`] is highlighted. +fn third_frame_highlighted(s: &mut Session) -> bool { + let in_frame = |o: &vectorcraft_tools::Overlay| matches!(o, vectorcraft_tools::Overlay::Highlight { quad, color: [60, 120, 255, 90] } if quad.iter().all(|p| p.x > 690.0)); + s.overlays(ViewInfo::default()).iter().any(in_frame) +} + +/// Where a third frame of the story of [`threaded_story_edited_in_its_second_frame`] goes, clear +/// of its other frames. +const THREADED_AT: [f64; 2] = [700.0, 10.0]; + +#[test] +fn a_frame_threaded_into_the_selected_story_is_highlighted_and_takes_the_edit() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + red_source(&mut s, b); + s.execute("select.all", &json!({})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let c = s.execute("text.thread.add", &json!({"from": a.0, "before": true, "at": THREADED_AT})).unwrap()["id"].as_u64().unwrap(); + let c = NodeId(c); + assert!(!obj(&s, c).plain_text().is_empty(), "the story starts in the new frame"); + assert!(third_frame_highlighted(&mut s), "the new frame's text is highlighted"); + pick_red_source(&mut s, mods); + assert!(all_red(&s, a) && all_red(&s, b) && all_red(&s, c), "every frame takes the pick"); + s.select_tool("type", ViewInfo::default()).unwrap(); + assert!(third_frame_highlighted(&mut s), "back in the Type tool too"); + s.tool_text("x", ViewInfo::default()).unwrap(); + let text = [a, b, c].map(|f| obj(&s, f).plain_text()).concat(); + assert_eq!(text, "x", "typing replaces the text of every frame"); + } +} + +#[test] +fn a_frame_that_left_the_selected_story_is_not_highlighted_and_leaves_the_edit() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + let c = s.execute("text.thread.add", &json!({"from": b.0, "at": THREADED_AT})).unwrap()["id"].as_u64().unwrap(); + let c = NodeId(c); + red_source(&mut s, b); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0, c.0])); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + s.execute("text.thread.break", &json!({"id": b.0, "port": "out"})).unwrap(); + s.execute("text.setText", &json!({"id": c.0, "text": "Kept"})).unwrap(); + assert!(!third_frame_highlighted(&mut s), "the frame that left has no highlight"); + pick_red_source(&mut s, mods); + assert!(all_red(&s, a) && all_red(&s, b), "the frames still threaded take the pick"); + assert!(!any_red(&s, c), "the frame that left stays as it is"); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.tool_text("x", ViewInfo::default()).unwrap(); + assert_eq!(obj(&s, c).plain_text(), "Kept", "typing leaves the frame that left"); + } +} + #[test] fn typing_after_a_thread_is_removed_leaves_the_frame_that_left_the_story() { for through_eyedropper in [false, true] { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 6066baab8..20a31deb5 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -174,9 +174,10 @@ pub struct TypeTool { /// Caret and selection anchor (byte offsets into the plain text). caret: usize, anchor: usize, - /// After Select All in a frame of threaded text: the story's frames in thread order, the edited - /// one included. While the selection is the edited frame's whole text, the text of the other - /// frames is selected too. Empty otherwise. + /// After Select All in a frame of threaded text: the story's frames in thread order then, the + /// edited one included. While the selection is the edited frame's whole text and this holds the + /// edited frame, the text of the other frames of its current thread is selected too. Empty + /// otherwise. story: Vec, /// Horizontal position kept by Up/Down (text space). goal_x: Option, @@ -441,8 +442,8 @@ impl TypeTool { } /// The other frames whose whole text is selected along with the edited frame's after Select - /// All in threaded text ([`Self::story`]) that are still threaded with it: none once the - /// selection is not all of the edited frame's text. + /// All in threaded text ([`Self::story`]), read from the document's current thread: none once + /// the selection is not all of the edited frame's text. fn story_others(&self, cx: &ToolContext) -> Vec { let Some(id) = self.editing.filter(|id| self.story.contains(id)) else { return vec![] }; let Some(t) = Self::text(cx, id) else { return vec![] }; @@ -450,8 +451,7 @@ impl TypeTool { if a != 0 || b < edit::runs_len(&t.runs) { return vec![]; } - let Some(thread) = cx.doc.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] }; - self.story.iter().copied().filter(|f| *f != id && thread.contains(f) && Self::text(cx, *f).is_some()).collect() + cx.doc.story_of(id).into_iter().filter(|f| *f != id).collect() } /// Move the caret (Shift keeps the anchor). Moving ends the typing session. diff --git a/docs/mcp.md b/docs/mcp.md index fe8eff6f4..56d6e2d67 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5017,8 +5017,10 @@ every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, w undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, 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`. Once a thread is -removed, typing replaces the text of the edited frame only. +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. ```json {"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}}