T: nested styles and GREP styles (compose overlays, Paragraph Style Options); ui.dialog.open

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 16:47:52 -04:00
1 parent e433c77b55
commit fcef655ba8
13 files changed
+370 -5

No files matched your search

Generated
+2
View File
@@ -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",
]
+1
View File
@@ -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.
+1
View File
@@ -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]
+15 -2
View File
@@ -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<Glyph> = shaped
.glyphs
.into_iter()
+97
View File
@@ -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<HashMap<String, Option<regex::Regex>>> = RefCell::new(HashMap::new());
}
fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> 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<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, 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());
}
}
+29
View File
@@ -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<usize> = 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) {
+48
View File
@@ -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<NestedStyle> = Vec::new(),
/// GREP styles: a character style for every match of a pattern (applied after nested).
grep_styles: Vec<GrepStyle> = Vec::new(),
grid_align: GridAlign = GridAlign::None,
composer: Composer = Composer::Paragraph,
// Hyphenation
+37
View File
@@ -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");
}
}
+1
View File
@@ -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 }
+7
View File
@@ -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;
+128
View File
@@ -909,6 +909,14 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
/// Paragraph Style Options: sections in a left list (like InDesign), fields on the right.
/// Edited values are stored as `p.<attr>` / `c.<attr>` 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<String> = 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<Value> = 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<Value> = 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:");
+1
View File
@@ -20,6 +20,7 @@ Start the app with `--control <port>` (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 |
+3 -3
View File
@@ -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 |