mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
Merge branch 'pr/647' into batch1
# Conflicts: # crates/tools/src/lib.rs # crates/tools/src/tests.rs # crates/ui-egui/src/canvas.rs
This commit is contained in:
commit
a390905beb
14 files changed
+396
-12
No files matched your search
@@ -136,6 +136,8 @@ Newest first.
|
||||
- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change.
|
||||
- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change.
|
||||
- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change.
|
||||
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
|
||||
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
|
||||
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
|
||||
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
|
||||
|
||||
@@ -417,7 +417,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
path: None,
|
||||
..tf.options.clone()
|
||||
};
|
||||
(Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts)
|
||||
let end = pt.end.map_or(len, |e| e.max(0.0).min(len));
|
||||
(Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts)
|
||||
}
|
||||
None => (item.text_area(), tf.options.clone()),
|
||||
};
|
||||
@@ -3179,6 +3180,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Type on a path: between the composed line (x along it, y as composed) and the frame's path
|
||||
/// space, as `path_glyphs` places the glyphs.
|
||||
pub struct PathTextMap {
|
||||
warp: designcraft_geom::warp::PathWarp,
|
||||
start: f64,
|
||||
/// Line y that sits on the path (the alignment), and the line's baseline.
|
||||
on: f64,
|
||||
baseline: f64,
|
||||
}
|
||||
|
||||
impl PathTextMap {
|
||||
/// The map of `item` (type on a path) for its composed frame `ft`; None for other frames.
|
||||
pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option<Self> {
|
||||
let pt = item.text_frame()?.options.path.as_ref()?;
|
||||
let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip);
|
||||
let (on, baseline) = match ft.lines.first() {
|
||||
Some(l) => (
|
||||
match pt.align {
|
||||
designcraft_doc::PathAlign::Baseline => l.baseline,
|
||||
designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent,
|
||||
designcraft_doc::PathAlign::Descender => l.baseline + l.descent,
|
||||
designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0,
|
||||
},
|
||||
l.baseline,
|
||||
),
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
Some(PathTextMap { warp, start: pt.start, on, baseline })
|
||||
}
|
||||
|
||||
/// A point of the composed line, on the path: along it by x, across it by y.
|
||||
pub fn to_path(&self, p: Point) -> Point {
|
||||
match self.warp.at(self.start + p.x) {
|
||||
Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of the path space, on the composed line: how far along the path its nearest point
|
||||
/// is, at the baseline (for hit testing).
|
||||
pub fn to_line(&self, p: Point) -> Point {
|
||||
Point::new(self.warp.nearest(p) - self.start, self.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space
|
||||
/// (grouped by run style).
|
||||
pub fn path_glyphs(
|
||||
|
||||
@@ -416,5 +416,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
|
||||
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
|
||||
return true;
|
||||
}
|
||||
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
|
||||
// Type on a path: the letters stand over the path, so a click on them hits it too.
|
||||
let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 };
|
||||
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner)
|
||||
}
|
||||
|
||||
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
|
||||
/// either side (the type may sit on or under the path).
|
||||
const PATH_TYPE_REACH: f64 = 12.0;
|
||||
@@ -345,8 +345,12 @@ pub struct TextFrameOptions {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct PathType {
|
||||
/// Where the text starts, as a distance along the path.
|
||||
/// Where the text starts, as a distance along the path (the start bracket).
|
||||
pub start: f64,
|
||||
/// Where the text ends, as a distance along the path (the end bracket); `None` is the path's
|
||||
/// end. Text past it is overset.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<f64>,
|
||||
/// Run the other way (the text sits on the other side).
|
||||
pub flip: bool,
|
||||
pub align: PathAlign,
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}",
|
||||
"{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path",
|
||||
has_doc,
|
||||
path_options
|
||||
),
|
||||
@@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = p.clone();
|
||||
s.edit(|d, sel| {
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length();
|
||||
let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) };
|
||||
let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) };
|
||||
if delete {
|
||||
@@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.text = None;
|
||||
return Ok(json!({"deleted": true}));
|
||||
}
|
||||
if let Some(v) = p.get("start").and_then(Value::as_f64) {
|
||||
pt.start = v.max(0.0);
|
||||
if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||||
pt.start = v.max(0.0).min(len);
|
||||
}
|
||||
match p.get("end") {
|
||||
Some(Value::Null) => pt.end = None,
|
||||
Some(v) => {
|
||||
let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?;
|
||||
pt.end = (e < len - 0.01).then_some(e.max(0.0));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// The brackets can't cross: the one being moved stops at the other.
|
||||
if let Some(e) = pt.end
|
||||
&& pt.start > e
|
||||
{
|
||||
if p.get("end").is_some() {
|
||||
pt.end = Some(pt.start);
|
||||
} else {
|
||||
pt.start = e;
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("flip").and_then(Value::as_bool) {
|
||||
pt.flip = v;
|
||||
@@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(a) = align {
|
||||
pt.align = a;
|
||||
}
|
||||
Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align}))
|
||||
Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,4 +179,93 @@ mod tests {
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap();
|
||||
assert!(pdf.len() > 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_brackets_set_where_the_text_starts_and_ends() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(!overset(&s));
|
||||
// The end bracket close to the start: the text no longer fits, and is overset.
|
||||
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap();
|
||||
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0)));
|
||||
assert!(overset(&s));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// Brackets can't cross: the start stops at the end, the end at the start.
|
||||
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap();
|
||||
assert_eq!(r["start"].as_f64(), Some(70.0));
|
||||
let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap();
|
||||
assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0)));
|
||||
// Past the path, or null: the path's end again; the start stays on the path.
|
||||
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap();
|
||||
assert!(r["end"].is_null());
|
||||
let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap();
|
||||
assert_eq!(r["end"].as_f64(), Some(200.0));
|
||||
let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap();
|
||||
assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true));
|
||||
assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err());
|
||||
// Saved with the document.
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap();
|
||||
let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone();
|
||||
assert_eq!(path(&back), path(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_tool_clicks_and_selects_along_a_curved_path() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// An arch: up from (100, 400) over (300, 200) and down to (500, 400).
|
||||
let id = s
|
||||
.execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
// Where each glyph sits on the path, from the composed line.
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
|
||||
let ft = &cs.frames[0];
|
||||
let map = designcraft_compose::PathTextMap::new(it, ft).unwrap();
|
||||
let l = &ft.lines[0];
|
||||
let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
let on_path = |byte: usize| {
|
||||
let g = glyph(byte);
|
||||
// A little outside the path, as a click near the letter would be.
|
||||
map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0))
|
||||
};
|
||||
// A click by the first C (byte 10), on the curve: the caret goes there.
|
||||
let p = on_path(10);
|
||||
let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap();
|
||||
assert_eq!(r["pos"], 10, "flip={flip}: {r}");
|
||||
// Dragging to the first E (byte 20) selects C to D.
|
||||
let q = on_path(20);
|
||||
s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!(t.range(), 10..20, "flip={flip}");
|
||||
// A double-click on a word (its first click places the caret) selects it.
|
||||
let w = on_path(16);
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
|
||||
s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}");
|
||||
// A click on a letter, above the path, hits the frame (the type tool's hit test).
|
||||
let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7));
|
||||
let sp = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(
|
||||
sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0),
|
||||
Some(designcraft_doc::ItemId(id)),
|
||||
"flip={flip}"
|
||||
);
|
||||
// The map runs both ways along the path.
|
||||
let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline)));
|
||||
assert!((back.x - 123.0).abs() < 0.01, "{back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,8 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
|
||||
let inner = xf.inverse() * pt;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||||
// Type on a path: the click lands on the line where it is along the path.
|
||||
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
|
||||
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
|
||||
return Some((sid, b, Some(CellAddr { table, row, col })));
|
||||
}
|
||||
|
||||
@@ -47,6 +47,22 @@ impl PathWarp {
|
||||
None
|
||||
}
|
||||
|
||||
/// The arc length (as `at` measures it, so flipped paths count from their end) of the point
|
||||
/// of the path nearest `p`.
|
||||
pub fn nearest(&self, p: Point) -> f64 {
|
||||
use kurbo::ParamCurveNearest;
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
let mut before = 0.0;
|
||||
for (seg, len) in &self.segs {
|
||||
let n = seg.nearest(p, 1e-6);
|
||||
if n.distance_sq < best.0 {
|
||||
best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4));
|
||||
}
|
||||
before += len;
|
||||
}
|
||||
if self.flip { self.total - best.1 } else { best.1 }
|
||||
}
|
||||
|
||||
/// Transform for something laid out on a straight line (x along the line, y down from the
|
||||
/// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with
|
||||
/// the path, its line at `baseline` on the path.
|
||||
@@ -76,5 +92,10 @@ mod tests {
|
||||
assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9);
|
||||
let flipped = PathWarp::new(&bp, true);
|
||||
assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9);
|
||||
// The nearest point by arc length, off the path and on it; flipped counts from the end.
|
||||
assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6);
|
||||
assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end");
|
||||
assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6);
|
||||
assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -1636,7 +1636,7 @@ impl<'a> Ex<'a> {
|
||||
.attr("TextAlignment", names::path_align_out(pt.align))
|
||||
.attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" })
|
||||
.attr("StartBracket", num(pt.start))
|
||||
.attr("EndBracket", num(len)),
|
||||
.attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))),
|
||||
);
|
||||
} else if let Content::Text(t) = &it.content {
|
||||
let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None));
|
||||
|
||||
@@ -2200,6 +2200,11 @@ impl<'r> Importer<'r> {
|
||||
let options = TextFrameOptions {
|
||||
path: Some(designcraft_doc::PathType {
|
||||
start: tp.num("StartBracket").unwrap_or(0.0).max(0.0),
|
||||
// At (or past) the path's length it is the path's end: no end bracket.
|
||||
end: tp
|
||||
.num("EndBracket")
|
||||
.filter(|e| e.is_finite() && *e >= 0.0)
|
||||
.filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01),
|
||||
flip: tp.get("FlipPathEffect") == Some("Flipped"),
|
||||
align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")),
|
||||
}),
|
||||
|
||||
@@ -1455,3 +1455,27 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() {
|
||||
check(&d);
|
||||
check(&import_idml(&export_idml(&d)).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_on_a_path_keeps_its_start_and_end_brackets() {
|
||||
use designcraft_geom::{Anchor, PathData, Point, SubPath};
|
||||
for end in [Some(250.0), None] {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let id = designcraft_doc::ItemId(d.alloc());
|
||||
let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false));
|
||||
let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line);
|
||||
let sid = designcraft_doc::StoryId(d.alloc());
|
||||
let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() };
|
||||
tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() });
|
||||
it.content = designcraft_doc::Content::Text(tf);
|
||||
let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default());
|
||||
story.frames = vec![id];
|
||||
d.stories.insert(sid, std::sync::Arc::new(story));
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap();
|
||||
assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}");
|
||||
assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket");
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,8 @@ pub enum Cursor {
|
||||
Pen,
|
||||
/// The Pen over an open path's end point: a click continues the path.
|
||||
PenContinue,
|
||||
/// Over a type-on-a-path bracket: dragging it moves where the text starts or ends.
|
||||
PathBracket,
|
||||
Text,
|
||||
Hand,
|
||||
HandGrab,
|
||||
|
||||
@@ -38,6 +38,11 @@ enum Drag {
|
||||
start: Point,
|
||||
cur: Point,
|
||||
},
|
||||
/// Dragging a type-on-a-path bracket along its path (`end`: the end bracket).
|
||||
Bracket {
|
||||
id: ItemId,
|
||||
end: bool,
|
||||
},
|
||||
Anchor {
|
||||
id: u64,
|
||||
si: usize,
|
||||
@@ -51,6 +56,50 @@ enum Drag {
|
||||
},
|
||||
}
|
||||
|
||||
/// Half the length of a path-type bracket, in screen pixels.
|
||||
const BRACKET_PX: f64 = 9.0;
|
||||
|
||||
/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its
|
||||
/// brackets' arc lengths (start, end).
|
||||
fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> {
|
||||
let it = cx.doc.item(id)?;
|
||||
let pt = it.text_frame()?.options.path.as_ref()?;
|
||||
let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip);
|
||||
let end = pt.end.unwrap_or(warp.length()).min(warp.length());
|
||||
Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end))
|
||||
}
|
||||
|
||||
/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas).
|
||||
fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> {
|
||||
let (p, a) = warp.at(s)?;
|
||||
let c = xf * p;
|
||||
let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c;
|
||||
let n = designcraft_geom::Vec2::new(-t.y, t.x);
|
||||
let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX);
|
||||
Some((c - n, c + n))
|
||||
}
|
||||
|
||||
/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end
|
||||
/// bracket).
|
||||
fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> {
|
||||
let tol = cx.tol(BRACKET_PX);
|
||||
cx.selection.items.iter().find_map(|id| {
|
||||
let (warp, xf, start, end) = path_type_of(cx, *id)?;
|
||||
if cx.doc.item(*id).is_some_and(|it| it.locked) {
|
||||
return None;
|
||||
}
|
||||
let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol);
|
||||
// The end bracket first: with both brackets at one point, it can still be pulled back.
|
||||
if near(end) {
|
||||
Some((*id, true))
|
||||
} else if near(start) {
|
||||
Some((*id, false))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle).
|
||||
pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> {
|
||||
anchor_at_in(cx, &cx.selection.items, p)
|
||||
@@ -378,6 +427,13 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.shift_release = None;
|
||||
// Type on a path: a bracket moves where the text starts or ends.
|
||||
if !ev.mods.shift
|
||||
&& let Some((id, end)) = bracket_at(cx, p)
|
||||
{
|
||||
self.drag = Drag::Bracket { id, end };
|
||||
return vec![Action::Begin("Move Bracket".into())];
|
||||
}
|
||||
if self.direct
|
||||
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
|
||||
{
|
||||
@@ -437,6 +493,14 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
PointerKind::Drag => match self.drag.clone() {
|
||||
Drag::Bracket { id, end } => {
|
||||
let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] };
|
||||
if xf.determinant().abs() < 1e-12 {
|
||||
return vec![];
|
||||
}
|
||||
let s = warp.nearest(xf.inverse() * p);
|
||||
vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))]
|
||||
}
|
||||
Drag::Pending { start, hit } => {
|
||||
if (p - start).hypot() < cx.tol(3.0) {
|
||||
return vec![];
|
||||
@@ -682,7 +746,9 @@ impl Tool for SelectionTool {
|
||||
let release = self.shift_release.take();
|
||||
let d = std::mem::replace(&mut self.drag, Drag::None);
|
||||
match d {
|
||||
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit],
|
||||
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => {
|
||||
vec![Action::Commit]
|
||||
}
|
||||
Drag::Pending { .. } => match release {
|
||||
Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
|
||||
None => vec![],
|
||||
@@ -739,15 +805,19 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
match &self.drag {
|
||||
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
|
||||
Drag::None | Drag::Bracket { .. } => brackets(cx),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
if self.direct {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
@@ -769,6 +839,27 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a
|
||||
/// line across the path with a short tick pointing at the text between them.
|
||||
fn brackets(cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = Vec::new();
|
||||
for id in &cx.selection.items {
|
||||
let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue };
|
||||
let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color);
|
||||
for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] {
|
||||
let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue };
|
||||
out.push(Overlay::Line { a, b, color, dashed: false });
|
||||
// The tick at the outer end points along the path towards the text.
|
||||
if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) {
|
||||
let dir = xf * p1 - xf * p0;
|
||||
let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6);
|
||||
out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn handle_cursor(h: usize) -> Cursor {
|
||||
match h {
|
||||
0 | 4 => Cursor::ResizeNwSe,
|
||||
|
||||
@@ -764,3 +764,51 @@ fn pen_continues_an_open_path_from_either_end_point() {
|
||||
let t = create("pen");
|
||||
assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_tool_drags_a_path_type_bracket_along_its_path() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let id = ItemId(d.alloc());
|
||||
let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new(
|
||||
vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))],
|
||||
false,
|
||||
));
|
||||
let mut it = Item::new(id, lid, Shape::GraphicLine, line);
|
||||
let sid = designcraft_doc::StoryId(d.alloc());
|
||||
let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() };
|
||||
tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() });
|
||||
it.content = designcraft_doc::Content::Text(tf);
|
||||
let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default());
|
||||
st.frames = vec![id];
|
||||
d.stories.insert(sid, Arc::new(st));
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let off = l.xf(SpreadRef::Doc(0)).translation();
|
||||
let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y);
|
||||
let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y);
|
||||
// Not selected: no brackets.
|
||||
let none = Selection::default();
|
||||
assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty());
|
||||
let s = Selection { items: vec![id], ..Default::default() };
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let mut t = create("selection");
|
||||
// Both brackets drawn across the path at 140 and 400 (a line and a tick each).
|
||||
let o = t.overlays(&cx);
|
||||
let xs: Vec<f64> = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect();
|
||||
assert_eq!(xs, [140.0, 400.0], "{o:?}");
|
||||
assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket);
|
||||
assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket);
|
||||
// Drag the start bracket: its distance along the path follows the pointer, off the path too.
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0)));
|
||||
let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") };
|
||||
assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0)));
|
||||
assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}");
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]);
|
||||
// The end bracket.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0)));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0)));
|
||||
let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") };
|
||||
assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}");
|
||||
}
|
||||
@@ -353,6 +353,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate => C::Alias,
|
||||
Cursor::Pen | Cursor::PenContinue => C::Crosshair,
|
||||
Cursor::PathBracket => C::ResizeColumn,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
@@ -978,6 +979,28 @@ fn draw_text_selection(
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Type on a path: the highlight bends with the text.
|
||||
if let Some(map) = compose::PathTextMap::new(it, ft) {
|
||||
for l in &ft.lines {
|
||||
let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end));
|
||||
if s >= e {
|
||||
continue;
|
||||
}
|
||||
for q in compose::highlight_quads(l, s, e, false) {
|
||||
let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x));
|
||||
let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y));
|
||||
// Short slices, each turned with the path.
|
||||
let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize;
|
||||
for k in 0..n {
|
||||
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
|
||||
let poly =
|
||||
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut quads = Vec::new();
|
||||
let mut glyphs = Vec::new();
|
||||
for (li, l) in ft.lines.iter().enumerate() {
|
||||
@@ -1009,8 +1032,10 @@ fn draw_text_selection(
|
||||
let m = a * doc.text_xf(it);
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
let p0 = xf.to_screen(m * Point::new(x, bl - asc));
|
||||
let p1 = xf.to_screen(m * Point::new(x, bl + desc));
|
||||
// Type on a path: the caret stands across the path where it is along it.
|
||||
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
|
||||
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
|
||||
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
|
||||
Reference in new issue
Block a user