# Conflicts:
#	crates/engine/src/cmd/anchors.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/ar.rs
#	crates/ui-egui/src/i18n/it.rs
#	crates/ui-egui/src/i18n/ja.rs
#	crates/ui-egui/src/i18n/pt_br.rs
#	crates/ui-egui/src/i18n/uk.rs
#	crates/ui-egui/src/icons.rs
#	docs/target-app-parity.md
This commit is contained in:
Brandon Thomas
2026-10-14 23:17:31 -04:00
11 changed files with 438 additions and 19 deletions
+1
View File
@@ -145,6 +145,7 @@ Newest first.
- **2026-10-15** · A story that ends with a return is no longer overset when only its empty last paragraph doesn't fit, as in InDesign (Preflight said "Overset text: 0 characters" for a caption InDesign shows with no errors). No readiness percentage change.
- **2026-10-15** · Flex Layout (InDesign 2026): Object › Flex Layout (Add to, Convert to, Remove) and a Flex Layout panel laid out as InDesign's, also in Properties. A container lays out its objects by direction, wrap, justification, the alignment grid, Fixed/Auto width and height, padding and spacing, after every edit; Add detects aligned objects (direction, Auto size, Space Between at their spacing); resizing a container reflows its objects instead of scaling them; dragging an object in one reorders it. Montilla Design's tutorial replays step for step. No readiness percentage change.
- **2026-10-14** · Layout › Margins and Columns and Layout › Create Guides are laid out as InDesign's: Margins in Top/Bottom and Inside/Outside pairs with a link button that makes all four the same, Rows and Columns side by side, Fit Guides to Margins or Page, and a Preview checkbox that shows the change on the canvas before OK. A measurement field works out arithmetic when you leave it (`0.4375 in*2` becomes `0.875 in`). No readiness percentage change.
- **2026-10-14** · The Pathfinder panel is laid out as InDesign's: Paths (Join, Open, Close, Reverse), Pathfinder, Convert Shape and Convert Point, each a row of icon buttons. Join Path closes an open path or joins two at their nearest ends (`path.join`), and Convert Point makes the selected points plain, corner, smooth or symmetrical (`path.convertPoints`). No readiness percentage change.
- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change.
- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change.
- **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change.
+199 -2
View File
@@ -1,12 +1,13 @@
//! Anchor-point editing (the Pen tool group): Add Anchor Point, Delete Anchor Point and Convert
//! Direction Point. Positions are in spread coordinates; anchors are `(subpath, index)`.
//! Direction Point, and the Pathfinder panel's Join Path and Convert Point. Positions are in spread
//! coordinates; anchors are `(subpath, index)`.
use designcraft_doc::{Document, ItemId, Shape};
use designcraft_geom::{Affine, Anchor, AnchorKind, Point};
use serde_json::{Value, json};
use super::object::path_editable;
use super::{CommandSpec, bad, cmd, has_doc, point_param};
use super::{CommandSpec, bad, cmd, has_doc, has_selection, point_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
@@ -56,9 +57,147 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| erase_or_smooth(s, p, true)
),
cmd!(
"path.join",
"Join",
["Object", "Paths"],
None,
"{ids?} — one open path: closes it; two open paths: joins them where their ends are nearest (coincident ends become one point) into the first, and removes the second → {id}",
has_selection,
join_paths
),
cmd!(
"path.convertPoints",
"Convert Point",
[],
None,
"{kind: plain|corner|smooth|symmetrical, ids?} — the Direct Selection tool's selected points, or every point of the selected paths: plain (no direction handles), corner (independent handles), smooth (collinear handles), symmetrical (collinear, equal length) → {converted}",
has_selection,
convert_points
),
]
}
/// Join Path: close one open path, or join two open paths at their nearest ends.
fn join_paths(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::targets(s, p)?;
s.edit(|d, sel| {
let ids: Vec<ItemId> =
ids.iter().copied().filter(|id| d.item(*id).is_some_and(|it| !it.locked && !it.is_group() && it.text_frame().is_none())).collect();
let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1));
match ids.as_slice() {
[id] => {
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
let mut closed = 0;
for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) {
sp.closed = true;
closed += 1;
}
if closed == 0 {
return Err(bad("path.join", "the path is already closed"));
}
as_path(&mut it.shape);
Ok(json!({"id": id.0, "closed": closed}))
}
[a, b] => {
let (Some(sa), Some(sb)) = (open(d, *a), open(d, *b)) else {
return Err(bad("path.join", "select two open paths"));
};
// b's path in a's space.
let inv_a = to_inner(d, *a).ok_or(designcraft_doc::DocError::NoItem(*a))?;
let xf_b = to_inner(d, *b).ok_or(designcraft_doc::DocError::NoItem(*b))?.inverse();
let m = inv_a * xf_b;
let mut pb = d.item(*b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(*b))?;
for an in &mut pb.anchors {
*an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind };
}
let ita = d.item_mut(*a).ok_or(designcraft_doc::DocError::NoItem(*a))?;
let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?;
// The pair of ends nearest each other; then a's end meets b's start.
let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p));
let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else {
return Err(bad("path.join", "select two open paths"));
};
let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)];
let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else {
return Err(bad("path.join", "select two open paths"));
};
if rev_a {
pa.reverse();
}
if rev_b {
pb.reverse();
}
let mut rest = pb.anchors.into_iter();
if let Some(first) = rest.next() {
match pa.anchors.last_mut() {
Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out),
_ => pa.anchors.push(first),
}
}
pa.anchors.extend(rest);
as_path(&mut ita.shape);
d.remove_item(*b)?;
*sel = designcraft_doc::Selection::items(vec![*a]);
Ok(json!({"id": a.0}))
}
_ => Err(bad("path.join", "select one or two open paths")),
}
})
}
/// Convert Point: the selected points (or every point of the selected paths) to a point type.
fn convert_points(s: &mut Session, p: &Value) -> Result<Value> {
let kind = super::str_param(p, "kind").unwrap_or("plain").to_string();
if !matches!(kind.as_str(), "plain" | "corner" | "smooth" | "symmetrical") {
return Err(bad("path.convertPoints", format!("unknown kind `{kind}`")));
}
let mut points: Vec<(ItemId, usize, usize)> = if p.get("ids").is_none() { s.doc()?.selection.anchors.clone() } else { Vec::new() };
if points.is_empty() {
for id in super::targets(s, p)? {
if let Some(it) = s.doc()?.doc.item(id) {
points.extend(it.path.anchors().map(|(si, ai, _)| (id, si, ai)));
}
}
}
s.edit(|d, _| {
let mut converted = 0;
for (id, si, ai) in &points {
let Some(it) = d.item_mut(*id) else { continue };
if it.locked || it.is_group() {
continue;
}
let Some(sp) = it.path.subpaths.get_mut(*si) else { continue };
let Some(a) = sp.anchors.get(*ai).copied() else { continue };
let auto = smooth_auto(&sp.anchors, *ai, sp.closed);
// The handles: the point's own, or ones worked out from its neighbours.
let (h_in, h_out) = if a.has_in() || a.has_out() { (a.h_in, a.h_out) } else { (auto.h_in, auto.h_out) };
let new = match kind.as_str() {
"plain" => Anchor::corner(a.p),
"corner" => Anchor { p: a.p, h_in, h_out, kind: AnchorKind::Corner },
_ => {
let dir = if (h_out - h_in).hypot() > 1e-9 { h_out - h_in } else { auto.h_out - auto.h_in };
let len = dir.hypot();
if len < 1e-9 {
a
} else {
let u = dir / len;
let (li, lo) = ((a.p - h_in).hypot(), (h_out - a.p).hypot());
let (li, lo) = if kind == "symmetrical" { ((li + lo) / 2.0, (li + lo) / 2.0) } else { (li, lo) };
Anchor { p: a.p, h_in: a.p - u * li, h_out: a.p + u * lo, kind: AnchorKind::Smooth }
}
}
};
if let Some(slot) = sp.anchors.get_mut(*ai) {
*slot = new;
converted += 1;
}
as_path(&mut it.shape);
}
Ok(json!({"converted": converted}))
})
}
/// Erase / Smooth: the stretch of one subpath touched by the drag.
fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
let cmd = if smooth { "path.smooth" } else { "path.erase" };
@@ -258,6 +397,64 @@ pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usi
mod tests {
use super::*;
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> u64 {
s.execute("line.create", &json!({"a": a, "b": b})).unwrap()["id"].as_u64().unwrap()
}
/// Join Path closes one open path, and joins two open paths at their nearest ends.
#[test]
fn join_path_closes_one_path_and_joins_two() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = line(&mut s, [100.0, 100.0], [200.0, 100.0]);
// b runs back towards a: its end meets a's end at (200, 100).
let b = line(&mut s, [200.0, 200.0], [200.0, 100.0]);
let r = s.execute("path.join", &json!({"ids": [a, b]})).unwrap();
assert_eq!(r["id"], a);
let d = s.doc().unwrap().doc.clone();
assert!(d.item(ItemId(b)).is_none());
let sp = &d.item(ItemId(a)).unwrap().path.subpaths[0];
assert_eq!(sp.anchors.len(), 3, "the coincident ends become one point");
assert!(!sp.closed);
let bb = d.item(ItemId(a)).unwrap().bounds();
assert!((bb.width() - 100.0).abs() < 0.5 && (bb.height() - 100.0).abs() < 0.5, "{bb:?}");
// Joining it again closes it.
s.execute("path.join", &json!({"ids": [a]})).unwrap();
assert!(s.doc().unwrap().doc.item(ItemId(a)).unwrap().path.subpaths[0].closed);
assert!(s.execute("path.join", &json!({"ids": [a]})).is_err(), "nothing left to close");
// Ends apart are joined with a straight segment.
let c = line(&mut s, [300.0, 300.0], [400.0, 300.0]);
let e = line(&mut s, [300.0, 400.0], [400.0, 400.0]);
s.execute("path.join", &json!({"ids": [c, e]})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(c)).unwrap().path.subpaths[0].anchors.len(), 4);
}
/// Convert Point turns the selected points (or a whole path's) plain, corner, smooth or symmetrical.
#[test]
fn convert_point_kinds() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap();
let anchor = |s: &Session, i: usize| s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors[i];
s.execute("path.convertPoints", &json!({"kind": "smooth", "ids": [id]})).unwrap();
let a = anchor(&s, 0);
assert_eq!(a.kind, AnchorKind::Smooth);
assert!(a.has_in() && a.has_out());
let (di, dout) = (a.p - a.h_in, a.h_out - a.p);
assert!((di.x * dout.y - di.y * dout.x).abs() < 1e-6, "collinear handles");
s.execute("path.convertPoints", &json!({"kind": "symmetrical", "ids": [id]})).unwrap();
let a = anchor(&s, 1);
assert!(((a.p - a.h_in).hypot() - (a.h_out - a.p).hypot()).abs() < 1e-6, "equal handles");
s.execute("path.convertPoints", &json!({"kind": "plain", "ids": [id]})).unwrap();
let a = anchor(&s, 2);
assert!(!a.has_in() && !a.has_out() && a.kind == AnchorKind::Corner);
s.execute("path.convertPoints", &json!({"kind": "corner", "ids": [id]})).unwrap();
let a = anchor(&s, 2);
assert!(a.has_in() && a.has_out() && a.kind == AnchorKind::Corner, "a corner point gets independent handles");
assert!(s.execute("path.convertPoints", &json!({"kind": "nope", "ids": [id]})).is_err());
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().shape, Shape::Path);
}
#[test]
fn erase_and_smooth_along_a_drag() {
let mut s = Session::new();
+8
View File
@@ -152,6 +152,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Outside:", ["Außen:", "Extérieur :", "Exterior:", "小口:", "外:"]),
("Left:", ["Links:", "Gauche :", "Izquierda:", "左:", "左:"]),
("Right:", ["Rechts:", "Droite :", "Derecha:", "右:", "右:"]),
("Paths:", ["Pfade:", "Tracés :", "Trazados:", "パス:", "路径:"]),
("Pathfinder:", ["Pathfinder:", "Pathfinder :", "Buscatrazos:", "パスファインダー:", "路径查找器:"]),
("Convert Shape:", ["Form konvertieren:", "Convertir la forme :", "Convertir forma:", "シェイプを変換:", "转换形状:"]),
("Convert Point:", ["Punkt konvertieren:", "Convertir le point :", "Convertir punto:", "ポイントを変換:", "转换点:"]),
("Join Path", ["Pfad zusammenfügen", "Joindre le tracé", "Unir trazado", "パスを連結", "连接路径"]),
("Open Path", ["Pfad öffnen", "Ouvrir le tracé", "Abrir trazado", "パスをオープン", "开放路径"]),
("Plain", ["Einfach", "Simple", "Normal", "プレーン", "普通"]),
("Symmetrical", ["Symmetrisch", "Symétrique", "Simétrico", "対称", "对称"]),
("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]),
("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]),
("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]),
+8 -1
View File
@@ -68,6 +68,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Outside:", "الخارج:"),
("Left:", "يسار:"),
("Right:", "يمين:"),
("Paths:", "المسارات:"),
("Pathfinder:", "مستكشف المسار:"),
("Convert Shape:", "تحويل الشكل:"),
("Convert Point:", "تحويل النقطة:"),
("Join Path", "ربط المسار"),
("Open Path", "فتح المسار"),
("Plain", "عادي"),
("Symmetrical", "متماثل"),
("Reverse Path", "عكس المسار"),
("Set Anchor", "تعيين نقطة الارتساء"),
("Clear", "مسح"),
@@ -1714,7 +1722,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Object/Transform", "كائن/تحويل"),
("Object/Transform Again", "كائن/تحويل مجدداً"),
("One record", "سجل واحد"),
("Open Path", "مسار مفتوح"),
("Package", "تجميع"),
("Page Break", "فاصل صفحات"),
("Paragraph Return", "محرف نهاية الفقرة"),
+8
View File
@@ -66,6 +66,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Outside:", "Esterno:"),
("Left:", "Sinistra:"),
("Right:", "Destra:"),
("Paths:", "Tracciati:"),
("Pathfinder:", "Elaborazione tracciati:"),
("Convert Shape:", "Converti forma:"),
("Convert Point:", "Converti punto:"),
("Join Path", "Unisci tracciato"),
("Open Path", "Apri tracciato"),
("Plain", "Semplice"),
("Symmetrical", "Simmetrico"),
("Reverse Path", "Inverti tracciato"),
("Set Anchor", "Imposta ancoraggio"),
("Clear", "Cancella"),
+7
View File
@@ -63,6 +63,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Fit Guides to:", "ガイドの適用先:"),
("Inside:", "ノド:"),
("Outside:", "小口:"),
("Paths:", "パス:"),
("Pathfinder:", "パスファインダー:"),
("Convert Shape:", "シェイプを変換:"),
("Convert Point:", "ポイントを変換:"),
("Join Path", "パスを連結"),
("Plain", "プレーン"),
("Symmetrical", "対称"),
("Reverse Path", "パスの方向反転"),
("Set Anchor", "アンカーポイントを設定"),
("Clear", "消去"),
+10 -2
View File
@@ -72,6 +72,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Outside:", "Externa:"),
("Left:", "Esquerda:"),
("Right:", "Direita:"),
("Paths:", "Demarcadores:"),
("Pathfinder:", "Pathfinder:"),
("Convert Shape:", "Converter forma:"),
("Convert Point:", "Converter ponto:"),
("Join Path", "Unir demarcador"),
("Open Path", "Abrir demarcador"),
("Close Path", "Fechar demarcador"),
("Plain", "Simples"),
("Smooth", "Suave"),
("Symmetrical", "Simétrico"),
("Reverse Path", "Inverter demarcador"),
("Set Anchor", "Definir ponto de ancoragem"),
("Clear", "Limpar"),
@@ -1428,7 +1438,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Show/Hide Rulers", "Mostrar/ocultar réguas"),
("Show/Hide Tagged Frames", "Mostrar/ocultar quadros marcados"),
("Show/Hide Text Threads", "Mostrar/ocultar encadeamentos de texto"),
("Smooth", "Suavizar"),
("Sort", "Ordenar"),
("Split Path", "Dividir traçado"),
("Step and Repeat…", "Passo e repetição…"),
@@ -1475,7 +1484,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Basic Feather", "Esmaecimento básico"),
("Bold", "Negrito"),
("Character Style Options…", "Opções de estilo de caractere…"),
("Close Path", "Fechar traçado"),
("Color Group Options", "Opções de grupo de cores"),
("Condition Options…", "Opções de condição…"),
("Convert to Liquid Guide", "Converter em guia líquida"),
+8
View File
@@ -69,6 +69,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Outside:", "Зовнішнє:"),
("Left:", "Ліворуч:"),
("Right:", "Праворуч:"),
("Paths:", "Контури:"),
("Pathfinder:", "Обробка контурів:"),
("Convert Shape:", "Перетворити фігуру:"),
("Convert Point:", "Перетворити точку:"),
("Join Path", "Об’єднати контур"),
("Open Path", "Розімкнути контур"),
("Plain", "Простий"),
("Symmetrical", "Симетричний"),
("Reverse Path", "Розвернути контур"),
("Set Anchor", "Задати прив’язку"),
("Clear", "Очистити"),
+120
View File
@@ -614,6 +614,94 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.frect(11.5, 6.0, 15.0, 14.0);
pen.line(&[(8.5, 10.0), (11.5, 10.0)]);
}
// Pathfinder panel › Paths: a join point, a path opened and one closed, two opposed arrows.
"path-join" => {
pen.line(&[(3.0, 16.0), (5.0, 12.0), (10.0, 10.0)]);
pen.line(&[(10.0, 10.0), (15.0, 8.0), (17.0, 4.0)]);
pen.fcircle(10.0, 10.0, 2.2);
}
"path-open" | "path-close" => {
let gap = if name == "path-open" { 0.9 } else { 0.0 };
let n = 28;
let pts: Vec<(f32, f32)> = (0..=n)
.map(|i| {
let a = -std::f32::consts::FRAC_PI_2 + gap / 2.0 + (std::f32::consts::TAU - gap) * i as f32 / n as f32;
(10.0 + 6.5 * a.cos(), 10.5 + 6.5 * a.sin())
})
.collect();
pen.line(&pts);
if name == "path-open" {
pen.frect(11.0, 2.5, 14.0, 5.5);
pen.frect(6.0, 2.5, 9.0, 5.5);
} else {
pen.fcircle(10.0, 4.0, 1.8);
}
}
"path-reverse" => {
pen.line(&[(3.0, 7.0), (16.0, 7.0)]);
pen.line(&[(13.0, 4.0), (16.5, 7.0), (13.0, 10.0)]);
pen.line(&[(17.0, 13.0), (4.0, 13.0)]);
pen.line(&[(7.0, 10.0), (3.5, 13.0), (7.0, 16.0)]);
}
// Pathfinder panel › Convert Shape: the shape outlined.
"shape-rect" => pen.rect(3.0, 5.0, 17.0, 15.0),
"shape-rounded" | "shape-beveled" | "shape-inverse" => {
let (x0, y0, x1, y1, r): (f32, f32, f32, f32, f32) = (3.0, 5.0, 17.0, 15.0, 3.5);
// Clockwise from the top left: each corner's two ends, and the centre of its arc
// (inside for a rounded corner, the corner itself for an inverse rounded one).
let corners = [
((x0, y0), (x0, y0 + r), (x0 + r, y0), (x0 + r, y0 + r)),
((x1, y0), (x1 - r, y0), (x1, y0 + r), (x1 - r, y0 + r)),
((x1, y1), (x1, y1 - r), (x1 - r, y1), (x1 - r, y1 - r)),
((x0, y1), (x0 + r, y1), (x0, y1 - r), (x0 + r, y1 - r)),
];
let mut pts = Vec::new();
for (corner, a, b, inner) in corners {
if name == "shape-beveled" {
pts.extend([a, b]);
continue;
}
let c = if name == "shape-inverse" { corner } else { inner };
let a0 = (a.1 - c.1).atan2(a.0 - c.0);
let mut d = (b.1 - c.1).atan2(b.0 - c.0) - a0;
if d > std::f32::consts::PI {
d -= std::f32::consts::TAU;
} else if d < -std::f32::consts::PI {
d += std::f32::consts::TAU;
}
pts.extend((0..=6).map(|k| {
let t = a0 + d * k as f32 / 6.0;
(c.0 + r * t.cos(), c.1 + r * t.sin())
}));
}
pen.closed(&pts);
}
"shape-ellipse" => pen.ellipse(10.0, 10.0, 7.5, 5.0),
"shape-triangle" => pen.closed(&[(10.0, 3.5), (17.0, 16.0), (3.0, 16.0)]),
"shape-polygon" => pen.closed(&[(6.5, 4.0), (13.5, 4.0), (17.0, 10.0), (13.5, 16.0), (6.5, 16.0), (3.0, 10.0)]),
"shape-line" => pen.line(&[(4.0, 16.0), (16.0, 4.0)]),
"shape-orthogonal" => {
pen.line(&[(10.0, 3.0), (10.0, 17.0)]);
pen.line(&[(3.0, 10.0), (17.0, 10.0)]);
}
// Pathfinder panel › Convert Point: a point on a path, with its direction handles.
"point-plain" | "point-corner" | "point-smooth" | "point-symmetrical" => {
match name {
"point-plain" | "point-corner" => pen.line(&[(3.0, 16.0), (10.0, 8.0), (17.0, 16.0)]),
_ => pen.line(&[(3.0, 16.0), (5.5, 11.5), (10.0, 8.0), (14.5, 11.5), (17.0, 16.0)]),
}
let handles: &[(f32, f32)] = match name {
"point-corner" => &[(4.5, 4.0), (16.0, 5.0)],
"point-smooth" => &[(5.0, 8.0), (17.5, 8.0)],
"point-symmetrical" => &[(4.0, 8.0), (16.0, 8.0)],
_ => &[],
};
for &(hx, hy) in handles {
pen.line(&[(10.0, 8.0), (hx, hy)]);
pen.fcircle(hx, hy, 1.4);
}
pen.frect(8.5, 6.5, 11.5, 9.5);
}
// ---- Properties panel glyphs (original drawings)
"orient-portrait" => {
pen.rect(5.5, 3.0, 14.5, 17.0);
@@ -950,6 +1038,38 @@ mod tests {
format!("{:?}", out.shapes.iter().map(|s| &s.shape).collect::<Vec<_>>())
}
/// The Pathfinder panel's icons are all drawn, and all differ.
#[test]
fn pathfinder_panel_icons_are_drawn() {
let names = [
"path-join",
"path-open",
"path-close",
"path-reverse",
"shape-rect",
"shape-rounded",
"shape-beveled",
"shape-inverse",
"shape-ellipse",
"shape-triangle",
"shape-polygon",
"shape-line",
"shape-orthogonal",
"point-plain",
"point-corner",
"point-smooth",
"point-symmetrical",
];
let unknown = drawing("no-such-icon");
let drawn: Vec<String> = names.iter().map(|n| drawing(n)).collect();
for (i, (n, d)) in names.iter().zip(&drawn).enumerate() {
assert_ne!(*d, unknown, "{n} falls back to the placeholder");
for (m, e) in names.iter().zip(&drawn).skip(i + 1) {
assert_ne!(d, e, "{n} draws like {m}");
}
}
}
#[test]
fn transform_tools_have_their_own_icons() {
let names = ["tool-free-transform", "tool-scale", "tool-shear"];
+67 -14
View File
@@ -1924,26 +1924,79 @@ fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) {
});
}
/// Pathfinder panel: combine the selected shapes; make or release compound paths.
/// Pathfinder panel › Paths: (icon, command, params, tooltip).
const PATHS: [(&str, &str, &str, &str); 4] = [
("path-join", "path.join", "{}", "Join Path"),
("path-open", "object.convertShape", r#"{"to": "openPath"}"#, "Open Path"),
("path-close", "object.convertShape", r#"{"to": "closedPath"}"#, "Close Path"),
("path-reverse", "path.reverse", "{}", "Reverse Path"),
];
/// Pathfinder panel › Convert Shape: (icon, shape, tooltip).
const CONVERT_SHAPE: [(&str, &str, &str); 9] = [
("shape-rect", "rectangle", "Rectangle"),
("shape-rounded", "roundedRectangle", "Rounded Rectangle"),
("shape-beveled", "beveledRectangle", "Beveled Rectangle"),
("shape-inverse", "inverseRoundedRectangle", "Inverse Rounded Rectangle"),
("shape-ellipse", "ellipse", "Ellipse"),
("shape-triangle", "triangle", "Triangle"),
("shape-polygon", "polygon", "Polygon"),
("shape-line", "line", "Line"),
("shape-orthogonal", "orthogonalLine", "Orthogonal Line"),
];
/// Pathfinder panel › Convert Point: (icon, kind, tooltip).
const CONVERT_POINT: [(&str, &str, &str); 4] = [
("point-plain", "plain", "Plain"),
("point-corner", "corner", "Corner"),
("point-smooth", "smooth", "Smooth"),
("point-symmetrical", "symmetrical", "Symmetrical"),
];
/// Pathfinder panel, as InDesign's: Paths, Pathfinder, Convert Shape and Convert Point, each a row of
/// icon buttons acting on the selection.
pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pathfinder")).strong());
ui.horizontal_wrapped(|ui| {
for (label, op) in
[("Add", "add"), ("Subtract", "subtract"), ("Intersect", "intersect"), ("Exclude Overlap", "exclude"), ("Minus Back", "minusBack")]
{
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
let section = |ui: &mut egui::Ui, app: &DesignApp, title: &str| {
ui.add_space(4.0);
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, title));
ui.add_space(2.0);
};
let row = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui)| {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(12.0, 8.0);
add(ui);
});
};
section(ui, app, "Paths:");
row(ui, &mut |ui| {
for (icon, cmd, params, tip) in PATHS {
if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run(cmd, serde_json::from_str(params).unwrap_or_default());
}
}
});
section(ui, app, "Pathfinder:");
row(ui, &mut |ui| {
for (icon, op, tip) in PATHFINDER {
if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run("object.pathfinder", json!({"op": op}));
}
}
});
ui.add_space(4.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paths")).strong());
ui.horizontal(|ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Make Compound"))).clicked() {
let _ = app.run("object.makeCompoundPath", json!({}));
section(ui, app, "Convert Shape:");
row(ui, &mut |ui| {
for (icon, to, tip) in CONVERT_SHAPE {
if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run("object.convertShape", json!({"to": to}));
}
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Release"))).clicked() {
let _ = app.run("object.releaseCompoundPath", json!({}));
});
section(ui, app, "Convert Point:");
row(ui, &mut |ui| {
for (icon, kind, tip) in CONVERT_POINT {
if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run("path.convertPoints", json!({"kind": kind}));
}
}
});
}
+2
View File
@@ -418,6 +418,8 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); the result keeps the frontmost object's look and content, so a photo intersected with shapes behind it shows through them (Subtract keeps the backmost); `object.pathfinder`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical).
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.