Data merge: add the panel and the source file dialog

Window, Utilities, Data Merge lists the linked fields and previews a
record without merging. Create Merged Document runs the merge. The
desktop open dialog offers CSV, TSV, tab, text, and Excel files. The
roadmap records this phase of the feature.
This commit is contained in:
Cameron Gilroy 2026-10-07 21:22:06 +11:00
parent b41eba1cba
commit a2078ece67
8 changed files with 390 additions and 60 deletions

View File

@ -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, `<<Field>>` 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 | |

View File

@ -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")));
}
}

View File

@ -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),
}

View File

@ -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.

View File

@ -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)]);

View File

@ -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\"}",

View File

@ -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<String> {
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))
}

View File

@ -1,6 +1,7 @@
//! Panels and shared panel helpers.
pub mod conditions;
pub mod datamerge;
pub mod glyphs;
pub mod interactive;
pub mod layers;