mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
Merge #84 into fix/29-kerning-across-runs
This commit is contained in:
Generated
+1
@@ -991,6 +991,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen-test",
|
||||
"zip",
|
||||
]
|
||||
|
||||
+19
-12
@@ -1,8 +1,8 @@
|
||||
# DesignCraft roadmap
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
|
||||
format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
@@ -25,15 +25,14 @@ format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
| Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing |
|
||||
|
||||
**Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in
|
||||
[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to
|
||||
another: the in and out ports are drawn, but clicking the out port does nothing and there is no
|
||||
thread or unthread command. Long text flows only through autoflow on Place and Primary Text
|
||||
Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything
|
||||
else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF
|
||||
export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual
|
||||
threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on
|
||||
real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux.
|
||||
Details: [docs/target-app-parity.md](docs/target-app-parity.md).
|
||||
[docs/roadmap.md](docs/roadmap.md#alpha-gate). Text threads by hand (click a port, then click or
|
||||
draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users
|
||||
can't delete (#165). Everything else in the gate works end to end on macOS (document setup,
|
||||
styles, images, object editing, PDF export, save and reopen), and ready for real work (~47%) is
|
||||
above the alpha bar, so the Primary Text Frame page fixes are the whole distance to alpha.
|
||||
Beyond that, beta needs INDD opening, IDML proven on real files, composition compared with
|
||||
InDesign's, and basic editing fixed on Windows and Linux. Details:
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md).
|
||||
|
||||
## By dimension
|
||||
|
||||
@@ -106,7 +105,7 @@ Also shipped: Italian (85%), Ukrainian (83%).
|
||||
Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in
|
||||
[docs/gaps.md](docs/gaps.md).
|
||||
|
||||
1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h)
|
||||
1. **Alpha gate:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 h)
|
||||
2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h)
|
||||
3. CI running `cargo xtask ci` on pull requests (4–8 h)
|
||||
4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h)
|
||||
@@ -129,6 +128,13 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load
|
||||
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
|
||||
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
|
||||
frame just added; click the linked frame or double-click a port to break the thread.
|
||||
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
|
||||
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
|
||||
readiness percentage change.
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
|
||||
|
||||
@@ -169,6 +175,7 @@ Newest first.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) |
|
||||
| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md |
|
||||
|
||||
@@ -300,6 +300,9 @@ impl Document {
|
||||
if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine {
|
||||
return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame")));
|
||||
}
|
||||
if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) {
|
||||
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
|
||||
}
|
||||
}
|
||||
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
|
||||
let same_place = match (fl.spread, tl.spread) {
|
||||
|
||||
@@ -30,6 +30,7 @@ log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
caseless = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -81,13 +81,23 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
|
||||
});
|
||||
}
|
||||
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
|
||||
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
|
||||
let text = outside_text(&text)?;
|
||||
s.execute("text.insert", &json!({"text": text, "raw": true}))
|
||||
}
|
||||
|
||||
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
|
||||
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
|
||||
/// variables, since a stray one would be read as a marker without its payload.
|
||||
fn outside_text(text: &str) -> Result<String> {
|
||||
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
|
||||
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
|
||||
}
|
||||
|
||||
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
|
||||
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
|
||||
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected.
|
||||
fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result<Value> {
|
||||
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
|
||||
/// `slice` (the text copied here), the frame's story is that formatted copy.
|
||||
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
|
||||
use designcraft_geom::{Point, Rect, shapes};
|
||||
const LIMIT: f64 = 1.0e5;
|
||||
let sr = super::spread_param(p, "spread");
|
||||
@@ -96,7 +106,7 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result<Value> {
|
||||
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
|
||||
let lid = s.doc()?.active_layer;
|
||||
let cache = s.cache.clone();
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
|
||||
s.edit(|d, sel| {
|
||||
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
|
||||
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
|
||||
@@ -107,8 +117,14 @@ fn paste_text_frame(s: &mut Session, p: &Value, text: &str) -> Result<Value> {
|
||||
let max_h = margins.height().max(12.0);
|
||||
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
|
||||
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
|
||||
let para = designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
|
||||
let para = match slice.and_then(|sl| sl.paras.first()) {
|
||||
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
|
||||
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
|
||||
};
|
||||
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
|
||||
if let Some(sl) = slice {
|
||||
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
|
||||
}
|
||||
// Shrink to the composed text unless it already fills the frame.
|
||||
let composed = cache.get(d, sid, None);
|
||||
if !composed.is_overset()
|
||||
@@ -279,7 +295,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return paste_text(s, text.map(str::to_string), true);
|
||||
}
|
||||
if let Some(text) = text {
|
||||
return paste_text_frame(s, p, text);
|
||||
// What our own text copy put on the clipboard keeps its formatting.
|
||||
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
|
||||
return paste_text_frame(s, p, text, slice.as_deref());
|
||||
}
|
||||
let Some(clip) = s.clipboard.clone() else {
|
||||
return Err(super::bad("edit.paste", "clipboard is empty"));
|
||||
@@ -404,6 +422,47 @@ mod text_clipboard_tests {
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_text_never_pastes_markers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// Private-use characters from another app would read as footnote, table or variable markers.
|
||||
let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}";
|
||||
let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap();
|
||||
let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}");
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
let b = frame(&mut s, "Target");
|
||||
s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap();
|
||||
s.execute("edit.paste", &json!({"text": dirty})).unwrap();
|
||||
s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}");
|
||||
assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = frame(&mut s, "Bold words\nSecond");
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap();
|
||||
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap();
|
||||
let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({"text": copied})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap();
|
||||
assert_eq!(st.text, "Bold words\nSecond");
|
||||
assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold"));
|
||||
assert_eq!(st.char_format_at(7).over.font_style, None);
|
||||
assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]);
|
||||
d.doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_copy_paste_and_plain_paste() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -124,19 +124,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
|
||||
}
|
||||
|
||||
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
|
||||
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
|
||||
/// name `cmd`, the command that autoflows.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result<usize> {
|
||||
let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
|
||||
if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) {
|
||||
return Err(bad(cmd, "autoflow works only from a frame on a document page"));
|
||||
}
|
||||
let mut added = 0;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
let Some(at) = overset else { return Ok(added) };
|
||||
if added >= max_pages {
|
||||
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
|
||||
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
|
||||
}
|
||||
let len = d.story(sid).map_or(0, |s| s.len());
|
||||
// Frames still needed at the rate the full ones are filled (at least one); with no full
|
||||
// frame to go by yet, one more page.
|
||||
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
|
||||
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
|
||||
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||||
d.insert_pages(Some(page), want, parent)?;
|
||||
@@ -144,7 +150,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu
|
||||
let mut prev = last;
|
||||
for _ in 0..want {
|
||||
page += 1;
|
||||
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
|
||||
let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?;
|
||||
let r = d.spreads[si].pages[pi].margin_rect();
|
||||
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
|
||||
d.thread(prev, fid)?;
|
||||
@@ -230,7 +236,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
|
||||
*sel = designcraft_doc::Selection::items(vec![fid]);
|
||||
sid
|
||||
};
|
||||
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
|
||||
let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 };
|
||||
let overset = fit(d, sid).0.is_some();
|
||||
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
|
||||
})
|
||||
|
||||
@@ -1038,10 +1038,78 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rename_in(std::sync::Arc::make_mut(top), &name, &new);
|
||||
}
|
||||
}
|
||||
for st in d.stories.values_mut() {
|
||||
let mut copy = (**st).clone();
|
||||
if rename_in_story(&mut copy, &name, &new) {
|
||||
*st = std::sync::Arc::new(copy);
|
||||
}
|
||||
}
|
||||
Ok(json!({"name": new}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
|
||||
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
|
||||
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
|
||||
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str) -> bool {
|
||||
fn fix(s: &mut String, from: &str, to: &str) -> bool {
|
||||
let hit = s == from;
|
||||
if hit {
|
||||
*s = to.to_string();
|
||||
}
|
||||
hit
|
||||
}
|
||||
let mut n = 0;
|
||||
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
|
||||
for r in &mut st.chars {
|
||||
let o = &mut r.format.over;
|
||||
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
|
||||
f(c);
|
||||
}
|
||||
}
|
||||
for pf in &mut st.paras {
|
||||
let a = &mut pf.para;
|
||||
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
|
||||
f(c);
|
||||
}
|
||||
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
|
||||
f(&mut r.color);
|
||||
}
|
||||
}
|
||||
let mut nested = false;
|
||||
for t in st.tables.values_mut() {
|
||||
let t = std::sync::Arc::make_mut(t);
|
||||
let o = &mut t.options;
|
||||
f(&mut o.border.color);
|
||||
for b in o.borders.iter_mut().flatten() {
|
||||
f(&mut b.color);
|
||||
}
|
||||
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
|
||||
f(&mut a.first_color);
|
||||
f(&mut a.next_color);
|
||||
}
|
||||
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
|
||||
f(&mut k.first_stroke.color);
|
||||
f(&mut k.next_stroke.color);
|
||||
}
|
||||
for c in &mut t.cells {
|
||||
f(&mut c.fill);
|
||||
for k in &mut c.strokes {
|
||||
f(&mut k.color);
|
||||
}
|
||||
nested |= rename_in_story(&mut c.text, from, to);
|
||||
}
|
||||
}
|
||||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to);
|
||||
}
|
||||
let changed = n > 0 || nested;
|
||||
if changed {
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Point fills and strokes of an item (and its children) at a renamed swatch.
|
||||
fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||||
if it.fill.swatch == from {
|
||||
@@ -1205,6 +1273,40 @@ mod color_tests {
|
||||
s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() {
|
||||
use std::sync::Arc;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap();
|
||||
s.execute("footnote.insert", &json!({"text": "note"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap();
|
||||
let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string();
|
||||
// The same unnamed colour on a cell, the cell's text and the footnote's text.
|
||||
s.edit(|d, _| {
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let t = Arc::make_mut(st.tables.values_mut().next().unwrap());
|
||||
t.cells[0].fill = old.clone();
|
||||
t.cells[0].text.chars[0].format.over.fill = Some(old.clone());
|
||||
Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let st = d.story(sid).unwrap();
|
||||
assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand"));
|
||||
let cell = &st.tables.values().next().unwrap().cells[0];
|
||||
assert_eq!(cell.fill, "Sand");
|
||||
assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand"));
|
||||
assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand"));
|
||||
d.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+110
-24
@@ -30,12 +30,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text Unit",
|
||||
[],
|
||||
None,
|
||||
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, paragraph or the whole story",
|
||||
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
|
||||
has_doc,
|
||||
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
|
||||
),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
|
||||
let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0));
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
let text = &st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.text;
|
||||
let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize);
|
||||
@@ -323,7 +324,7 @@ fn hit_byte_with(
|
||||
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
|
||||
const ID: &str = "text.selectAt";
|
||||
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
|
||||
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
|
||||
let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?;
|
||||
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
|
||||
let st = s.doc()?;
|
||||
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
@@ -335,33 +336,49 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
|
||||
}
|
||||
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, r.end)),
|
||||
// The paragraph with its return (the last paragraph has none).
|
||||
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))),
|
||||
"story" => (0, text.len()),
|
||||
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
|
||||
};
|
||||
s.text_drag = false;
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
|
||||
st.revision += 1;
|
||||
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
|
||||
}
|
||||
|
||||
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character. At
|
||||
/// the end of a paragraph, what ends it.
|
||||
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
|
||||
/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split:
|
||||
/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base.
|
||||
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
|
||||
let at = |i: usize| s.get(i..).and_then(|r| r.chars().next()).filter(|c| *c != '\n');
|
||||
let i = if at(i).is_none() && i > 0 { prev_char(s, i) } else { i };
|
||||
let Some(c) = at(i) else { return (i, i) };
|
||||
let space = |c: char| c.is_whitespace() && c != '\n';
|
||||
if is_word(c) {
|
||||
word_bounds(s, i)
|
||||
} else if space(c) {
|
||||
let a = s[..i].char_indices().rev().take_while(|(_, c)| space(*c)).last().map_or(i, |(k, _)| k);
|
||||
let e = s[i..].char_indices().find(|(_, c)| !space(*c)).map_or(s.len(), |(k, _)| i + k);
|
||||
(a, e)
|
||||
} else {
|
||||
(i, i + c.len_utf8())
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let i = floor_char_boundary(s, i.min(s.len()));
|
||||
// Clusters never span a paragraph return, so the paragraph is enough.
|
||||
let ps = s[..i].rfind('\n').map_or(0, |k| k + 1);
|
||||
let pe = s[i..].find('\n').map_or(s.len(), |k| i + k);
|
||||
let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect();
|
||||
let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) };
|
||||
let kind = |c: &str| match c.chars().next() {
|
||||
Some(c) if is_word(c) => 1,
|
||||
Some(c) if c.is_whitespace() => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len()));
|
||||
let kd = g.get(k).map_or(0, |(_, c)| kind(c));
|
||||
if kd == 0 {
|
||||
return span(k, k);
|
||||
}
|
||||
let mut a = k;
|
||||
while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut e = k;
|
||||
while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||||
e += 1;
|
||||
}
|
||||
span(a, e)
|
||||
}
|
||||
|
||||
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
|
||||
@@ -397,6 +414,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||||
} else if s.text_drag {
|
||||
return Ok(json!({"story": sid.0, "dragging": true}));
|
||||
}
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
// Dragging from one cell into another selects cells.
|
||||
if extend
|
||||
@@ -721,12 +739,6 @@ pub fn prev_word(s: &str, i: usize) -> usize {
|
||||
}
|
||||
0
|
||||
}
|
||||
fn word_bounds(s: &str, i: usize) -> (usize, usize) {
|
||||
let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
|
||||
let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Type ▸ Story Direction.
|
||||
fn story_direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?;
|
||||
@@ -807,6 +819,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"storyEnd" => text.len(),
|
||||
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
|
||||
};
|
||||
s.typing_format = None;
|
||||
let stm = s.doc_mut()?;
|
||||
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
|
||||
stm.revision += 1;
|
||||
@@ -1155,6 +1168,10 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// No undo step for nothing.
|
||||
if changed == 0 {
|
||||
return Err(bad(id, "nothing to change"));
|
||||
}
|
||||
ok()
|
||||
});
|
||||
r?;
|
||||
@@ -1435,6 +1452,69 @@ mod multi_click_tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn moving_the_caret_drops_the_pending_typing_format() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||||
let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
|
||||
let pending = |s: &mut Session| {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
};
|
||||
// Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the
|
||||
// same caret is in the text's own format.
|
||||
let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![
|
||||
Box::new(|s| {
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}),
|
||||
Box::new(|s| {
|
||||
s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
}),
|
||||
Box::new(|s| {
|
||||
s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
}),
|
||||
];
|
||||
for m in &moves {
|
||||
pending(&mut s);
|
||||
m(&mut s);
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(size_at(&s, 6), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Without a move it applies.
|
||||
pending(&mut s);
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(size_at(&s, 6), Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_selection_keeps_grapheme_clusters_whole() {
|
||||
use super::word_or_spaces_at as word;
|
||||
// Decomposed "café": the combining acute belongs to the word, wherever the click lands.
|
||||
let s = "un cafe\u{301} noir";
|
||||
let cafe = (3, 3 + "cafe\u{301}".len());
|
||||
assert_eq!(word(s, 4), cafe);
|
||||
assert_eq!(word(s, 7), cafe, "on the combining mark");
|
||||
// Devanagari with a virama (U+094D) and a dependent vowel sign.
|
||||
let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x";
|
||||
let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len();
|
||||
assert_eq!(word(s, 0), (0, w));
|
||||
assert_eq!(word(s, 9), (0, w), "on the virama");
|
||||
// An emoji ZWJ sequence and a variation selector stay one character.
|
||||
let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}";
|
||||
let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len());
|
||||
assert_eq!(word(s, 2), e);
|
||||
assert_eq!(word(s, 6), e, "on the joiner");
|
||||
assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole");
|
||||
// Empty paragraphs select nothing.
|
||||
assert_eq!(word("a\n\nb", 2), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
|
||||
let mut s = Session::new();
|
||||
@@ -1459,7 +1539,7 @@ mod multi_click_tests {
|
||||
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
|
||||
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
|
||||
assert_eq!(select("line", 9), (0, 19));
|
||||
assert_eq!(select("paragraph", 9), (0, 19));
|
||||
assert_eq!(select("paragraph", 9), (0, 20), "with its return");
|
||||
assert_eq!(select("paragraph", 22), (20, text.len()));
|
||||
assert_eq!(select("story", 9), (0, text.len()));
|
||||
// A second click inside the selected word selects again rather than starting a text drag.
|
||||
@@ -1477,6 +1557,7 @@ mod multi_click_tests {
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (7, 12));
|
||||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
|
||||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,6 +1590,11 @@ mod style_link_tests {
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&mut s, 0), "Italic");
|
||||
assert_eq!(style_at(&mut s, 7), "Regular");
|
||||
// Nothing to change: an error and no undo step.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("type.bold", &json!({"on": false})).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// At a caret the style is for what is typed next.
|
||||
let end = "plain words here".len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
|
||||
|
||||
@@ -74,6 +74,9 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|d, sel| {
|
||||
let mut new_frame = || -> Result<ItemId> {
|
||||
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
|
||||
if d.layer(layer).is_none_or(|l| l.locked || !l.visible) {
|
||||
return Err(bad("text.thread", "the active layer is locked or hidden"));
|
||||
}
|
||||
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
|
||||
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
|
||||
};
|
||||
@@ -92,7 +95,7 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.check_thread(from, to)?;
|
||||
d.thread_keeping_story(from, to)?;
|
||||
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
|
||||
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES)? } else { 0 };
|
||||
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 };
|
||||
*sel = Selection::items(vec![joined]);
|
||||
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
|
||||
})
|
||||
@@ -241,6 +244,29 @@ mod tests {
|
||||
assert_eq!(*s.doc().unwrap().doc, *doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_frames_and_layers_refuse_threads() {
|
||||
let mut s = session();
|
||||
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
||||
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
|
||||
let layer = s.doc().unwrap().active_layer.0;
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.lock", &json!({"ids": [b]})).unwrap();
|
||||
assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame");
|
||||
assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] {
|
||||
let mut p = json!({"id": layer});
|
||||
p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone());
|
||||
s.execute("layer.set", &p).unwrap();
|
||||
let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) };
|
||||
assert!(s.execute("text.thread", &t).is_err(), "{p} {t}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(frames(&mut s, a), [a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_a_new_frame_after_or_in_front() {
|
||||
let mut s = session();
|
||||
@@ -280,6 +306,24 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_from_a_parent_frame_is_an_error() {
|
||||
let mut s = session();
|
||||
let parent = json!({"kind": "parent", "index": 0});
|
||||
let r = s.execute(
|
||||
"frame.create",
|
||||
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}),
|
||||
);
|
||||
let a = r.unwrap()["id"].as_u64().unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("text.thread"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(*s.doc().unwrap().doc, *doc);
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unthread_after_and_before() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -468,7 +468,7 @@ impl Session {
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
if overset {
|
||||
let _ = cmd::place_text_autoflow(&mut d, sid, 500);
|
||||
let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow");
|
||||
} else {
|
||||
// Drop empty trailing frames on the last pages (one frame, nothing else there).
|
||||
while let Some(story) = d.story(sid) {
|
||||
|
||||
@@ -132,7 +132,7 @@ impl SelectionTool {
|
||||
self.loaded = self.loaded.and_then(|l| l.resolve(cx.doc));
|
||||
match (ev.kind, self.loaded) {
|
||||
(PointerKind::Down | PointerKind::DoubleClick, _) if let Some((frame, out)) = crate::thread::port_at(cx, p) => {
|
||||
self.loaded = Some(Loaded { frame, out, advance: false });
|
||||
self.loaded = Some(Loaded::new(frame, out));
|
||||
self.drag = Drag::None;
|
||||
Some(vec![])
|
||||
}
|
||||
@@ -148,13 +148,12 @@ impl SelectionTool {
|
||||
}
|
||||
(PointerKind::Up, Some(l)) => {
|
||||
let Drag::Place { start, .. } = std::mem::replace(&mut self.drag, Drag::None) else { return Some(vec![]) };
|
||||
let cmd = l.click(cx, start, p, ev.mods);
|
||||
let clicked = l.click(cx, start, p, ev.mods);
|
||||
// Alt (semi-autoflow) stays loaded with what is left; an invalid target keeps the load.
|
||||
if cmd.is_some() {
|
||||
self.loaded = (ev.mods.alt && !ev.mods.shift && cmd.as_ref().is_some_and(|c| c.0 == "text.thread"))
|
||||
.then_some(Loaded { advance: true, ..l });
|
||||
if let Some(c) = &clicked {
|
||||
self.loaded = c.joins.filter(|_| ev.mods.alt && !ev.mods.shift).map(|j| Loaded { advance: Some(j), ..l });
|
||||
}
|
||||
Some(exec(cmd))
|
||||
Some(exec(clicked.map(|c| (c.id, c.params))))
|
||||
}
|
||||
(PointerKind::Move, Some(_)) => Some(vec![]),
|
||||
_ => None,
|
||||
@@ -800,9 +799,14 @@ 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 } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
Drag::Place { start, cur } if crate::thread::is_drag(cx, *start, *cur) => {
|
||||
let Some((sr, r, guides)) = crate::thread::drag_frame(cx, *start, *cur) else { return vec![] };
|
||||
let r = Rect::from_points(cx.layout.to_canvas(sr, Point::new(r.x0, r.y0)), cx.layout.to_canvas(sr, Point::new(r.x1, r.y1)));
|
||||
std::iter::once(Overlay::Marquee(r)).chain(guides).collect()
|
||||
}
|
||||
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(),
|
||||
_ => vec![],
|
||||
}
|
||||
|
||||
@@ -823,14 +823,63 @@ fn loaded_drag_makes_a_frame_of_that_size_and_shift_autoflows() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_stays_loaded() {
|
||||
fn loaded_drag_snaps_like_frame_drawing() {
|
||||
let td = thread_doc();
|
||||
let cx = td.cx();
|
||||
let cx = ToolContext { snap: SnapView::FACTORY, ..td.cx() };
|
||||
let mut t = create("selection");
|
||||
click(&mut t, &cx, td.port(td.a, true), Mods::default());
|
||||
let col = td.d.spreads[0].pages[0].column_rects()[0];
|
||||
// Press and release a point or two inside the column's edges: both sides snap to them.
|
||||
let (p0, p1) = (td.at(col.x0 + 1.5, 560.0), td.at(col.x1 - 2.0, 650.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y));
|
||||
let Some(Overlay::Marquee(m)) = t.overlays(&cx).first().cloned() else { panic!("no marquee") };
|
||||
assert!((m.x0 - td.at(col.x0, 0.0).x).abs() < 1e-9 && (m.x1 - td.at(col.x1, 0.0).x).abs() < 1e-9, "{m:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y));
|
||||
let [Action::Exec(id, p)] = a.as_slice() else { panic!("{a:?}") };
|
||||
assert_eq!(id, "text.thread");
|
||||
let r: Vec<f64> = p["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!((r[0], r[2]), (col.x0, col.x1), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_stays_loaded() {
|
||||
let mut td = thread_doc();
|
||||
let original = td.d.clone();
|
||||
let mut t = create("selection");
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0})));
|
||||
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText);
|
||||
let col = td.d.spreads[0].pages[0].column_rects()[0];
|
||||
let empty = td.at(200.0, 600.0);
|
||||
let new_frame_from =
|
||||
|f: ItemId| thread_exec(serde_json::json!({"from": f.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}}));
|
||||
{
|
||||
let cx = td.cx();
|
||||
click(&mut t, &cx, td.port(td.a, true), Mods::default());
|
||||
assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0})));
|
||||
assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText);
|
||||
// The thread failed (the document is unchanged): still loaded from a.
|
||||
assert_eq!(click(&mut t, &cx, empty, alt), new_frame_from(td.a));
|
||||
}
|
||||
// That click's command ran: the next click threads after the frame it added.
|
||||
let (n, _) =
|
||||
td.d.add_text_frame(SpreadRef::Doc(0), Rect::new(col.x0, 600.0, col.x1, col.y1), td.d.default_layer(), "", ParaFormat::default()).unwrap();
|
||||
td.d.thread(td.a, n).unwrap();
|
||||
{
|
||||
let cx = td.cx();
|
||||
assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": n.0, "to": td.e.0})));
|
||||
}
|
||||
// Undone: back on a, not on a's old neighbour or the frame the undo removed.
|
||||
td.d = original;
|
||||
{
|
||||
let cx = td.cx();
|
||||
assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.a));
|
||||
}
|
||||
// From x, which threads into y: when threading e in between fails, x stays loaded, not y.
|
||||
let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td };
|
||||
let cx = td.cx();
|
||||
click(&mut t, &cx, td.port(td.x, true), Mods::default());
|
||||
assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.x.0, "to": td.e.0})));
|
||||
assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.x));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+77
-17
@@ -10,7 +10,7 @@ use designcraft_doc::{Document, Item, ItemId, SpreadRef};
|
||||
use designcraft_geom::{Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CanvasLayout, Cursor, Mods, ToolContext, rect_json, spread_json};
|
||||
use crate::{CanvasLayout, Cursor, Mods, Overlay, ToolContext, rect_json, spread_json};
|
||||
|
||||
/// Port square size, in screen pixels.
|
||||
pub const PORT_SIZE_PX: f64 = 8.5;
|
||||
@@ -87,15 +87,51 @@ pub fn click_frame_rect(doc: &Document, sr: SpreadRef, p: Point) -> Option<Rect>
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
|
||||
}
|
||||
|
||||
/// Is a press at `start` released at `p` (canvas) a drag rather than a click?
|
||||
pub(crate) fn is_drag(cx: &ToolContext, start: Point, p: Point) -> bool {
|
||||
(p - start).hypot() >= cx.tol(3.0)
|
||||
}
|
||||
|
||||
/// The frame a drag with the loaded cursor from `start` to `p` (canvas) makes, in spread
|
||||
/// coordinates, and the snap guides to draw. It is drawn like the frame tools (the press point
|
||||
/// and the moving edges snap) without their modifiers: Shift and Alt autoflow here.
|
||||
pub(crate) fn drag_frame(cx: &ToolContext, start: Point, p: Point) -> Option<(SpreadRef, Rect, Vec<Overlay>)> {
|
||||
let (sr, sa) = cx.layout.spread_at(start)?;
|
||||
let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa };
|
||||
let (r, guides) = crate::frame::snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, p), Mods::default());
|
||||
Some((sr, r, guides))
|
||||
}
|
||||
|
||||
/// The loaded text cursor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Loaded {
|
||||
/// The frame whose port was clicked.
|
||||
/// The frame whose port was clicked, or the frame semi-autoflow moved on to.
|
||||
pub frame: ItemId,
|
||||
/// Loaded from the out port (thread after `frame`) or the in port (in front of it).
|
||||
pub out: bool,
|
||||
/// Semi-autoflow: the frame just threaded on that side takes over once the document has it.
|
||||
pub advance: bool,
|
||||
/// Semi-autoflow: the frame expected to join on this side once the click's command has run.
|
||||
/// The load moves on to it only if the document has it there.
|
||||
pub advance: Option<Joins>,
|
||||
/// The frame semi-autoflow moved on from: loaded again once `frame` is no longer its
|
||||
/// neighbour on this side (undo).
|
||||
pub back: Option<ItemId>,
|
||||
}
|
||||
|
||||
/// The frame a `text.thread` from the loaded cursor adds on the loaded side.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Joins {
|
||||
/// This existing frame.
|
||||
Frame(ItemId),
|
||||
/// A new frame: any frame other than the neighbour there before the click (`None`: none).
|
||||
NewFrameReplacing(Option<ItemId>),
|
||||
}
|
||||
|
||||
/// A click with the loaded cursor: the command, and for `text.thread` the frame it adds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Clicked {
|
||||
pub id: &'static str,
|
||||
pub params: Value,
|
||||
pub joins: Option<Joins>,
|
||||
}
|
||||
|
||||
/// What a click with the loaded cursor does at a point.
|
||||
@@ -112,11 +148,35 @@ pub(crate) enum Target {
|
||||
}
|
||||
|
||||
impl Loaded {
|
||||
/// The loaded frame now (an advancing load moves on to the frame threaded last), or None
|
||||
/// when that frame is gone (undo, delete).
|
||||
pub fn new(frame: ItemId, out: bool) -> Self {
|
||||
Self { frame, out, advance: None, back: None }
|
||||
}
|
||||
|
||||
/// The loaded frame now, or None when that frame is gone (undo, delete). A semi-autoflow load
|
||||
/// moves on to the frame its click added once the document has it there, and goes back to
|
||||
/// the frame it came from once an undo takes that frame out again.
|
||||
pub fn resolve(self, doc: &Document) -> Option<Loaded> {
|
||||
let frame = if self.advance { self.neighbour(doc).unwrap_or(self.frame) } else { self.frame };
|
||||
doc.item(frame).filter(|i| i.is_text_frame()).map(|_| Loaded { frame, out: self.out, advance: false })
|
||||
let usable = |id: ItemId| doc.item(id).is_some_and(Item::is_text_frame);
|
||||
let mut l = self;
|
||||
if let Some(b) = l.back
|
||||
&& usable(b)
|
||||
&& Loaded::new(b, l.out).neighbour(doc) != Some(l.frame)
|
||||
{
|
||||
l = Loaded::new(b, l.out);
|
||||
}
|
||||
if let Some(joins) = l.advance.take()
|
||||
&& let Some(n) = l.neighbour(doc)
|
||||
&& usable(n)
|
||||
{
|
||||
let arrived = match joins {
|
||||
Joins::Frame(f) => n == f,
|
||||
Joins::NewFrameReplacing(before) => Some(n) != before,
|
||||
};
|
||||
if arrived {
|
||||
l = Loaded { frame: n, out: l.out, advance: None, back: Some(l.frame) };
|
||||
}
|
||||
}
|
||||
usable(l.frame).then_some(l)
|
||||
}
|
||||
|
||||
/// The next frame from an out port, the previous one from an in port.
|
||||
@@ -155,22 +215,22 @@ impl Loaded {
|
||||
}
|
||||
|
||||
/// The command for a click at `p`, or a drag from `start` to `p` (canvas). None: nothing to do.
|
||||
pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option<(&'static str, Value)> {
|
||||
let dragged = (p - start).hypot() >= cx.tol(3.0);
|
||||
let (pair, new_rect) = if dragged {
|
||||
let (sr, a) = cx.layout.spread_at(start)?;
|
||||
(self.pair(cx.doc, None), Some((sr, Rect::from_points(a, cx.layout.to_spread(sr, p)))))
|
||||
pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option<Clicked> {
|
||||
let new_frame = Joins::NewFrameReplacing(self.neighbour(cx.doc));
|
||||
let (pair, new_rect, joins) = if is_drag(cx, start, p) {
|
||||
let (sr, r, _) = drag_frame(cx, start, p)?;
|
||||
(self.pair(cx.doc, None), Some((sr, r)), new_frame)
|
||||
} else {
|
||||
match self.target(cx, p) {
|
||||
Target::Unthread => {
|
||||
let side = if self.out { "after" } else { "before" };
|
||||
return Some(("text.unthread", json!({"frame": self.frame.0, "side": side})));
|
||||
return Some(Clicked { id: "text.unthread", params: json!({"frame": self.frame.0, "side": side}), joins: None });
|
||||
}
|
||||
Target::Invalid => return None,
|
||||
Target::Thread(t) => (self.pair(cx.doc, Some(t)), None),
|
||||
Target::Thread(t) => (self.pair(cx.doc, Some(t)), None, Joins::Frame(t)),
|
||||
Target::Empty => {
|
||||
let (sr, sp) = cx.layout.spread_at(p)?;
|
||||
(self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?)))
|
||||
(self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?)), new_frame)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -188,7 +248,7 @@ impl Loaded {
|
||||
if mods.shift {
|
||||
params.insert("autoflow".into(), json!(true));
|
||||
}
|
||||
Some(("text.thread", Value::Object(params)))
|
||||
Some(Clicked { id: "text.thread", params: Value::Object(params), joins: Some(joins) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1440,11 +1440,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
||||
{
|
||||
// Count presses in a row here: egui reports double and triple clicks only on release and
|
||||
// stops at three, but a press must know (word, line, paragraph, story).
|
||||
// Like egui, the next press must come within the double-click delay of the last release.
|
||||
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
|
||||
let (released_at, last_pos, last_n): (f64, Pos2, u8) = ui.data(|d| d.get_temp(clicks_id)).unwrap_or((f64::NEG_INFINITY, o, 0));
|
||||
let n = if now - released_at <= delay && (o - last_pos).length() <= dist { last_n.saturating_add(1) } else { 1 };
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, (f64::NEG_INFINITY, o, n)));
|
||||
let n = press_count(ui.data(|d| d.get_temp(clicks_id)), o, now, delay, dist);
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, Clicks { at: f64::NEG_INFINITY, pos: o, n }));
|
||||
events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n });
|
||||
down = true;
|
||||
}
|
||||
@@ -1476,8 +1474,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
||||
ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id));
|
||||
}
|
||||
if down && released {
|
||||
if let Some((_, at, n)) = ui.data(|d| d.get_temp::<(f64, Pos2, u8)>(clicks_id)) {
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, (now, at, n)));
|
||||
if let Some(c) = ui.data(|d| d.get_temp::<Clicks>(clicks_id)) {
|
||||
let dist = ui.ctx().options(|o| o.input_options.max_click_dist);
|
||||
ui.data_mut(|d| d.insert_temp(clicks_id, release_clicks(c, latest.unwrap_or(c.pos), now, dist)));
|
||||
}
|
||||
let p = latest.unwrap_or(rect.center());
|
||||
events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 1 });
|
||||
@@ -1799,6 +1798,31 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec
|
||||
true
|
||||
}
|
||||
|
||||
/// Presses in a row on the canvas: the last press's count, and where and when it was released
|
||||
/// (`at` is −∞ while the button is down).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Clicks {
|
||||
at: f64,
|
||||
pos: Pos2,
|
||||
n: u8,
|
||||
}
|
||||
|
||||
/// The count of a press at `pos` at time `now`: one more than the last press when it comes within
|
||||
/// the double-click delay of the last release and near where that release was, else 1.
|
||||
fn press_count(last: Option<Clicks>, pos: Pos2, now: f64, delay: f64, dist: f32) -> u8 {
|
||||
match last {
|
||||
Some(c) if now - c.at <= delay && (pos - c.pos).length() <= dist => c.n.saturating_add(1),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The record a release at `pos` leaves: a release away from its press ended a drag, which is
|
||||
/// not a click, so the next press counts from one.
|
||||
fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks {
|
||||
let n = if (pos - press.pos).length() <= dist { press.n } else { 0 };
|
||||
Clicks { at: now, pos, n }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use designcraft_geom::Unit;
|
||||
@@ -1806,6 +1830,23 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_press_after_a_drag_is_not_a_double_click() {
|
||||
let (delay, dist) = (0.3, 6.0);
|
||||
let press = |last, x: f32, now| press_count(last, pos2(x, 0.0), now, delay, dist);
|
||||
// Click, click: a double click.
|
||||
let n = press(None, 0.0, 0.0);
|
||||
let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n }, pos2(0.0, 0.0), 0.1, dist);
|
||||
assert_eq!(press(Some(up), 1.0, 0.2), 2);
|
||||
// A drag from 0 to 100, then a fast press where it started or where it ended: a single click.
|
||||
let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(100.0, 0.0), 0.1, dist);
|
||||
assert_eq!(press(Some(up), 0.0, 0.2), 1);
|
||||
assert_eq!(press(Some(up), 100.0, 0.2), 1);
|
||||
// Too slow: a single click.
|
||||
let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(0.0, 0.0), 0.1, dist);
|
||||
assert_eq!(press(Some(up), 0.0, 1.0), 1);
|
||||
}
|
||||
|
||||
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
|
||||
+24
-16
@@ -1,6 +1,6 @@
|
||||
# Where DesignCraft falls short of InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
|
||||
InDesign professional from doing real work. This is the work list: agents pick from the top.
|
||||
@@ -14,20 +14,14 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
|
||||
## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate))
|
||||
|
||||
### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER**
|
||||
- **Missing:** threading a story from one frame to another. The canvas draws in and out ports
|
||||
(`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text
|
||||
cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking
|
||||
a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP
|
||||
(`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the
|
||||
sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which
|
||||
users report adding pages they can't delete (#165).
|
||||
- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's
|
||||
missing everywhere).
|
||||
- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next
|
||||
that the designer drew. That is InDesign's core text workflow.
|
||||
- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and
|
||||
click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7).
|
||||
### 0. Primary Text Frame autoflow adds pages users can't delete (F) · **ALPHA BLOCKER**
|
||||
- **Missing:** fixes to Smart Text Reflow on the Primary Text Frame, which users report adding
|
||||
pages they can't delete (#165). Threading by hand works (ports, loaded text cursor, `text.thread` / `text.unthread` /
|
||||
`text.removeFromThread`).
|
||||
- **Evidence:** #165.
|
||||
- **Impact:** a long story in a Primary Text Frame document can leave pages the designer can't
|
||||
remove.
|
||||
- **Estimate:** 5–7 h.
|
||||
- **Doc:** [ui-parity.md](ui-parity.md)
|
||||
|
||||
## Blocking (beta can't be declared while these are open)
|
||||
@@ -35,7 +29,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
### 1. Basic editing fails for many users (U, S)
|
||||
- **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF
|
||||
paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't
|
||||
open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields
|
||||
open (#238, #187, #160, #312); double-click word selection in fields
|
||||
(#189); character formatting set at a caret is discarded (#289).
|
||||
- **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds.
|
||||
- **Impact:** a layout artist can't finish the simplest job. This dominates the
|
||||
@@ -189,6 +183,19 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
parallel story composition.
|
||||
- **Estimate:** 40–80 h.
|
||||
|
||||
### 26. Autoflow fills the following pages before it adds pages (F)
|
||||
- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the
|
||||
story's last frame, one margin-sized frame each. InDesign first threads a frame per column on
|
||||
the existing pages that follow, then adds pages at the end of the document.
|
||||
- **Impact:** autoflowing into a document that already has pages inserts new pages in the
|
||||
middle of it instead of using the empty pages.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### 27. Loaded text cursor can't thread into a frame inside a group (F, U)
|
||||
- **Missing:** a click with the loaded text cursor on a text frame inside a group hits the group,
|
||||
so the cursor shows it can't thread there.
|
||||
- **Estimate:** 2–4 h.
|
||||
|
||||
## Lower
|
||||
|
||||
### 21. Localization breadth (L)
|
||||
@@ -224,5 +231,6 @@ Cue. These are excluded from every percentage.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Gap 0 shrunk to the Primary Text Frame page fixes (#165): text threads by hand; gaps added for autoflow onto existing pages and threading into grouped frames |
|
||||
| 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) |
|
||||
| 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 |
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
# DesignCraft roadmap (detail)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in
|
||||
[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are
|
||||
@@ -18,7 +18,7 @@ but are next in line.
|
||||
| Core workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 |
|
||||
| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 |
|
||||
| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 |
|
||||
| Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 |
|
||||
| Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 |
|
||||
| Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 |
|
||||
@@ -27,16 +27,15 @@ but are next in line.
|
||||
Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part
|
||||
of the alpha gate: IDML works both ways today and INDD is gap 2.
|
||||
|
||||
**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above
|
||||
**Distance to alpha:** ~5–7 h (the threading row: Primary Text Frame page fixes). Ready for real work (~47%) is already above
|
||||
the ~40% alpha bar.
|
||||
|
||||
## Current focus
|
||||
|
||||
In order. Each closes a beta blocker from [gaps.md](gaps.md).
|
||||
|
||||
0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands,
|
||||
out-port click with a loaded text cursor (click a frame or draw one), unthread by
|
||||
double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h.
|
||||
0. **Alpha gate: Primary Text Frame page fixes** (gap 0): autoflow adds pages users can't
|
||||
delete (#165). 5–7 h.
|
||||
1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on
|
||||
macOS, Windows and Linux. 30–50 h.
|
||||
2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux;
|
||||
@@ -58,7 +57,7 @@ set exists; depth gaps are in gaps.md.
|
||||
|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ |
|
||||
| M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) |
|
||||
| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) |
|
||||
| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool, composer and threading ✅; Primary Text Frame pages (#165, alpha blocker); caret formatting (#289); no IME (#322) |
|
||||
| M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) |
|
||||
| M4 | Color & effects | ✅ core; colour management depth |
|
||||
| M5 | Graphics & links | ✅ core; EPS preview only |
|
||||
@@ -71,7 +70,7 @@ set exists; depth gaps are in gaps.md.
|
||||
| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images |
|
||||
| M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ |
|
||||
| M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started |
|
||||
| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate |
|
||||
| **A1** | **Alpha** (core-workflow gate) | blocked by the Primary Text Frame page fixes (#165); see Alpha gate |
|
||||
| **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below |
|
||||
|
||||
## To beta
|
||||
@@ -110,6 +109,7 @@ output intent · EPS place") is done except Books depth and EPS (preview only).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Threading row: threading by hand works; the gate waits on the Primary Text Frame page fixes (#165), 5–7 h |
|
||||
| 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points |
|
||||
| 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 |
|
||||
| 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DesignCraft parity with Adobe InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
|
||||
|
||||
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
|
||||
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
|
||||
@@ -16,7 +16,7 @@ presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask p
|
||||
| Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) |
|
||||
| Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated |
|
||||
| Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated |
|
||||
| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) |
|
||||
| Stage | **pre-alpha** (fails the core-workflow gate: Primary Text Frame adds pages users can't delete, #165); ~5–7 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) |
|
||||
|
||||
## How breadth was measured
|
||||
|
||||
@@ -77,7 +77,7 @@ standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2
|
||||
today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That
|
||||
multiplicative step was dropped to match the standard (discounts belong only to the mainstream
|
||||
and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows,
|
||||
and the missing manual threading is what holds the stage at pre-alpha through the gate.
|
||||
and the Primary Text Frame page fixes (#165) are what hold the stage at pre-alpha through the gate.
|
||||
|
||||
## Mainstream practitioner and essentials user
|
||||
|
||||
@@ -108,7 +108,7 @@ twice.
|
||||
| Area | Weight | Depth | Evidence |
|
||||
|---|---|---|---|
|
||||
| Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table |
|
||||
| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only |
|
||||
| Text frames: type, select, edit, thread, autoflow | 15% | 45% | threading by hand, autoflow; Primary Text Frame adds pages users can't delete (#165) |
|
||||
| Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange |
|
||||
| Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply |
|
||||
| Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction |
|
||||
@@ -211,7 +211,7 @@ table is the one summed.
|
||||
| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS |
|
||||
| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth |
|
||||
| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place |
|
||||
| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids |
|
||||
| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids |
|
||||
| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) |
|
||||
| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth |
|
||||
| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer |
|
||||
@@ -443,6 +443,7 @@ Task notes that headed ROADMAP.md:
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged |
|
||||
| 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated |
|
||||
|
||||
Reference in New Issue
Block a user