Type: selected type shows its in and out ports, and the in port threads backwards

Squashed from #3817 onto main.
This commit is contained in:
bflatastic
2026-10-24 03:09:10 +07:00
parent b321a77d38
commit f247772042
15 changed files with 619 additions and 142 deletions
+36 -3
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Thread Text",
[],
None,
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread: closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
"{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
has_doc,
add
),
@@ -263,11 +263,12 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// `text.thread.add`: the out port's loaded text, placed.
/// `text.thread.add`: the loaded text of a port, placed.
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.thread.add";
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
let before = switch_or(p, "before", false, C)?;
let d = &s.doc()?.doc;
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?;
let style = src.first_style();
@@ -330,9 +331,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
match d.text_threads.iter_mut().find(|t| t.contains(&from)) {
Some(t) => {
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1);
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| if before { i } else { i + 1 });
t.insert(at, to);
}
None if before => d.text_threads.push(vec![to, from]),
None => d.text_threads.push(vec![from, to]),
}
// Force a re-flow of the thread on this edit: the edit leaves existing area type `to`
@@ -618,6 +620,35 @@ mod tests {
assert_eq!(threads(&s), [[a, c, d]]);
}
/// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes
/// before the frame it was loaded from, so the story starts there and flows on into it; in the
/// middle of a thread the new frame goes in between, each in one undo step.
#[test]
fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() {
let (mut s, a, b) = setup();
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap());
assert_eq!(threads(&s), [[c, a]]);
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click");
assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c));
assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4));
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]);
// In between the frame and the one before it.
let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap());
assert_eq!(threads(&s), [[c, d, a]]);
assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [[c, a]]);
// A closed path clicked with the in port's text comes first, with the start of the story.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b);
assert_eq!(threads(&s), [[b, a]]);
assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty());
assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4));
// `before: false` is the out port's.
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap());
assert_eq!(threads(&s), [[b, a, e]]);
}
/// Type on a path along a line beside the area type of [`setup`].
fn type_on_a_line(s: &mut Session) -> u64 {
let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap());
@@ -692,6 +723,8 @@ mod tests {
json!({"from": a, "rect": [0, 0, 10, 0]}),
json!({"from": a, "rect": [0, 0, 10]}),
json!({"from": a, "rect": "big"}),
json!({"from": a, "at": [0, 0], "before": "yes"}),
json!({"from": a, "at": [0, 0], "before": 1}),
] {
assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document");
+3 -3
View File
@@ -1432,8 +1432,8 @@ pub struct Session {
pub(crate) pointer_held: bool,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
/// Which selected type overflows its frame, for the markers every tool but the Type tools shows.
pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks,
/// Which selected type overflows its frame, for the ports every tool but the Type tools shows.
pub(crate) port_marks: vectorcraft_tools::text::PortMarks,
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
/// starts; dropping it stops it.
pub(crate) font_search: Option<cmd::fontfiles::FontSearch>,
@@ -1527,7 +1527,7 @@ impl Session {
plane_widget_press: false,
pointer_held: false,
ruler_guide: None,
overflow_marks: Default::default(),
port_marks: Default::default(),
font_search: None,
link_search: None,
search_rules: None,
+30
View File
@@ -1216,3 +1216,33 @@ fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() {
assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long);
assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]);
}
/// #2572: with the Selection tool, area type whose text fits shows both ports, and a click on its
/// in port then on empty canvas makes a new frame the story starts in, flowing on into the frame
/// clicked, in one undo step; the ports then show the thread's arrows.
#[test]
fn clicking_the_in_port_then_the_canvas_threads_a_new_frame_before_it() {
let mut s = session();
let v = ViewInfo::default();
let words = "Words that fit. ";
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "size": 12, "area": {"width": 100, "height": 40}})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("selection", v).unwrap();
let boxes = |s: &mut Session| {
s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { color: [255, 255, 255, 255], .. })).count()
};
assert_eq!(boxes(&mut s), 2, "an in port and an out port");
let steps = undo_labels(&s).len();
click(&mut s, 50.0, 50.0 + vectorcraft_tools::text::PORT_RISE_PX);
assert_eq!(s.cursor(Point::new(400.0, 400.0), Mods::default(), v), vectorcraft_tools::Cursor::LoadedText);
click(&mut s, 400.0, 400.0);
let doc = &s.doc().unwrap().doc;
let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) };
assert_eq!(thread[1], id, "the frame clicked comes second");
assert_eq!(format!("{}{}", obj(&s, thread[0]).plain_text(), obj(&s, id).plain_text()), words);
assert!(!obj(&s, thread[0]).plain_text().is_empty(), "the story starts in the new frame");
assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]);
let strokes = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { .. })).count();
assert_eq!(strokes, 6, "both frames selected: the first one's out port and the second one's in port show arrows");
}
+4 -4
View File
@@ -473,11 +473,11 @@ impl Session {
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
// The Type tools draw the overflow markers of selected type themselves.
// The Type tools draw the ports of selected type themselves.
if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") {
let mut marks = std::mem::take(&mut self.overflow_marks);
v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx)));
self.overflow_marks = marks;
let mut marks = std::mem::take(&mut self.port_marks);
v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::thread_ports(&mut marks, cx)));
self.port_marks = marks;
}
if let Some(g) = self.ruler_guide.take() {
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
+1 -1
View File
@@ -130,7 +130,7 @@ look = "Arrow with a type badge (a letter T)."
id = "LoadedText"
group = "Selection"
shown_when = [
"The Selection tool after a click on the red out port of selected area type or type on a path whose text overflows: the pointer carries that text until a click threads it into a closed path, area type or type on a path, a click on empty canvas makes a new frame of the same kind and shape there, or a drag draws the new frame. Escape or another tool drops it.",
"The Selection tool after a click on the out port or the in port of selected area type or type on a path, threaded, overflowing or not: the pointer carries that text until a click threads it into a closed path, area type or type on a path, a click on empty canvas makes a new frame of the same kind and shape there, or a drag draws the new frame; from the in port the frame placed comes before the clicked one. Escape or another tool drops it.",
]
hotspot = "Where the new frame's top-left corner goes (the click), or the path clicked."
look = "A small block of text lines: the overflow text the pointer carries. Until it is drawn it borrows the I-beam (Text)."
+2 -2
View File
@@ -561,8 +561,8 @@ pub enum Cursor {
/// Over the type widget of selected type (a double-click converts point type to area type and
/// back): the arrow with a type badge.
TypeWidget,
/// The Selection tool loaded with the text of the out port of area type or type on a path (a
/// click threads it into a path or a new frame, a drag draws the new frame).
/// The Selection tool loaded with the text of area type or type on a path by a click on its out
/// or in port (a click threads it into a path or a new frame, a drag draws the new frame).
LoadedText,
/// The Shape Builder: a crosshair with a plus (merge mode)...
ShapeBuilder,
+104 -24
View File
@@ -17,11 +17,13 @@
//! scaling its type: the text reflows at its size.
//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and
//! E pressed again makes it take the objects it touches.
//! A click on the red out port of selected area type or type on a path whose text overflows loads
//! the pointer with that text ([`Cursor::LoadedText`]): the next click threads it into the closed
//! path, area type or type on a path under the pointer, or into a new frame of the same kind, shape
//! and size at the click on empty canvas,
//! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text.
//! Selected area type and type on a path show an in port and an out port. A click on the out port
//! loads the pointer with the text ([`Cursor::LoadedText`]): the next click threads it into the
//! closed path, area type or type on a path under the pointer, or into a new frame of the same
//! kind, shape and size at the click on empty canvas, and a drag draws a rectangle for the new
//! frame (`text.thread.add`), which comes after the clicked one in its thread. A click on the in
//! port does the same with the new frame put before it: the story then starts there and flows on
//! into the clicked one. Escape drops the loaded text.
//! With Smart Guides and Invoke Distance Guides on, Alt/Option held over another object shows how
//! far the selection is from it, and over an empty part of an artboard how far it is from the
//! artboard's edges ([`crate::guides::distance_guides`]).
@@ -90,10 +92,12 @@ enum State {
Mirror(crate::mirror::MirrorDrag),
/// Dragging a bracket of type on a path.
Bracket(BracketDrag),
/// Loaded with the text of area type or type on a path `from` by a click on its out port;
/// Loaded with the text of area type or type on a path `from` by a click on its out port, or
/// on its in port (`before`: the frame it goes into comes before `from` in its thread);
/// `press`: where the next press went down and where the pointer is now.
Loaded {
from: NodeId,
before: bool,
press: Option<(Point, Point)>,
},
}
@@ -483,20 +487,25 @@ impl SelectionTool {
}
/// The pointer loaded with the text of area type or type on a path `from` ([`State::Loaded`]):
/// a press starts placing it, and its release threads it (`text.thread.add`). None when `from`
/// can no longer thread: the press is an ordinary one.
fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, press: Option<(Point, Point)>) -> Option<Vec<Action>> {
/// a press starts placing it, and its release threads it (`text.thread.add`), into a frame put
/// `before` `from` or after it. None when `from` can no longer thread: the press is an
/// ordinary one.
fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, before: bool, press: Option<(Point, Point)>) -> Option<Vec<Action>> {
let p = ev.pos;
match (ev.kind, press) {
(PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_threadable) => {
self.state = State::Idle;
return None;
}
(PointerKind::Down, _) => self.state = State::Loaded { from, press: Some((p, p)) },
(PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, press: Some((start, p)) },
(PointerKind::Down, _) => self.state = State::Loaded { from, before, press: Some((p, p)) },
(PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, before, press: Some((start, p)) },
(PointerKind::Up, Some((start, _))) => {
self.state = State::Idle;
return Some(vec![Action::Exec("text.thread.add".into(), thread_params(cx, from, start, p))]);
let mut params = thread_params(cx, from, start, p);
if before {
params["before"] = json!(true);
}
return Some(vec![Action::Exec("text.thread.add".into(), params)]);
}
_ => {}
}
@@ -589,8 +598,8 @@ impl Tool for SelectionTool {
let hovering = ev.kind == PointerKind::Move && matches!(self.state, State::Idle);
self.alt_hover = hovering && ev.mods.alt;
self.distance = hovering.then(|| distance_target(cx, ev.pos, ev.mods)).flatten();
if let State::Loaded { from, press } = self.state
&& let Some(out) = self.loaded(cx, ev, from, press)
if let State::Loaded { from, before, press } = self.state
&& let Some(out) = self.loaded(cx, ev, from, before, press)
{
return out;
}
@@ -663,9 +672,10 @@ impl Tool for SelectionTool {
vec![]
}
(PointerKind::Down, _) => {
// 0. The out port of selected area type: the pointer takes its text.
if let Some(from) = crate::text::out_port_at(cx, p) {
self.state = State::Loaded { from, press: None };
// 0. A port of selected area type or type on a path: the pointer takes its text,
// to thread on from the out port or back from the in port.
if let Some((from, port)) = crate::text::port_at(cx, p) {
self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None };
return vec![];
}
// 1. Live Corners widgets, pie widgets and type on a path's brackets, then the
@@ -2541,10 +2551,9 @@ mod tests {
assert_eq!(a, vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]);
}
/// Escape drops the loaded text, and the next click is an ordinary one; type that fits its
/// frame shows no out port to click.
/// Escape drops the loaded text, and the next click is an ordinary one.
#[test]
fn escape_drops_the_loaded_text_and_type_that_fits_has_no_port() {
fn escape_drops_the_loaded_text() {
let (d, id, _, port) = overflowing_area_type();
let mut s = Selection::default();
s.set([id]);
@@ -2559,15 +2568,86 @@ mod tests {
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
/// [`doc_with_area_type`], its text fitting its frame; where its in and out ports are.
fn fitting_area_type() -> (vectorcraft_doc::Document, NodeId, Point, Point) {
let (d, id) = doc_with_area_type();
let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") };
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).overflow, "the text fits");
// On the left side below the top-left corner, on the right side above the bottom-right one.
let (input, output) = (t.xf * Point::new(0.0, crate::text::PORT_RISE_PX), t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX));
(d, id, input, output)
}
/// #2572: selected area type has both ports to click whether its text fits or not; other
/// points of its frame, and type that isn't selected, have none.
#[test]
fn every_selected_area_type_has_an_in_and_an_out_port() {
use crate::text::{Port, port_at};
let (d, id, input, output) = fitting_area_type();
let p = paint();
let mut s = Selection::default();
assert_eq!(port_at(&cx(&d, &s, &p), output), None, "not selected");
s.set([id]);
let cx = cx(&d, &s, &p);
assert_eq!(port_at(&cx, output), Some((id, Port::Out)));
assert_eq!(port_at(&cx, input), Some((id, Port::In)));
assert_eq!(port_at(&cx, Point::new(input.x + 4.0, input.y + 4.0)), Some((id, Port::In)), "the box around it");
assert_eq!(port_at(&cx, Point::new(input.x + 12.0, input.y)), None);
assert_eq!(port_at(&cx, Point::new(output.x, output.y + crate::text::PORT_RISE_PX)), None, "the corner");
}
/// #2572: a click on the out port of type that fits loads its text as well; the next click
/// threads it into a new frame after it.
#[test]
fn a_click_on_the_out_port_of_type_that_fits_starts_threading() {
let (d, id, _, output) = fitting_area_type();
let mut s = Selection::default();
s.set([id]);
let cx = crate::testutil::cx(&d, &s, &p);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y));
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
assert!(t.pointer(&cx, &ev(PointerKind::Down, output.x, output.y)).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, output.x, output.y)).is_empty());
assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0));
assert_eq!(
t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)),
vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))]
);
}
/// #2572: a click on the in port loads the text to thread backwards: the next click on empty
/// canvas, on a closed path or a drag makes the frame the story flows from into this one
/// (`before`). The corner handle above the in port still resizes the frame.
#[test]
fn a_click_on_the_in_port_threads_into_a_frame_before_it() {
let (d, id, input, _) = fitting_area_type();
let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id;
let mut s = Selection::default();
s.set([id]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)];
let load = |t: &mut SelectionTool| {
assert!(t.pointer(&cx, &ev(PointerKind::Down, input.x, input.y)).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, input.x, input.y)).is_empty());
assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
};
load(&mut t);
t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0], "before": true})));
load(&mut t);
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0, "before": true})));
load(&mut t);
t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0));
t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0], "before": true})));
let corner = Point::new(input.x, input.y - crate::text::PORT_RISE_PX);
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]);
}
/// The memo answers for the revision, the selected objects and Use Preview Bounds it measured
+154 -60
View File
@@ -326,97 +326,188 @@ impl TypeTool {
out.push(Action::Notify("text.editNew".into()));
out
}
fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec<Overlay>) {
if lay.overflow {
overflow_port(t, cx, o);
}
}
}
/// How far (screen px) the out port of area type sits above the bottom-right corner of its frame,
/// on its right side: clear of the bounding box's corner handle, so both can be grabbed.
/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the
/// out port above the bottom-right corner on the right side, the in port below the top-left corner
/// on the left side, clear of the bounding box's corner handles, so both can be grabbed.
pub const PORT_RISE_PX: f64 = 12.0;
/// Half the out port's side (screen px).
/// Half a port's side (screen px).
const PORT_HALF_PX: f64 = 4.5;
/// The red of the out port of text that doesn't fit.
const OVERFLOW_RED: [u8; 3] = [230, 30, 30];
/// Centre of the out port of `t`: on the right side of an area type frame just above its
/// bottom-right corner ([`PORT_RISE_PX`]), and for type on a path just past its end bracket, where
/// its text stops, clear of the bracket, which drags.
fn out_port(t: &TextObject, cx: &ToolContext) -> Option<Point> {
/// One of the two ports of area type or type on a path: its story flows in through the in port
/// and on through the out port.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Port {
In,
Out,
}
/// What a port shows: empty when the frame is not threaded on that side (and, for the out port,
/// all its text is seen), an arrow when it is threaded to another frame there, a red "+" in the out
/// port when its text doesn't fit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PortState {
Empty,
Threaded,
Overflow,
}
/// Centre of `port` of `t`, `px` document units to the screen pixel: area type's on its frame's
/// sides by [`PORT_RISE_PX`] from the corners; type on a path's along its path, as far past its end
/// bracket (out) or before its start bracket (in), clear of the brackets, which drag.
pub fn port_centre(t: &TextObject, port: Port, px: f64) -> Option<Point> {
let rise = PORT_RISE_PX * px;
match &t.kind {
TextKind::Area { frame } => {
let b = frame.bounds()?;
let corner = t.xf * Point::new(b.x1, b.y1);
let up = t.xf * Point::new(b.x1, b.y0) - corner;
let len = up.hypot();
Some(if len > 1e-9 && len.is_finite() { corner + up * (cx.tol(PORT_RISE_PX).min(len / 2.0) / len) } else { corner })
// The corner the port sits by, and the far end of the side it sits on.
let (corner, far) = match port {
Port::Out => (Point::new(b.x1, b.y1), Point::new(b.x1, b.y0)),
Port::In => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)),
};
let (corner, side) = (t.xf * corner, t.xf * far - t.xf * corner);
let len = side.hypot();
Some(if len > 1e-9 && len.is_finite() { corner + side * (rise.min(len / 2.0) / len) } else { corner })
}
TextKind::OnPath { path, .. } => {
let arc = vectorcraft_geom::ArcPath::new(path);
let (_, end) = t.kind.path_span()?;
let (start, end) = t.kind.path_span()?;
if arc.is_empty() {
return None;
}
let (p, dir) = arc.at(end * arc.len());
let (at, away) = match port {
Port::Out => (end, rise),
Port::In => (start, -rise),
};
let (p, dir) = arc.at(at * arc.len());
let (at, ahead) = (t.xf * p, t.xf * (p + dir) - t.xf * p);
let len = ahead.hypot();
Some(if len > 1e-9 && len.is_finite() { at + ahead * (cx.tol(PORT_RISE_PX) / len) } else { at })
Some(if len > 1e-9 && len.is_finite() { at + ahead * (away / len) } else { at })
}
TextKind::Point => None,
}
}
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit), at [`out_port`].
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
let Some(c) = out_port(t, cx) else { return };
let h = cx.tol(PORT_HALF_PX);
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
let red = [230, 30, 30];
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
let (k, m) = (cx.tol(2.5), r.center());
o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false });
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
/// What the in and out ports of frame `id` of `d` show, its text `overflow`ing or not: an arrow
/// where a frame of its thread comes before (in) or after (out) it, else a red "+" in the out port
/// of text that doesn't fit, else nothing.
pub fn port_states(d: &vectorcraft_doc::Document, id: NodeId, overflow: bool) -> (PortState, PortState) {
let thread = d.text_threads.iter().find(|t| t.contains(&id));
let at = thread.and_then(|t| t.iter().position(|x| *x == id));
let input = if at.is_some_and(|i| i > 0) { PortState::Threaded } else { PortState::Empty };
let output = match (thread, at) {
(Some(t), Some(i)) if i + 1 < t.len() => PortState::Threaded,
_ if overflow => PortState::Overflow,
_ => PortState::Empty,
};
(input, output)
}
/// The selected area type or type on a path whose red out port is under `p` (only text that
/// overflows shows one): a click there loads the Selection tool with its text, to thread into
/// another frame.
pub(crate) fn out_port_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
/// A port at `c` showing `state`, `px` document units to the screen pixel: a white box outlined in
/// `color` (the frame's layer colour; red for text that doesn't fit), with a red "+" or an arrow
/// pointing the way the story flows.
pub fn push_port(o: &mut Vec<Overlay>, c: Point, state: PortState, color: [u8; 3], px: f64) {
let h = PORT_HALF_PX * px;
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
let color = if state == PortState::Overflow { OVERFLOW_RED } else { color };
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
o.push(Overlay::Highlight { quad: corners, color: [255, 255, 255, 255] });
o.push(Overlay::Path { path: r.to_path(0.1), color, width: 1.0, dashed: false });
let k = 2.5 * px;
let line = |a: Point, b: Point| Overlay::Line { a, b, color, dashed: false };
match state {
PortState::Empty => {}
PortState::Overflow => {
o.push(line(Point::new(c.x - k, c.y), Point::new(c.x + k, c.y)));
o.push(line(Point::new(c.x, c.y - k), Point::new(c.x, c.y + k)));
}
PortState::Threaded => {
let tip = Point::new(c.x + k, c.y);
o.push(line(Point::new(c.x - k, c.y), tip));
o.push(line(Point::new(c.x, c.y - k), tip));
o.push(line(Point::new(c.x, c.y + k), tip));
}
}
}
/// The in and out ports of area type or type on a path `t`, frame `id`, its text `overflow`ing or
/// not ([`port_states`]).
fn ports(cx: &ToolContext, id: NodeId, t: &TextObject, overflow: bool, o: &mut Vec<Overlay>) {
let (input, output) = port_states(cx.doc, id, overflow);
let (color, px) = (cx.doc.layer_color(id), cx.tol(1.0));
for (port, state) in [(Port::In, input), (Port::Out, output)] {
if let Some(c) = port_centre(t, port, px) {
push_port(o, c, state, color, px);
}
}
}
/// The most selected area type and type on a path whose ports are drawn and take clicks: selecting
/// all of an opened PDF can select tens of thousands of type objects, whose ports would only bury
/// the canvas (and the GPU's buffers) in boxes.
pub const PORTS_MAX: usize = 1_000;
/// The text of `id` in `d` when it has ports: area type or type on a path a thread can hold, not in
/// perspective ([`crate::select::is_threadable`]). The one test for drawing ports, clicking them and
/// counting them toward [`PORTS_MAX`].
pub fn ported(d: &vectorcraft_doc::Document, id: NodeId) -> Option<&TextObject> {
match d.node(id).filter(|n| crate::select::is_threadable(n)).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
}
}
/// Are at most [`PORTS_MAX`] of `selected` [`ported`] (counting stops past it), so that their
/// ports show?
pub fn ports_shown(d: &vectorcraft_doc::Document, selected: &[NodeId]) -> bool {
selected.iter().filter(|id| ported(d, **id).is_some()).take(PORTS_MAX + 1).count() <= PORTS_MAX
}
/// The selected area type or type on a path with a port under `p`, and which port: a click on the
/// out port loads the Selection tool with its text to thread into another frame after it, a click
/// on the in port to thread into one before it. Every such frame has both ports, threaded,
/// overflowing or not, while their ports show ([`PORTS_MAX`]).
pub(crate) fn port_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Port)> {
if !ports_shown(cx.doc, &cx.selection.objects) {
return None;
}
let h = cx.tol(PORT_HALF_PX + 1.0);
let near = |t: &TextObject| out_port(t, cx).is_some_and(|c| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h);
cx.selection.objects.iter().copied().find(|id| {
cx.doc
.node(*id)
.filter(|n| crate::select::is_threadable(n))
.and_then(|_| TypeTool::text(cx, *id))
.is_some_and(|t| near(t) && vectorcraft_text::layout(FontDb::global(), t).overflow)
let near = |c: Point| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h;
cx.selection.objects.iter().find_map(|id| {
let t = ported(cx.doc, *id)?;
[Port::Out, Port::In].into_iter().find(|port| port_centre(t, *port, cx.tol(1.0)).is_some_and(near)).map(|port| (*id, port))
})
}
/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept
/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept
/// with the object and the fonts it was made from, as overlays run every frame.
#[derive(Default)]
pub struct OverflowMarks {
pub struct PortMarks {
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
}
/// The overflow markers of the selected area type and type on a path, for every tool but the Type
/// tools (they draw their own): an out port with a red "+" tells that text is hidden by the size of
/// its frame, whatever tool is active.
pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec<Overlay> {
/// The in and out ports of the selected area type and type on a path, for every tool but the Type
/// tools (they draw their own): each tells whether the frame is threaded on that side and the out
/// port whether text is hidden by the size of its frame, whatever tool is active. None when more
/// than [`PORTS_MAX`] of them are selected.
pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec<Overlay> {
let fonts = FontDb::global().generation();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
if !ports_shown(cx.doc, &cx.selection.objects) {
marks.seen = kept;
return o;
}
for id in &cx.selection.objects {
let Some(t) = TypeTool::text(cx, *id).filter(|t| !matches!(t.kind, TextKind::Point)) else { continue };
let Some(t) = ported(cx.doc, *id) else { continue };
let over = match marks.seen.remove(id) {
Some(((g, was), over)) if g == fonts && &was == t => over,
_ => vectorcraft_text::layout(FontDb::global(), t).overflow,
};
if over {
overflow_port(t, cx, &mut o);
}
ports(cx, *id, t, over, &mut o);
kept.insert(*id, ((fonts, t.clone()), over));
}
marks.seen = kept;
@@ -887,14 +978,15 @@ impl Tool for TypeTool {
o.push(Overlay::Measure { p: d, text: cx.size_label(size.width, size.height) });
}
}
// Overflow markers on selected text.
for id in &cx.selection.objects {
if Some(*id) == self.editing {
continue;
}
if let Some(t) = Self::text(cx, *id) {
let lay = self.layout(t);
Self::overflow_marker(t, &lay, cx, &mut o);
// The ports of selected area type and type on a path, as many as show ([`PORTS_MAX`]), the
// edited one's included.
let shown = ports_shown(cx.doc, &cx.selection.objects);
if shown {
for id in cx.selection.objects.iter().filter(|id| Some(**id) != self.editing) {
if let Some(t) = ported(cx.doc, *id) {
let lay = self.layout(t);
ports(cx, *id, t, lay.overflow, &mut o);
}
}
}
let Some(t) = self.current(cx) else { return o };
@@ -936,7 +1028,9 @@ impl Tool for TypeTool {
o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] });
}
}
Self::overflow_marker(&t, &lay, cx, &mut o);
if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) {
ports(cx, id, &t, lay.overflow, &mut o);
}
o
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
+123 -8
View File
@@ -746,20 +746,135 @@ fn a_drag_or_a_click_on_other_type_while_editing_acts_at_once() {
assert_eq!(tool.editing, Some(other));
}
/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not
/// only the Type tools (#2323); text that fits shows none.
/// #2572: selected area type shows its in and out ports with any tool, not only the Type tools
/// (#2323): empty while its text fits and it isn't threaded, the out port's red "+" when the text
/// doesn't fit, an arrow in the port on the side where it is threaded.
#[test]
fn overflowing_selected_type_shows_its_marker_without_the_type_tool() {
fn selected_type_shows_its_ports_in_each_state() {
let (mut d, id) = crate::testutil::doc_with_area_type();
let (mut sel, p) = (Selection::default(), paint());
sel.set([id]);
let mut marks = OverflowMarks::default();
assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "the text fits");
let mut marks = PortMarks::default();
// (white boxes, outlines, glyph strokes, red outlines)
let count = |ov: &[Overlay]| {
let n = |f: &dyn Fn(&Overlay) -> bool| ov.iter().filter(|o| f(o)).count();
(
n(&|o| matches!(o, Overlay::Highlight { color: [255, 255, 255, 255], .. })),
n(&|o| matches!(o, Overlay::Path { .. })),
n(&|o| matches!(o, Overlay::Line { .. })),
n(&|o| matches!(o, Overlay::Path { color: [230, 30, 30], .. })),
)
};
assert_eq!(count(&thread_ports(&mut marks, &cx(&d, &sel, &p))), (2, 2, 0, 0), "the text fits: two empty ports");
let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") };
let (input, output) = (t.xf * Point::new(0.0, PORT_RISE_PX), t.xf * Point::new(120.0, 40.0 - PORT_RISE_PX));
let near = |a: Option<Point>, b: Point| a.is_some_and(|a| a.distance(b) < 1e-9);
assert!(near(port_centre(t, Port::In, 1.0), input), "on the left side, below the top-left corner");
assert!(near(port_centre(t, Port::Out, 1.0), output), "on the right side, above the bottom-right corner");
let ov = thread_ports(&mut marks, &cx(&d, &sel, &p));
let box_at = |c: Point| ov.iter().any(|o| matches!(o, Overlay::Highlight { quad, .. } if quad[0].midpoint(quad[2]).distance(c) < 1e-9));
assert!(box_at(input) && box_at(output), "{ov:?}");
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs = vec![TextRun::new("Words that fill more than the frame holds. ".repeat(20), CharStyle { size: 20.0, ..Default::default() })];
}
let ov = overflow_markers(&mut marks, &cx(&d, &sel, &p));
assert_eq!(ov.len(), 3, "a box and a plus: {ov:?}");
assert_eq!(count(&thread_ports(&mut marks, &cx(&d, &sel, &p))), (2, 2, 2, 1), "an empty in port and the red plus");
assert_eq!(port_states(&d, id, true), (PortState::Empty, PortState::Overflow));
// Threaded on both sides: arrows (three strokes each), the out port's in place of the plus.
let (a, b) = (d.alloc_id(), d.alloc_id());
d.text_threads = vec![vec![a, id, b]];
assert_eq!(port_states(&d, id, true), (PortState::Threaded, PortState::Threaded));
assert_eq!(count(&thread_ports(&mut marks, &cx(&d, &sel, &p))), (2, 2, 6, 0));
d.text_threads = vec![vec![id, a]];
assert_eq!(port_states(&d, id, false), (PortState::Empty, PortState::Threaded));
d.text_threads = vec![vec![a, id]];
assert_eq!(port_states(&d, id, false), (PortState::Threaded, PortState::Empty));
assert_eq!(port_states(&d, id, true), (PortState::Threaded, PortState::Overflow), "the last frame's text doesn't fit");
sel.clear();
assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows it");
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows them");
}
/// Past [`PORTS_MAX`] selected frames (select all of an opened PDF) no ports are drawn or clicked:
/// they would bury the canvas in boxes; up to it every frame has its two.
#[test]
fn a_huge_selection_of_type_shows_no_ports() {
let (mut d, id) = crate::testutil::doc_with_area_type();
let node = d.node(id).unwrap().clone();
let l = d.layers[0].id;
let mut sel = Selection::default();
sel.set([id]);
for _ in 0..PORTS_MAX {
let copy = d.alloc_id();
d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap();
sel.add(copy);
}
let p = paint();
let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") };
let out = port_centre(t, Port::Out, 1.0).unwrap();
let mut marks = PortMarks::default();
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty());
assert_eq!(port_at(&cx(&d, &sel, &p), out), None);
sel.remove(id);
let ports = thread_ports(&mut marks, &cx(&d, &sel, &p));
assert_eq!(ports.iter().filter(|o| matches!(o, Overlay::Highlight { .. })).count(), 2 * PORTS_MAX, "at the limit");
}
/// Type in perspective has no ports, as it can't thread ([`crate::select::is_threadable`]): none
/// are drawn with any tool, the Type tools included, and it doesn't count toward [`PORTS_MAX`].
#[test]
fn type_in_perspective_has_no_ports_and_doesnt_count() {
let (mut d, id) = crate::testutil::doc_with_area_type();
d.node_mut(id).unwrap().extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)));
let (mut sel, p) = (Selection::default(), paint());
sel.set([id]);
let mut marks = PortMarks::default();
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "no ports on type in perspective");
let boxes = |o: &[Overlay]| o.iter().filter(|o| matches!(o, Overlay::Highlight { color: [255, 255, 255, 255], .. })).count();
assert_eq!(boxes(&TypeTool::new("type").overlays(&cx(&d, &sel, &p))), 0, "nor with the Type tool");
// With PORTS_MAX frames that have ports selected besides it, theirs still show.
let mut node = d.node(id).unwrap().clone();
node.extras_mut().perspective = None;
let l = d.layers[0].id;
for _ in 0..PORTS_MAX {
let copy = d.alloc_id();
d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap();
sel.add(copy);
}
assert_eq!(boxes(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 2 * PORTS_MAX);
}
/// The Type tool draws the ports of the type it edits as of the rest of the selection: none past
/// [`PORTS_MAX`] selected frames, where they can't be clicked either.
#[test]
fn the_edited_types_ports_follow_the_limit_too() {
let (mut d, id) = crate::testutil::doc_with_area_type();
let node = d.node(id).unwrap().clone();
let l = d.layers[0].id;
let mut sel = Selection::default();
sel.set([id]);
for _ in 1..PORTS_MAX {
let copy = d.alloc_id();
d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap();
sel.add(copy);
}
let p = paint();
let mut tool = TypeTool::new("type");
tool.start_editing(id, 0);
let boxes = |o: &[Overlay]| o.iter().filter(|o| matches!(o, Overlay::Highlight { color: [255, 255, 255, 255], .. })).count();
assert_eq!(boxes(&tool.overlays(&cx(&d, &sel, &p))), 2 * PORTS_MAX, "at the limit, the edited one's included");
let copy = d.alloc_id();
d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap();
sel.add(copy);
assert_eq!(boxes(&tool.overlays(&cx(&d, &sel, &p))), 0, "past it, not even the edited one's");
}
/// Type on a path has its in port just before its start bracket and its out port just past its
/// end bracket, along the path, clear of the brackets.
#[test]
fn type_on_a_path_has_its_ports_beyond_its_brackets() {
let (d, id) = crate::testutil::doc_with_path_type();
let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") };
let near = |a: Option<Point>, b: Point| a.is_some_and(|a| a.distance(b) < 1e-6);
assert!(near(port_centre(t, Port::In, 1.0), Point::new(160.0 - PORT_RISE_PX, 300.0)), "{:?}", port_centre(t, Port::In, 1.0));
assert!(near(port_centre(t, Port::Out, 1.0), Point::new(400.0 + PORT_RISE_PX, 300.0)), "{:?}", port_centre(t, Port::Out, 1.0));
assert!(near(port_centre(t, Port::In, 0.5), Point::new(160.0 - PORT_RISE_PX / 2.0, 300.0)), "screen pixels at 200%");
}
+123 -16
View File
@@ -652,7 +652,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
mirror_axis_overlay(app, &painter, &xf);
if app.ui.view.text_threads {
thread_overlay(app, &painter, &xf);
// Under the tools' overlays: a selected frame's own ports are drawn over its thread's.
let mut threads = thread_overlays(app, xf.zoom);
threads.append(&mut overlays);
overlays = threads;
}
if app.ui.view.live_paint_gaps {
overlays.extend(live_paint_gap_overlays(app));
@@ -2577,28 +2580,36 @@ impl HiddenMarks {
}
}
/// Text threads of the selected frames: a line from each frame's out port (on its right side,
/// just above the bottom right, where the tools draw the red one) to the next frame's in port (top
/// left), like Illustrator's thread indicators.
fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
/// View › Show Text Threads: for each thread with a selected frame, a line in its first frame's
/// layer colour from each frame's out port to the next frame's in port, both showing the arrow of a
/// threaded port, where the tools draw the ports of selected type ([`vectorcraft_tools::text`]).
/// Only frames with ports ([`vectorcraft_tools::text::ported`]) are joined: a link to or from a
/// frame in perspective, which has none, gets no line. With more than
/// [`vectorcraft_tools::text::PORTS_MAX`] such frames selected the tools draw no ports, and only
/// the lines are drawn here. `zoom`: screen pixels to the document unit.
fn thread_overlays(app: &VectorcraftApp, zoom: f64) -> Vec<Overlay> {
use vectorcraft_tools::text::{Port, PortState, port_centre, ported, ports_shown, push_port};
let Some(st) = app.session.active() else { return vec![] };
let px = 1.0 / zoom.max(1e-9);
let boxes = ports_shown(&st.doc, &st.selection.objects);
let mut o = vec![];
for thread in &st.doc.text_threads {
if !thread.iter().any(|id| st.selection.objects.contains(id)) {
continue;
}
let color = c32(st.doc.layer_color(thread[0]));
let frames: Vec<vectorcraft_geom::Rect> = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect();
for w in frames.windows(2) {
let rise = (vectorcraft_tools::text::PORT_RISE_PX / xf.zoom.max(1e-9)).min(w[0].height() / 2.0);
let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1 - rise)), xf.to_screen(Point::new(w[1].x0, w[1].y0)));
p.line_segment([out, inp], Stroke::new(1.0, color));
for port in [out, inp] {
p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE);
p.rect_stroke(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, color), egui::StrokeKind::Inside);
p.line_segment([port - egui::vec2(2.0, 0.0), port + egui::vec2(2.0, 0.0)], Stroke::new(1.0, color));
let color = st.doc.layer_color(thread[0]);
for w in thread.windows(2) {
let [a, b] = [w[0], w[1]];
let (Some(from), Some(to)) = (ported(&st.doc, a), ported(&st.doc, b)) else { continue };
let (Some(out), Some(input)) = (port_centre(from, Port::Out, px), port_centre(to, Port::In, px)) else { continue };
o.push(Overlay::Line { a: out, b: input, color, dashed: false });
if boxes {
push_port(&mut o, out, PortState::Threaded, st.doc.layer_color(a), px);
push_port(&mut o, input, PortState::Threaded, st.doc.layer_color(b), px);
}
}
}
o
}
/// View → Show Live Paint Gaps: the gaps of the selected Live Paint groups in each group's Gap
@@ -9991,4 +10002,100 @@ mod tests {
.collect();
assert!(!baselines.is_empty(), "selected text shows baseline alongside bounding box");
}
/// #2572: Show Text Threads links a thread's frames from the out port of one to the in port of
/// the next, where the tools draw those ports, each with the arrow of a threaded port; nothing
/// is drawn while no frame of the thread is selected.
#[test]
fn text_threads_run_from_each_out_port_to_the_next_in_port() {
use vectorcraft_tools::text::{Port, port_centre};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
let text = "Words that fill more than the frame holds. ".repeat(10);
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone();
let b = app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap()["id"].clone();
let frame = |app: &VectorcraftApp, id: &serde_json::Value| match &app
.session
.active()
.unwrap()
.doc
.node(vectorcraft_doc::NodeId(id.as_u64().unwrap()))
.unwrap()
.kind
{
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
};
let zoom = 2.0;
let (out, input) = (port_centre(&frame(&app, &a), Port::Out, 0.5).unwrap(), port_centre(&frame(&app, &b), Port::In, 0.5).unwrap());
let o = thread_overlays(&app, zoom);
assert!(o.iter().any(|x| matches!(x, Overlay::Line { a, b, .. } if *a == out && *b == input)), "the thread line: {o:?}");
let boxes: Vec<Point> =
o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect();
assert_eq!(boxes.len(), 2);
assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}");
// Two arrows: three strokes each, beside the thread line.
assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7);
app.run("select.none", json!({})).unwrap();
assert!(thread_overlays(&app, zoom).is_empty());
}
/// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text
/// Threads draws only its lines, no port boxes.
#[test]
fn text_threads_of_a_huge_selection_have_lines_but_no_ports() {
use vectorcraft_tools::text::PORTS_MAX;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
let text = "Words that fill more than the frame holds. ".repeat(10);
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone();
app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap();
app.run("text.create", json!({"x": 20, "y": 300, "text": "x", "area": {"width": 50, "height": 20}})).unwrap();
while app.session.active().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) <= PORTS_MAX {
app.run("select.all", json!({})).unwrap();
app.run("edit.duplicate", json!({})).unwrap();
}
app.run("select.all", json!({})).unwrap();
let o = thread_overlays(&app, 1.0);
assert!(o.iter().any(|x| matches!(x, Overlay::Line { .. })), "the thread lines");
assert!(!o.iter().any(|x| matches!(x, Overlay::Highlight { .. } | Overlay::Path { .. })), "no port boxes");
}
/// A frame of a thread in perspective has no ports: Show Text Threads draws no port box on it
/// and no line to or from it; the links between frames with ports keep theirs.
#[test]
fn text_threads_skip_the_links_of_a_frame_in_perspective() {
use vectorcraft_tools::text::{Port, port_centre};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
let text = "Words that fill more than the frame holds. ".repeat(20);
let id = |v: serde_json::Value| vectorcraft_doc::NodeId(v["id"].as_u64().unwrap());
let a = id(app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap());
let b = id(app.run("text.thread.add", json!({"from": a.0, "at": [200, 20]})).unwrap());
let c = id(app.run("text.thread.add", json!({"from": b.0, "at": [20, 200]})).unwrap());
let d = id(app.run("text.thread.add", json!({"from": c.0, "at": [200, 200]})).unwrap());
app.session
.edit("Perspective", |doc, _| {
if let Some(n) = doc.node_mut(b) {
n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)));
}
Ok(())
})
.unwrap();
app.run("select.set", json!({"ids": [a.0, b.0, c.0, d.0]})).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc.text_threads, [vec![a, b, c, d]], "b is still threaded");
let frame = |id| match &st.doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
};
let (out, input) = (port_centre(&frame(c), Port::Out, 1.0).unwrap(), port_centre(&frame(d), Port::In, 1.0).unwrap());
let o = thread_overlays(&app, 1.0);
let boxes: Vec<Point> =
o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect();
assert_eq!(boxes.len(), 2, "only c's out port and d's in port: {boxes:?}");
assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}");
// One thread line (c to d) and its two arrows of three strokes each.
assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7);
}
}
+7 -1
View File
File diff suppressed because one or more lines are too long
+22 -15
View File
@@ -4072,23 +4072,30 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print
{"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}}
```
## Threading text from the out port
## Threading text from the in and out ports
As in the reference app, a click with the Selection tool on the red "+" out port of selected area type whose text
overflows loads the pointer with that text (the port sits on the frame's right side, just above the bottom-right
corner, so the corner handle stays free). The next click threads the text into the closed path or area type under the
pointer, which becomes the next frame; a click on empty canvas makes a new frame of the first one's shape and size
with its top-left corner at the click, and a drag draws a rectangle for the new frame. Escape or another tool drops
the loaded text. Each placement is one undo step (`text.thread.add`), which agents can run directly:
As in the reference app, selected area type and type on a path show an in port and an out port: area type's on the
frame's left side just below the top-left corner and on its right side just above the bottom-right corner (so the
corner handles stay free), type on a path's just before its start bracket and just past its end bracket. A port is
empty while the frame isn't threaded on that side, shows an arrow when it is, and the out port shows a red "+" when
the text doesn't fit (with more than 1,000 such objects selected no ports are drawn). A click with the Selection tool
on the out port loads the pointer with the text. The next click
threads it into the closed path, area type or type on a path under the pointer, which becomes the next frame; a click
on empty canvas makes a new frame of the first one's kind, shape and size with its top-left corner at the click, and a
drag draws a rectangle for the new frame. A click on the in port works the same way, but the frame placed comes
before the clicked one: the story starts there and flows on into it. Escape or another tool drops the loaded text.
Each placement is one undo step (`text.thread.add`), which agents can run directly:
`text.thread.add {from, to? | rect?: [x, y, width, height] | at?: [x, y]}` threads area type `from` into one more
frame, put right after it in its thread (a new thread of the two when `from` has none): `to`, a closed path (converted
to an empty frame) or area type that is not threaded yet; `rect`, a new rectangular frame (positive width and height);
or `at`, a new frame of `from`'s shape and size whose top-left is at `at`. Give exactly one of the three. New frames
take `from`'s type options, paint and first character and paragraph attributes, and go just above it. The story
reflows through the thread, the two frames are selected, and it returns `{id}`, the frame threaded into. It refuses
an `id` that doesn't exist, a `from` that isn't area type (type on a path doesn't thread), an open path, `from`
itself, an object inside a compound path, a frame already in a thread, and coordinates that aren't finite numbers.
`text.thread.add {from, to? | rect?: [x, y, width, height] | at?: [x, y], before?}` threads area type or type on a
path `from` into one more frame, put right after it in its thread (a new thread of the two when `from` has none), or
right before it with `before: true` (the in port): `to`, a closed path (converted to an empty frame), area type or
type on a path that is not threaded yet; `rect`, a new rectangular frame (positive width and height); or `at`, a new
frame of `from`'s kind, shape and size whose top-left is at `at`. Give exactly one of the three. New frames take
`from`'s type options, paint and first character and paragraph attributes, and go just above it. The story reflows
through the thread, the two frames are selected, and it returns `{id}`, the frame threaded into. It refuses an `id`
that doesn't exist, a `from` that is point type, a `to` that is an open path, `from` itself, inside a compound path
or already in a thread (`from` may be threaded already: the frame goes in beside it), coordinates that aren't finite
numbers and a `before` that isn't true or false.
```json
{"name":"run_command","arguments":{"command":"text.thread.add","params":{"from":42,"at":[300,120]}}}
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long