From fcef655ba87b4a17e57e781d3bbbbfe17df8fb25 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:47:52 -0400 Subject: [PATCH] T: nested styles and GREP styles (compose overlays, Paragraph Style Options); ui.dialog.open Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + ROADMAP.md | 1 + crates/compose/Cargo.toml | 1 + crates/compose/src/lib.rs | 17 ++++- crates/compose/src/overlay.rs | 97 ++++++++++++++++++++++++++ crates/compose/src/shape.rs | 29 ++++++++ crates/doc/src/attrs.rs | 48 +++++++++++++ crates/engine/src/cmd/text.rs | 37 ++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/control.rs | 7 ++ crates/ui-egui/src/dialogs.rs | 128 ++++++++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + docs/parity.md | 6 +- 13 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 crates/compose/src/overlay.rs diff --git a/Cargo.lock b/Cargo.lock index ab258324..04d2403b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -907,6 +907,7 @@ dependencies = [ "log", "miniz_oxide 0.8.9", "proptest", + "regex", ] [[package]] @@ -1100,6 +1101,7 @@ dependencies = [ "egui", "egui_kittest", "log", + "regex", "serde", "serde_json", ] diff --git a/ROADMAP.md b/ROADMAP.md index 822d986f..53d49c06 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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`. - 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`). +- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles and their IDML mapping are still to come. - Table and cell styles: cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. IDML mapping is still to come. - Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 7f02d332..90db270e 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -14,6 +14,7 @@ designcraft-color = { workspace = true } designcraft-doc = { workspace = true } designcraft-fonts = { workspace = true } log = { workspace = true } +regex = { workspace = true } miniz_oxide = "0.8" [dev-dependencies] diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 54daf9e4..66089776 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -13,6 +13,7 @@ pub mod breaker; mod cache; pub mod hyphen; mod notes; +mod overlay; pub mod shape; pub mod table; pub mod vars; @@ -420,7 +421,19 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo } let sub_objects = sub.objects.clone(); let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts }; - let mut sp = shape::shape_para(db, &doc.styles, story, pi, prange.clone(), &base_chars, pp.auto_leading, &sub, &mut table); + let mut sp = shape::shape_para( + db, + &doc.styles, + story, + pi, + prange.clone(), + &base_chars, + pp.auto_leading, + &sub, + &mut table, + &pp.nested_styles, + &pp.grep_styles, + ); match pp.list_type { designcraft_doc::ListType::Numbers => { list_counter += 1; @@ -1390,7 +1403,7 @@ fn prepend_label( let mut tmp = Story::new(StoryId(0)); tmp.insert(0, label); let styles = designcraft_doc::Styles::default(); - let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, pp.auto_leading, &SubstCtx::default(), table); + let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, pp.auto_leading, &SubstCtx::default(), table, &[], &[]); let mut pre: Vec = shaped .glyphs .into_iter() diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs new file mode 100644 index 00000000..d763a589 --- /dev/null +++ b/crates/compose/src/overlay.rs @@ -0,0 +1,97 @@ +//! Character styles that paragraphs apply by themselves: nested styles (from the paragraph start +//! through or up to a delimiter) and GREP styles (every match of a pattern). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::ops::Range; + +use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; + +thread_local! { + static PATTERNS: RefCell>> = RefCell::new(HashMap::new()); +} + +fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { + match u { + NestedUntil::Sentences => matches!(c, '.' | '!' | '?') && next.is_none_or(char::is_whitespace), + NestedUntil::Words => c.is_whitespace() && prev.is_some_and(|p| !p.is_whitespace()), + NestedUntil::Characters => !matches!(c, '\n'), + NestedUntil::Letters => c.is_alphabetic(), + NestedUntil::Digits => c.is_numeric(), + NestedUntil::Tab => c == '\t', + NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK, + NestedUntil::EmSpace => c == '\u{2003}', + NestedUntil::EnSpace => c == '\u{2002}', + NestedUntil::Chars(s) => s.contains(c), + } +} + +/// Character-style ranges for the paragraph `text[range]`, in application order (later wins). +pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range, String)> { + let mut out = Vec::new(); + let para = &text[range.clone()]; + // The paragraph's own end mark isn't styled. + let end = range.start + para.trim_end_matches('\n').len(); + let mut pos = range.start; + for ns in nested { + if pos >= end { + break; + } + let mut count = 0; + let mut stop = end; + let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect(); + for (k, &(i, c)) in chars.iter().enumerate() { + let prev = if k > 0 { Some(chars[k - 1].1) } else { None }; + let next = chars.get(k + 1).map(|x| x.1); + if is_event(&ns.until, c, prev, next) { + count += 1; + if count >= ns.count.max(1) { + stop = if ns.through { i + c.len_utf8() } else { i }; + break; + } + } + } + if stop > pos && !ns.style.is_empty() { + out.push((pos..stop, ns.style.clone())); + } + pos = stop; + } + for g in grep { + if g.style.is_empty() || g.pattern.is_empty() { + continue; + } + PATTERNS.with(|p| { + let mut p = p.borrow_mut(); + let re = p.entry(g.pattern.clone()).or_insert_with(|| regex::Regex::new(&g.pattern).ok()); + if let Some(re) = re { + for m in re.find_iter(&text[range.start..end]) { + if !m.is_empty() { + out.push((range.start + m.start()..range.start + m.end(), g.style.clone())); + } + } + } + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_and_grep_ranges() { + let text = "Q: what is 42? A drop of 7 words.\n".to_string(); + let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until }; + // Through the first ':' then up to the second word end. + let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]); + assert_eq!(&text[o[0].0.clone()], "Q:"); + assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word"); + // Digits by GREP. + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]); + let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect(); + assert_eq!(got, ["42", "7"]); + // A bad pattern is ignored. + assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty()); + } +} diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index f8b13923..0ae7259d 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -145,15 +145,44 @@ pub(crate) fn shape_para( auto_leading: f64, sub: &SubstCtx, table: &mut StyleTable<'_>, + nested: &[designcraft_doc::NestedStyle], + grep: &[designcraft_doc::GrepStyle], ) -> ShapedPara { let _ = pi; let mut glyphs = Vec::with_capacity(range.len()); + let overlays = if nested.is_empty() && grep.is_empty() { Vec::new() } else { crate::overlay::overlays(&story.text, range.clone(), nested, grep) }; + // Each run, cut where nested / GREP styles start and end. + let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); for (rr, fmt) in story.runs() { let a = rr.start.max(range.start); let b = rr.end.min(range.end); if a >= b { continue; } + if overlays.is_empty() { + segments.push((a, b, None, fmt)); + continue; + } + let mut cuts: Vec = overlays.iter().flat_map(|(r, _)| [r.start, r.end]).filter(|x| *x > a && *x < b).collect(); + cuts.push(a); + cuts.push(b); + cuts.sort_unstable(); + cuts.dedup(); + for w in cuts.windows(2) { + let top = overlays.iter().rev().find(|(r, _)| r.start <= w[0] && w[0] < r.end).map(|(_, s)| s.as_str()); + segments.push((w[0], w[1], top, fmt)); + } + } + for (a, b, overlay, fmt) in segments { + // A nested / GREP character style sits under the run's own style and overrides. + let base; + let para_chars = match overlay { + Some(st) => { + base = styles.resolve_char(para_chars, &designcraft_doc::CharFormat { style: st.to_string(), over: Default::default() }); + &base + } + None => para_chars, + }; let props = styles.resolve_char(para_chars, fmt); let style = table.intern(&props); if !story.text[a..b].contains(designcraft_doc::FOOTNOTE_REF) { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index e8791a24..20f337ad 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -205,6 +205,50 @@ pub enum Composer { SingleLine, } +/// What a nested style runs to (Drop Caps and Nested Styles). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "chars")] +pub enum NestedUntil { + Sentences, + #[default] + Words, + Characters, + Letters, + Digits, + Tab, + ForcedLineBreak, + EmSpace, + EnSpace, + /// Any of these characters. + Chars(String), +} + +/// A character style applied from the start of the paragraph through (or up to) the `count`-th +/// delimiter; nested styles follow each other. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NestedStyle { + pub style: String, + /// Through (include the delimiter) or up to (stop before it). + pub through: bool, + pub count: u32, + pub until: NestedUntil, +} + +impl Default for NestedStyle { + fn default() -> Self { + NestedStyle { style: String::new(), through: true, count: 1, until: NestedUntil::Words } + } +} + +/// A character style applied to every match of a regular expression in the paragraph. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GrepStyle { + pub style: String, + pub pattern: String, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum GridAlign { @@ -419,6 +463,10 @@ attr_set! { space_after: f64 = 0.0, drop_cap_lines: u32 = 0, drop_cap_chars: u32 = 0, + /// Nested styles, in order from the paragraph start. + nested_styles: Vec = Vec::new(), + /// GREP styles: a character style for every match of a pattern (applied after nested). + grep_styles: Vec = Vec::new(), grid_align: GridAlign = GridAlign::None, composer: Composer = Composer::Paragraph, // Hyphenation diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 266e0b2d..2f00f291 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -796,3 +796,40 @@ mod open_type_tests { assert_ne!(gids(&s), lining); } } + +#[cfg(test)] +mod nested_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn nested_and_grep_styles_colour_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Lead", "chars": {"fill": "C=100 M=0 Y=0 K=0"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Num", "chars": {"fill": "C=0 M=100 Y=0 K=0"}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Opening words then 2026 here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute( + "type.para", + &json!({"attrs": { + "nestedStyles": [{"style": "Lead", "through": true, "count": 2, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Num", "pattern": "\\d+"}] + }}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); + let text = &d.story(designcraft_doc::StoryId(sid)).unwrap().text; + let fill_at = |byte: usize| { + let g = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.byte == byte).unwrap(); + cs.styles[g.style as usize].fill.clone() + }; + assert_eq!(fill_at(0), "C=100 M=0 Y=0 K=0", "nested: first two words"); + assert_eq!(fill_at(text.find("words").unwrap()), "C=100 M=0 Y=0 K=0"); + assert_eq!(fill_at(text.find("then").unwrap()), "[Black]"); + assert_eq!(fill_at(text.find("2026").unwrap()), "C=0 M=100 Y=0 K=0", "GREP: digits"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index cadfdf06..c1e71af4 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,7 @@ designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-tools = { workspace = true } designcraft-engine = { workspace = true } +regex = { workspace = true } egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 73ff5e7a..6c3bf747 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -280,6 +280,13 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } None => err("no dialog open"), }, + "ui.dialog.open" => { + let Some(id) = s("id") else { return err("missing `id`") }; + let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); + let v = serde_json::to_value(&d).unwrap_or_default(); + app.ui.dialog = Some(d); + ok(v) + } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { app.ui.dialog = None; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5b0f9b7c..da1b1ba7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -909,6 +909,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { /// Paragraph Style Options: sections in a left list (like InDesign), fields on the right. /// Edited values are stored as `p.` / `c.` fields and applied on OK. +/// Is `p` a pattern the GREP styles can use? (Checked as the user types.) +fn regex_ok(p: &str) -> Result<(), ()> { + if p.is_empty() { + return Ok(()); + } + regex::Regex::new(p).map(|_| ()).map_err(|_| ()) +} + fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); let Some(st) = app.session.active() else { return }; @@ -924,6 +932,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); ui.set_min_width(560.0); ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); ui.vertical(|ui| { ui.set_width(170.0); for (id, label) in [ @@ -932,6 +943,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ("indents", "Indents and Spacing"), ("hyph", "Hyphenation"), ("justify", "Justification"), + ("nested", "Drop Caps and Nested Styles"), + ("grep", "GREP Style"), ("color", "Character Color"), ] { if ui.selectable_label(d.s("section") == id, label).clicked() { @@ -940,7 +953,122 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } }); ui.separator(); + let cnames: Vec = app.session.active().map(|s| s.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); ui.vertical(|ui| match d.s("section").as_str() { + "nested" => { + ui.label(egui::RichText::new("Nested Styles").font(semibold(12.0))); + let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, ns) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = ns["style"].as_str().unwrap_or("[None]").to_string(); + egui::ComboBox::from_id_salt(("ns_style", i)).selected_text(&style).width(110.0).show_ui(ui, |ui| { + for c in &cnames { + if ui.selectable_label(*c == style, c).clicked() { + ns["style"] = json!(c); + changed = true; + } + } + }); + let through = ns["through"].as_bool().unwrap_or(true); + egui::ComboBox::from_id_salt(("ns_thr", i)).selected_text(if through { "through" } else { "up to" }).width(70.0).show_ui( + ui, + |ui| { + for (v, l) in [(true, "through"), (false, "up to")] { + if ui.selectable_label(v == through, l).clicked() { + ns["through"] = json!(v); + changed = true; + } + } + }, + ); + let mut n = ns["count"].as_u64().unwrap_or(1) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + ns["count"] = json!(n); + changed = true; + } + let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); + egui::ComboBox::from_id_salt(("ns_until", i)).selected_text(&kind).width(100.0).show_ui(ui, |ui| { + for k in + ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "chars"] + { + if ui.selectable_label(k == kind, k).clicked() { + ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; + changed = true; + } + } + }); + if kind == "chars" { + let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { + ns["until"]["chars"] = json!(c); + changed = true; + } + } + if ui.small_button("×").on_hover_text("Delete").clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button("New Nested Style").clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); + changed = true; + } + if changed { + d.fields.insert("p.nestedStyles".into(), Value::Array(list)); + } + } + "grep" => { + ui.label(egui::RichText::new("GREP Styles").font(semibold(12.0))); + let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, g) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.label("Apply Style:"); + let style = g["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("gs_style", i)).selected_text(&style).width(110.0).show_ui(ui, |ui| { + for c in &cnames { + if ui.selectable_label(*c == style, c).clicked() { + g["style"] = json!(c); + changed = true; + } + } + }); + ui.label("To Text:"); + let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); + let bad = regex_ok(&pat).is_err(); + let r = ui.add( + egui::TextEdit::singleline(&mut pat) + .desired_width(150.0) + .text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))), + ); + if r.changed() { + g["pattern"] = json!(pat); + changed = true; + } + if ui.small_button("×").on_hover_text("Delete").clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button("New GREP Style").clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); + changed = true; + } + if changed { + d.fields.insert("p.grepStyles".into(), Value::Array(list)); + } + } "chars" => { egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { ui.label("Font Family:"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8beab07b..0c6f334e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -20,6 +20,7 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | +| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | diff --git a/docs/parity.md b/docs/parity.md index 10bda4bc..f8577b82 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 57 | 24 | 16 | 71% | +| P1 | 97 | 57 | 25 | 15 | 72% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Effects & transparency | 50% | | Placing & links | 78% | | Type & text frames | 82% | -| Typography | 83% | +| Typography | 85% | | Styles | 92% | | Tables | 81% | | Long documents | 58% | @@ -223,7 +223,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Typography | Paragraph Composer (total-fit) | P0 | D | | Typography | Single-line Composer | P0 | D | | Typography | Tabs, leaders, right-indent tab | P0 | D | -| Typography | Nested / line / GREP styles | P1 | M | +| Typography | Nested / line / GREP styles | P1 | P | | Typography | Paragraph rules | P1 | D | | Typography | Paragraph borders & shading | P1 | P | | Typography | Balance ragged lines | P1 | D |