mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+22
-15
@@ -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
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user