diff --git a/ROADMAP.md b/ROADMAP.md index ec17b71..77743c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (CSV or JSON rows, `<>` placeholders), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. @@ -208,7 +208,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) | | M9 | Performance (MT composition, tiles) | in progress (glyph path cache, culling, parallel compose 6.7× faster, perf harness) | | M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | -| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge, step & repeat, snippets) | +| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge phase 1, step & repeat, snippets) | | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | | M13 | Automation (scripts, batch) | | | M14 | 1.0 polish & packaging | | diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 2fb3a1f..101a5f5 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -89,65 +89,78 @@ fn save_prefs(app: &DesignApp) { } } +/// One file-type row in an open dialog. `open_filters` is what `pick_open` applies. +struct OpenFilter { + name: &'static str, + extensions: &'static [&'static str], +} + +fn open_filters(purpose: &str) -> &'static [OpenFilter] { + match purpose { + "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], + "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], + "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], + "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], + "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], + "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], + "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], + "place" => &[ + OpenFilter { + name: "Graphics and text", + extensions: &[ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "psd", + "svg", + "pdf", + "ai", + "eps", + "txt", + "docx", + "rtf", + "md", + "xlsx", + "idml", + "designcraft", + "mp4", + "m4v", + "mov", + "webm", + "mp3", + "m4a", + "wav", + "ogg", + ], + }, + OpenFilter { + name: "Graphics", + extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], + }, + OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, + OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, + ], + _ => &[ + OpenFilter { name: "DesignCraft or IDML", extensions: &["designcraft", "idml"] }, + OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, + OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, + ], + } +} + fn services() -> Services { Services { pick_open: Some(Box::new(|purpose: &str| { - let d = rfd::FileDialog::new(); - let d = if purpose == "swatches" { - d.add_filter("Swatch Exchange (ASE)", &["ase"]) - } else if purpose == "script" { - d.add_filter("Script", &["dcscript", "txt", "json"]) - } else if purpose == "icc" { - d.add_filter("ICC profile", &["icc", "icm"]) - } else if purpose == "book" { - d.add_filter("Book", &["dcbook"]) - } else if purpose == "xml" { - d.add_filter("XML", &["xml"]) - } else if purpose == "library" { - d.add_filter("Object Library", &["dclib"]) - } else if purpose == "place" { - d.add_filter( - "Graphics and text", - &[ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "psd", - "svg", - "pdf", - "ai", - "eps", - "txt", - "docx", - "rtf", - "md", - "xlsx", - "idml", - "designcraft", - "mp4", - "m4v", - "mov", - "webm", - "mp3", - "m4a", - "wav", - "ogg", - ], - ) - .add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"]) - .add_filter("Text (Word, RTF, plain, Excel)", &["docx", "rtf", "txt", "md", "xlsx"]) - .add_filter("Video and sound", &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"]) - } else { - d.add_filter("DesignCraft or IDML", &["designcraft", "idml"]) - .add_filter("DesignCraft", &["designcraft"]) - .add_filter("InDesign Markup (IDML)", &["idml"]) - }; - d.pick_file().map(|p| p.to_string_lossy().to_string()) + let mut dialog = rfd::FileDialog::new(); + for filter in open_filters(purpose) { + dialog = dialog.add_filter(filter.name, filter.extensions); + } + dialog.pick_file().map(|p| p.to_string_lossy().to_string()) })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), @@ -244,3 +257,21 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + #[test] + fn data_merge_open_dialog_lists_table_extensions() { + let filters = super::open_filters("dataMerge"); + let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { + assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); + } + assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); + let place = super::open_filters("place"); + assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); + assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); + let documents = super::open_filters(""); + assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 9b43557..fdf20e6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -45,6 +45,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("transitions", "Page Transitions", "panel-pages"), ("trackChanges", "Track Changes", "panel-articles"), ("scripts", "Scripts", "panel-library"), + ("dataMerge", "Data Merge", "panel-datamerge"), ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -200,6 +201,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "transitions" => panels::interactive::transitions(app, ui), "trackChanges" => panels::interactive::track_changes(app, ui), "scripts" => panels::library::scripts(app, ui), + "dataMerge" => panels::datamerge::show(app, ui), "table" => panels::table::show(app, ui), _ => panels::properties::info_panel(app, ui), } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75..56a60c3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -770,7 +770,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Create Frame", ["Create Frame", "Create Frame", "Create Frame", "Create Frame", "创建框架"]), ( "Create Merged Document…", - ["Create Merged Document…", "Create Merged Document…", "Create Merged Document…", "Create Merged Document…", "创建合并文档…"], + [ + "Zusammengeführtes Dokument erstellen …", + "Créer un document fusionné…", + "Crear documento combinado…", + "結合ドキュメントを作成…", + "创建合并文档…", + ], ), ( "Cross-Reference Formats", @@ -2619,6 +2625,54 @@ const TABLE: &[(&str, [&str; 5])] = &[ "文档中的字体:{count} 缺失:{missing}", ], ), + ("No data source", ["Keine Datenquelle", "Aucune source de données", "No hay fuente de datos", "データソースがありません", "没有数据源"]), + ( + "Select Data Source…", + ["Datenquelle auswählen …", "Choisir une source de données…", "Seleccionar fuente de datos…", "データソースを選択…", "选择数据源…"], + ), + ("Data Merge", ["Datenzusammenführung", "Fusion de données", "Combinación de datos", "データ結合", "数据合并"]), + ("Preview", ["Vorschau", "Aperçu", "Previsualización", "プレビュー", "预览"]), + ("Previous", ["Zurück", "Précédent", "Anterior", "前へ", "上一条"]), + ("Next", ["Weiter", "Suivant", "Siguiente", "次へ", "下一条"]), + ("Record", ["Datensatz", "Enregistrement", "Registro", "レコード", "记录"]), + ("Merge options", ["Optionen für die Zusammenführung", "Options de fusion", "Opciones de combinación", "結合オプション", "合并选项"]), + ("All records", ["Alle Datensätze", "Tous les enregistrements", "Todos los registros", "すべてのレコード", "全部记录"]), + ("One record", ["Ein Datensatz", "Un enregistrement", "Un registro", "1件のレコード", "一条记录"]), + ("Range", ["Bereich", "Plage", "Intervalo", "範囲", "范围"]), + ("Records per page", ["Datensätze pro Seite", "Enregistrements par page", "Registros por página", "ページあたりのレコード", "每页记录数"]), + ("Single record", ["Einzelner Datensatz", "Un seul enregistrement", "Un solo registro", "単一レコード", "单条记录"]), + ("Multiple records", ["Mehrere Datensätze", "Plusieurs enregistrements", "Varios registros", "複数レコード", "多条记录"]), + ("Rows first", ["Zeilen zuerst", "Lignes d'abord", "Filas primero", "行を優先", "先行后列"]), + ("Columns first", ["Spalten zuerst", "Colonnes d'abord", "Columnas primero", "列を優先", "先列后行"]), + ("Column spacing", ["Spaltenabstand", "Espacement des colonnes", "Espacio entre columnas", "列の間隔", "列间距"]), + ("Row spacing", ["Zeilenabstand", "Espacement des lignes", "Espacio entre filas", "行の間隔", "行间距"]), + ( + "Fit proportionally", + ["Proportional einpassen", "Ajuster proportionnellement", "Encajar proporcionalmente", "縦横比を保持して合わせる", "按比例适合"], + ), + ( + "Fill proportionally", + ["Proportional füllen", "Remplir proportionnellement", "Llenar proporcionalmente", "縦横比を保持して塗りつぶす", "按比例填充"], + ), + ( + "Fit content to frame", + ["Inhalt an Rahmen anpassen", "Ajuster le contenu au bloc", "Ajustar el contenido al marco", "内容をフレームに合わせる", "内容适合框架"], + ), + ("Center in frame", ["Im Rahmen zentrieren", "Centrer dans le bloc", "Centrar en el marco", "フレーム内で中央", "在框架中居中"]), + ("Link images", ["Bilder verknüpfen", "Lier les images", "Vincular imágenes", "画像をリンク", "链接图像"]), + ("Limit", ["Obergrenze", "Limite", "Límite", "上限", "上限"]), + ("Bind to frame", ["An Rahmen binden", "Lier au bloc", "Vincular al marco", "フレームにバインド", "绑定到框架"]), + ("Insert field", ["Feld einfügen", "Insérer le champ", "Insertar campo", "フィールドを挿入", "插入字段"]), + ( + "Pass csv, rows, or bytes from the control channel.", + [ + "csv, rows oder bytes über den Steuerkanal übergeben.", + "Transmettez csv, rows ou bytes par le canal de contrôle.", + "Pase csv, rows o bytes por el canal de control.", + "制御チャネルから csv、rows または bytes を渡します。", + "请通过控制通道传入 csv、rows 或 bytes。", + ], + ), ]; /// Interface direction; independent of document binding and paragraph direction. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 00169d4..ce7cb75 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -762,6 +762,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } "format-container" => pen.rect(4.0, 4.0, 16.0, 16.0), "format-text" => pen.text(10.0, 10.5, "T", 15.0), + "panel-datamerge" => { + pen.w = 1.2; + pen.rect(3.0, 2.0, 17.0, 18.0); + pen.line(&[(3.0, 6.0), (17.0, 6.0)]); + pen.line(&[(6.5, 10.0), (8.5, 13.5), (6.5, 13.5)]); + pen.line(&[(13.5, 10.0), (11.5, 13.5), (13.5, 13.5)]); + pen.line(&[(9.2, 15.0), (11.0, 9.0)]); + } "screen-mode" => { pen.rect(3.0, 4.0, 17.0, 16.0); pen.line(&[(3.0, 7.0), (17.0, 7.0)]); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f19..48da69d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -648,7 +648,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Book|{\"panel\": \"book\"}", "<", ">Utilities", - "cmd:data.merge", + "ui:window.panel|Data Merge|{\"panel\":\"dataMerge\"}", "<", ">Output", "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs new file mode 100644 index 0000000..f0ae772 --- /dev/null +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -0,0 +1,234 @@ +//! Data Merge panel. It reads the linked source and calls commands. Opening the panel does not merge. + +use egui::{Color32, Sense, Stroke, vec2}; +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::theme::{Tokens, semibold}; + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let snapshot = app.session.active().map(|st| { + let source = st.doc.data_merge.sources.first(); + (source.map(|s| s.name.clone()), source.map(|s| s.rows.len()).unwrap_or(0), st.doc.data_merge.options.clone(), st.preview_record.unwrap_or(0)) + }); + let Some((source_name, record_count, options, preview_record)) = snapshot else { + dim(ui, &t, &lang, "No data source"); + return; + }; + let fields = app.session.execute("data.fields", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + + ui.horizontal(|ui| { + let label = source_name.clone().unwrap_or_else(|| tr(&lang, "No data source").to_string()); + let color = if source_name.is_some() { t.text_strong } else { t.text_dim }; + crate::rtl::label(ui, egui::RichText::new(label).color(color)); + }); + ui.horizontal(|ui| { + if ui.button(w(ui, &lang, "Select Data Source…")).clicked() { + select_source(app); + } + let has_source = source_name.is_some(); + if ui.add_enabled(has_source, egui::Button::new(w(ui, &lang, "Update"))).clicked() { + let _ = app.run("data.source.update", json!({})); + } + if ui.add_enabled(has_source, egui::Button::new(w(ui, &lang, "Remove"))).clicked() { + let _ = app.run("data.source.remove", json!({})); + } + }); + ui.separator(); + + if fields.is_empty() { + dim(ui, &t, &lang, "Pass csv, rows, or bytes from the control channel."); + } + for field in &fields { + let name = field["name"].as_str().unwrap_or("").to_string(); + let kind = field["kind"].as_str().unwrap_or("text").to_string(); + let uses = field["uses"].as_u64().unwrap_or(0); + ui.horizontal(|ui| { + kind_icon(ui, &kind, t.icon); + crate::rtl::label(ui, egui::RichText::new(name.clone()).color(t.text)); + crate::rtl::label(ui, egui::RichText::new(uses.to_string()).size(11.0).color(t.text_dim)); + let bind = kind == "image" || kind == "qr"; + let caption = if bind { "Bind to frame" } else { "Insert field" }; + if ui.small_button(w(ui, &lang, caption)).clicked() { + let params = if bind { json!({"field": name}) } else { json!({"field": name, "role": "text"}) }; + let _ = app.run("data.placeholder.add", params); + } + }); + } + ui.separator(); + + let mut preview_on = preview_record > 0; + if ui.checkbox(&mut preview_on, w(ui, &lang, "Preview")).changed() { + if preview_on { + let _ = app.run("data.preview", json!({"record": 1})); + } else { + let _ = app.run("data.preview.stop", json!({})); + } + } + let max_record = (record_count as u32).max(1); + let shown = if preview_record == 0 { 1 } else { preview_record.min(max_record) }; + let mut jump = None; + ui.add_enabled_ui(preview_record > 0, |ui| { + ui.horizontal(|ui| { + if ui.small_button(w(ui, &lang, "First")).clicked() { + jump = Some(1); + } + if ui.small_button(w(ui, &lang, "Previous")).clicked() { + jump = Some(shown.saturating_sub(1).max(1)); + } + let mut rec = shown; + if ui.add(egui::DragValue::new(&mut rec).range(1..=max_record)).changed() { + jump = Some(rec); + } + crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Record")).color(t.text)); + if ui.small_button(w(ui, &lang, "Next")).clicked() { + jump = Some(shown.saturating_add(1).min(max_record)); + } + if ui.small_button(w(ui, &lang, "Last")).clicked() { + jump = Some(max_record); + } + }); + }); + if let Some(rec) = jump.filter(|rec| *rec != preview_record) { + let _ = app.run("data.preview", json!({"record": rec})); + } + ui.separator(); + + crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Merge options")).strong().color(t.text_strong)); + if let Some(records) = choice(ui, "dm_records", &options.records, &[("all", "All records"), ("one", "One record"), ("range", "Range")], &lang) { + let _ = app.run("data.options", json!({"records": records})); + } + if options.records == "one" { + let mut one = options.one.max(1); + if ui.add(egui::DragValue::new(&mut one).range(1..=max_record).prefix(format!("{} ", tr(&lang, "Record")))).changed() { + let _ = app.run("data.options", json!({"one": one})); + } + } + if options.records == "range" { + let mut range = options.range.clone(); + if ui.text_edit_singleline(&mut range).changed() { + let _ = app.run("data.options", json!({"range": range})); + } + } + crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Records per page")).color(t.text)); + if let Some(per_page) = choice(ui, "dm_per_page", &options.per_page, &[("single", "Single record"), ("multiple", "Multiple records")], &lang) { + let _ = app.run("data.options", json!({"perPage": per_page})); + } + if options.per_page == "multiple" { + if let Some(arrange) = choice(ui, "dm_arrange", &options.arrange, &[("rows", "Rows first"), ("columns", "Columns first")], &lang) { + let _ = app.run("data.options", json!({"arrange": arrange})); + } + let labels = ["Top", "Right", "Bottom", "Left"]; + let mut insets = options.insets; + let mut insets_changed = false; + for (i, label) in labels.iter().enumerate() { + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr(&lang, label)).color(t.text)); + if ui.add(egui::DragValue::new(&mut insets[i]).speed(1.0)).changed() { + insets_changed = true; + } + }); + } + if insets_changed { + let _ = app.run("data.options", json!({"insets": insets})); + } + let mut column_spacing = options.column_spacing; + if ui.add(egui::DragValue::new(&mut column_spacing).speed(1.0).prefix(format!("{} ", tr(&lang, "Column spacing")))).changed() { + let _ = app.run("data.options", json!({"columnSpacing": column_spacing})); + } + let mut row_spacing = options.row_spacing; + if ui.add(egui::DragValue::new(&mut row_spacing).speed(1.0).prefix(format!("{} ", tr(&lang, "Row spacing")))).changed() { + let _ = app.run("data.options", json!({"rowSpacing": row_spacing})); + } + } + if let Some(fitting) = choice( + ui, + "dm_fitting", + &options.fitting, + &[ + ("fitProportionally", "Fit proportionally"), + ("fillProportionally", "Fill proportionally"), + ("fitContentToFrame", "Fit content to frame"), + ("none", "None"), + ], + &lang, + ) { + let _ = app.run("data.options", json!({"fitting": fitting})); + } + let mut center = options.center; + if ui.checkbox(&mut center, w(ui, &lang, "Center in frame")).changed() { + let _ = app.run("data.options", json!({"center": center})); + } + let mut link_images = options.link_images; + if ui.checkbox(&mut link_images, w(ui, &lang, "Link images")).changed() { + let _ = app.run("data.options", json!({"linkImages": link_images})); + } + let mut limit = options.limit.unwrap_or(0); + if ui.add(egui::DragValue::new(&mut limit).range(0..=100_000).prefix(format!("{} ", tr(&lang, "Limit")))).changed() { + let value = if limit == 0 { Value::Null } else { json!(limit) }; + let _ = app.run("data.options", json!({"limit": value})); + } + ui.add_space(6.0); + if ui.button(w(ui, &lang, "Create Merged Document…")).clicked() { + let _ = app.run("data.merge", json!({})); + } +} + +fn select_source(app: &mut DesignApp) { + let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dataMerge")) else { + app.status(tr(&app.ui.language, "Pass csv, rows, or bytes from the control channel.")); + return; + }; + let _ = app.run("data.source.select", json!({"path": path})); +} + +fn choice(ui: &mut egui::Ui, id: &str, current: &str, options: &[(&str, &str)], lang: &str) -> Option { + let label = options.iter().find(|(value, _)| *value == current).map(|(_, text)| tr(lang, text)).unwrap_or(current); + let mut picked = None; + egui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { + for (value, text) in options { + if ui.selectable_label(*value == current, tr(lang, text)).clicked() { + picked = Some((*value).to_string()); + } + } + }); + picked.filter(|value| value != current) +} + +fn kind_icon(ui: &mut egui::Ui, kind: &str, color: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + let painter = ui.painter(); + match kind { + "image" => { + painter.rect_stroke(rect.shrink(1.5), 1.0, Stroke::new(1.0, color), egui::StrokeKind::Middle); + painter.circle_stroke(rect.center() + vec2(-3.0, -2.0), 1.5, Stroke::new(1.0, color)); + let base = rect.left_bottom() + vec2(2.0, -3.0); + painter.line_segment([base, rect.center() + vec2(-1.0, 2.0)], Stroke::new(1.0, color)); + painter.line_segment([rect.center() + vec2(-1.0, 2.0), rect.right_bottom() + vec2(-2.0, -5.0)], Stroke::new(1.0, color)); + } + "qr" => { + let origin = rect.min + vec2(2.0, 2.0); + for (x, y) in [(0, 0), (2, 0), (0, 2), (2, 2), (1, 1)] { + let at = origin + vec2(x as f32 * 4.0, y as f32 * 4.0); + painter.rect_filled(egui::Rect::from_min_size(at, vec2(3.0, 3.0)), 0.0, color); + } + } + _ => { + painter.text(rect.center(), egui::Align2::CENTER_CENTER, "T", semibold(13.0), color); + } + } +} + +fn dim(ui: &mut egui::Ui, t: &Tokens, lang: &str, key: &str) { + crate::rtl::label(ui, egui::RichText::new(tr(lang, key)).size(11.0).color(t.text_dim)); +} + +fn tr<'a>(lang: &str, s: &'a str) -> &'a str { + crate::i18n::tr(lang, s) +} + +fn w(ui: &egui::Ui, lang: &str, s: &str) -> egui::WidgetText { + crate::rtl::widget(ui, tr(lang, s)) +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0ef1256..1b382d5 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,7 @@ //! Panels and shared panel helpers. pub mod conditions; +pub mod datamerge; pub mod glyphs; pub mod interactive; pub mod layers;