diff --git a/Cargo.lock b/Cargo.lock index c5ffc09c5..d24f0968b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1009,6 +1009,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "unicode-segmentation", "wasm-bindgen-test", "zip", ] diff --git a/ROADMAP.md b/ROADMAP.md index 28b37ec39..126528b1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-11 · **Last updated:** 2026-10-11 · **Change:** minor (Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); macOS lists fonts that font services and managers activate; shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181)) · **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: Czech (99%), 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** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. 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. @@ -202,6 +208,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-11 | minor | Localized Japanese print, import and table controls; readiness estimates unchanged | | 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 | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index da843b4cb..72e8362cf 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -390,6 +390,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) { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index dc1c4ca66..caa01db6c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,7 @@ thiserror = { workspace = true } log = { workspace = true } quick-xml = { workspace = true } caseless = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 737f4cd34..ec2c5bec3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -81,13 +81,23 @@ fn paste_text(s: &mut Session, text: Option, 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 { + 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 { +/// 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 { 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 { .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 { 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() @@ -289,7 +305,9 @@ pub fn specs() -> Vec { 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")); @@ -431,6 +449,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(); diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 402322bfe..daafc4ca1 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -123,19 +123,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option, 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 { +/// 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 { + 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)?; @@ -143,7 +149,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)?; @@ -229,7 +235,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})) }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 208455ea8..46375773d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1577,6 +1577,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)] diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b1214c452..aa95384c2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -30,12 +30,13 @@ pub fn specs() -> Vec { "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); @@ -366,7 +367,7 @@ fn hit_byte_with( fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { 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))?; @@ -378,33 +379,49 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { 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.bump_revision(); 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. @@ -440,6 +457,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { } 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 @@ -778,12 +796,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 { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -864,6 +876,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { "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; @@ -1566,6 +1579,10 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { }); } } + // No undo step for nothing. + if changed == 0 { + return Err(bad(id, "nothing to change")); + } ok() }); r?; @@ -1866,6 +1883,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> = 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(); @@ -1890,7 +1970,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. @@ -1908,6 +1988,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"); } } @@ -1940,6 +2021,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(); diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index ff360e00a..8cedac277 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -74,6 +74,9 @@ fn thread(s: &mut Session, p: &Value) -> Result { s.edit(|d, sel| { let mut new_frame = || -> Result { 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 { 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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4b539034d..ab381b309 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -546,7 +546,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) { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index c2fecf90a..d8a274ef0 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -177,7 +177,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![]) } @@ -193,13 +193,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, @@ -979,7 +978,12 @@ impl Tool for SelectionTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut out = 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 { content: true, exclude, .. } => { let mut v: Vec = exclude.iter().map(|id| Overlay::ContentGhost(*id)).collect(); v.extend(self.guides.iter().cloned()); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 2aef4defe..d586a38ee 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -859,14 +859,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 = 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] diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs index 7e6500fbb..0a1d69f9e 100644 --- a/crates/tools/src/thread.rs +++ b/crates/tools/src/thread.rs @@ -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 [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)> { + 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, + /// The frame semi-autoflow moved on from: loaded again once `frame` is no longer its + /// neighbour on this side (undo). + pub back: Option, +} + +/// 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), +} + +/// 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, } /// 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 { - 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 { + 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) }) } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d3b55f6ac..0e1894643 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1873,11 +1873,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; } @@ -1909,8 +1907,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_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 }); @@ -2425,6 +2424,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, 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; @@ -2432,6 +2456,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| { diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 0dfb687de..8436cbe64 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1027,7 +1027,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "配置した動画または音声を選択します (ファイル › 配置で .mp4、.mov、.mp3、.wav などを配置)。", ), - ("Select an object to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトを選択してください。"), + ("Select an object or text to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトまたはテキストを選択してください。"), ("Select an object to give its fill a gradient.", "塗りにグラデーションを適用するオブジェクトを選択してください。"), ("Select an object to make it a button.", "ボタンにするオブジェクトを選択します。"), ( diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index e676f9eaf..452196b37 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -982,7 +982,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save recovery data every:", "Salvar dados de recuperação a cada:"), ("Saves which bars show and where the panels are.", "Salva quais barras são exibidas e onde ficam os painéis."), ("Search:", "Pesquisar:"), - ("Select an object to color its fill or stroke.", "Selecione um objeto para colorir seu preenchimento ou traçado."), + ("Select an object or text to color its fill or stroke.", "Selecione um objeto ou texto para colorir seu preenchimento ou traçado."), ("Select an object to give its fill a gradient.", "Selecione um objeto para dar um gradiente ao seu preenchimento."), ("Select an object to make it a button.", "Selecione um objeto para transformá-lo em botão."), ("Select an object.", "Selecione um objeto."), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5cf464906..46997d573 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -904,7 +904,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "Виділіть розміщене відео або звук («Файл» › «Розмістити» файл .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "Виділіть об’єкт, щоб змінити колір заливки або обведення."), + ("Select an object or text to color its fill or stroke.", "Виділіть об’єкт або текст, щоб змінити колір заливки або обведення."), ("Select an object to give its fill a gradient.", "Виділіть об’єкт, щоб застосувати градієнт до заливки."), ("Select an object to make it a button.", "Виділіть об’єкт, щоб перетворити його на кнопку."), ( diff --git a/docs/gaps.md b/docs/gaps.md index c94c533a1..44a00d93e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; 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; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler) · **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 @@ -196,6 +190,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) @@ -231,5 +238,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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 53f84a2e6..643d8b8f3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 (#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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9242caf2c..5647dbe53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 | @@ -293,6 +293,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. +- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). - Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). @@ -445,6 +446,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 |