mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
1 parent
e433c77b55
commit
fcef655ba8
13 files changed
+370
-5
No files matched your search
Generated
+2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user