From 17da588d8468e64000fd7ef50d97ccddee2747e8 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:39:44 +0200 Subject: [PATCH] Object Styles panel and the New Object Style / Object Style Options dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Styles › Object Styles, as InDesign's: the selected object's style on top (+ when the object differs from it), the document's object styles (click applies, double-click opens the options, the default graphic and text frame styles marked), Clear Overrides and Create New Style (Alt-click: the dialog), and a menu with New Object Style… and Object Style Options…. The dialog: Style Name; Basic Attributes General (Based On and the Style Settings summary), Fill and Stroke (swatch list, tint, weight) and Stroke & Corner Options (corner shape and size), each included in the style by its checkbox; Effects for Object, with each effect's page from the Effects dialog; Apply Style to Selection for a new style. OK runs style.object.create (then style.object.apply) or style.object.edit. UI command objectStyle.dialog {name?}. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/effects_dialog.rs | 8 +- crates/ui-egui/src/i18n.rs | 39 ++ crates/ui-egui/src/i18n/ar.rs | 12 + crates/ui-egui/src/i18n/cs.rs | 12 + crates/ui-egui/src/i18n/it.rs | 13 + crates/ui-egui/src/i18n/ka.rs | 12 + crates/ui-egui/src/i18n/pl.rs | 12 + crates/ui-egui/src/i18n/pt_br.rs | 13 + crates/ui-egui/src/i18n/ru.rs | 12 + crates/ui-egui/src/i18n/uk.rs | 13 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 14 + crates/ui-egui/src/object_style_dialog.rs | 467 ++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 143 ++++++- 17 files changed, 773 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/object_style_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index b1199ed8f..e4cf4399e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Window › Styles › Object Styles: InDesign's Object Styles panel (the selected object's style, `+` when overridden; a click applies a style, a double-click opens its options; the default graphic and text frame styles marked; Clear Overrides; Create New Style, ⌥/Alt-click for the dialog) and its New Object Style / Object Style Options dialog (Style Name; Basic Attributes Fill, Stroke, Stroke & Corner Options, each included by its checkbox; Effects for Object with the effects' pages; Style Settings; Apply Style to Selection). UI command `objectStyle.dialog`. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-21** · Object styles carry Corner Options (`corners`), taken from the selection by `style.object.create` and written to IDML (`EnableStrokeAndCornerOptions`, `TopLeftCornerOption`…). `style.object.edit` also changes a style's fill, stroke and corners and renames it (`rename`); the items using the style that haven't overridden an attribute follow each change, as in InDesign. Found in the Learn Skills Daily course, chapter 15. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..251ce9005 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2087,6 +2087,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"), "layerSettings" => crate::i18n::tr(&app.ui.language, "Layer Options"), crate::effects_dialog::ID => crate::i18n::tr(&app.ui.language, "Effects"), + crate::object_style_dialog::ID => crate::i18n::tr(&app.ui.language, crate::object_style_dialog::title(&d)), id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => crate::i18n::tr(&app.ui.language, c.label.trim_end_matches('…')), None => crate::i18n::tr(&app.ui.language, "Dialog"), @@ -2104,6 +2105,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "newDocument" => 960.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, + crate::object_style_dialog::ID => 760.0, _ => 640.0, }; let width = preferred_width.min(available.x); @@ -2361,6 +2363,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "colorGroupOptions" => options::color_group_options(app, ui, &mut d), "layerSettings" => options::layer_options(app, ui, &mut d), crate::effects_dialog::ID => crate::effects_dialog::show(app, ui, &mut d), + crate::object_style_dialog::ID => crate::object_style_dialog::show(app, ui, &mut d), "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { @@ -3573,6 +3576,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "alert" => Ok(Value::Null), crate::effects_dialog::ID => crate::effects_dialog::confirm(app, &d), + crate::object_style_dialog::ID => crate::object_style_dialog::confirm(app, &d), "findChange" => { let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; // Confirmation consumes retained bounds before closing, even on an error. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d4551e813..3612e2968 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -19,6 +19,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("effects", "Effects", "panel-effects"), ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), + ("objectStyles", "Object Styles", "frame-dashed"), ("character", "Character", "panel-character"), ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), @@ -280,6 +281,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "swatches" => panels::swatches::show(app, ui), "paragraphStyles" => panels::styles::paragraph(app, ui), "characterStyles" => panels::styles::character(app, ui), + "objectStyles" => panels::styles::object(app, ui), "stroke" => panels::properties::stroke_panel(app, ui), "character" => panels::properties::character_panel(app, ui), "typography" => panels::typography::show(app, ui), diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..cd54fc225 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -65,7 +65,7 @@ pub fn open(app: &mut DesignApp, page: &str) { } /// Every effect's settings, those still at their defaults included (the document leaves them out). -fn effects_json(e: &designcraft_doc::Effects) -> Value { +pub(crate) fn effects_json(e: &designcraft_doc::Effects) -> Value { use designcraft_doc::{Bevel, DirectionalFeather, DropShadow, FeatherOptions, GradientFeather, InnerGlow, InnerShadow, OuterGlow, Satin}; let mut fx = serde_json::to_value(e).unwrap_or_else(|_| json!({})); let defaults = [ @@ -90,7 +90,7 @@ fn effects_json(e: &designcraft_doc::Effects) -> Value { } /// Turn the effect of `page` on, as choosing it does (Basic Feather: its default width). -fn turn_on(fx: &mut Value, page: &str) { +pub(crate) fn turn_on(fx: &mut Value, page: &str) { match page { "transparency" => {} "feather" => { @@ -106,7 +106,7 @@ fn turn_on(fx: &mut Value, page: &str) { } } -fn is_on(fx: &Value, page: &str) -> bool { +pub(crate) fn is_on(fx: &Value, page: &str) -> bool { match page { "transparency" => true, "feather" => fx["feather"].as_f64().unwrap_or(0.0) > 0.0, @@ -269,7 +269,7 @@ fn offset(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, global: f64, } /// The settings of `page`. -fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { +pub(crate) fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..72d756c7b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,45 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("New Object Style", ["Neues Objektformat", "Nouveau style d'objet", "Nuevo estilo de objeto", "新規オブジェクトスタイル", "新建对象样式"]), + ( + "Object Style Options", + ["Objektformatoptionen", "Options de style d'objet", "Opciones de estilo de objeto", "オブジェクトスタイルの設定", "对象样式选项"], + ), + ( + "Object Style Options…", + ["Objektformatoptionen…", "Options de style d'objet…", "Opciones de estilo de objeto…", "オブジェクトスタイルの設定…", "对象样式选项…"], + ), + ("Basic Attributes", ["Grundattribute", "Attributs de base", "Atributos básicos", "基本属性", "基本属性"]), + ( + "Stroke & Corner Options", + ["Kontur- und Eckenoptionen", "Options de contour et d'angle", "Opciones de trazo y esquina", "線とコーナーのオプション", "描边和角选项"], + ), + ("Effects for:", ["Effekte für:", "Effets pour :", "Efectos para:", "効果 :", "效果:"]), + ( + "Apply Style to Selection", + [ + "Format auf Auswahl anwenden", + "Appliquer le style à la sélection", + "Aplicar estilo a la selección", + "選択範囲にスタイルを適用", + "将样式应用于选区", + ], + ), + ("Create New Style", ["Neues Format erstellen", "Créer un style", "Crear nuevo estilo", "新規スタイルを作成", "创建新样式"]), + ("Clear Overrides", ["Abweichungen löschen", "Effacer les remplacements", "Borrar modificaciones", "オーバーライドを消去", "清除覆盖"]), + ("Shape:", ["Form:", "Forme :", "Forma:", "形状 :", "形状:"]), + ("(no attributes)", ["(keine Attribute)", "(aucun attribut)", "(sin atributos)", "(属性なし)", "(无属性)"]), + ( + "Enter a style name.", + [ + "Geben Sie einen Formatnamen ein.", + "Saisissez un nom de style.", + "Escriba un nombre de estilo.", + "スタイル名を入力してください。", + "请输入样式名称。", + ], + ), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..1872dd73c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("New Object Style", "نمط كائن جديد"), + ("Object Style Options", "خيارات نمط الكائن"), + ("Object Style Options…", "خيارات نمط الكائن…"), + ("Basic Attributes", "السمات الأساسية"), + ("Stroke & Corner Options", "خيارات الحد والزوايا"), + ("Effects for:", "التأثيرات لـ:"), + ("Apply Style to Selection", "تطبيق النمط على التحديد"), + ("Create New Style", "إنشاء نمط جديد"), + ("Clear Overrides", "مسح التجاوزات"), + ("Shape:", "الشكل:"), + ("(no attributes)", "(لا سمات)"), + ("Enter a style name.", "أدخل اسم نمط."), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..64261a4de 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("New Object Style", "Nový styl objektu"), + ("Object Style Options", "Volby stylu objektu"), + ("Object Style Options…", "Volby stylu objektu…"), + ("Basic Attributes", "Základní atributy"), + ("Stroke & Corner Options", "Volby tahu a rohů"), + ("Effects for:", "Efekty pro:"), + ("Apply Style to Selection", "Použít styl na výběr"), + ("Create New Style", "Vytvořit nový styl"), + ("Clear Overrides", "Vymazat změny"), + ("Shape:", "Tvar:"), + ("(no attributes)", "(žádné atributy)"), + ("Enter a style name.", "Zadejte název stylu."), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..ab7a6840f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("New Object Style", "Nuovo stile oggetto"), + ("Object Style Options", "Opzioni stile oggetto"), + ("Object Style Options…", "Opzioni stile oggetto…"), + ("Basic Attributes", "Attributi di base"), + ("Stroke & Corner Options", "Opzioni traccia e angoli"), + ("Effects for:", "Effetti per:"), + ("Apply Style to Selection", "Applica stile alla selezione"), + ("Create New Style", "Crea nuovo stile"), + ("Clear Overrides", "Cancella sostituzioni"), + ("Shape:", "Forma:"), + ("(no attributes)", "(nessun attributo)"), + ("Enter a style name.", "Inserisci un nome di stile."), + ("Corner Options", "Opzioni angolo"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..fe735f4d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("New Object Style", "ახალი ობიექტის სტილი"), + ("Object Style Options", "ობიექტის სტილის პარამეტრები"), + ("Object Style Options…", "ობიექტის სტილის პარამეტრები…"), + ("Basic Attributes", "ძირითადი ატრიბუტები"), + ("Stroke & Corner Options", "კონტურისა და კუთხეების პარამეტრები"), + ("Effects for:", "ეფექტები:"), + ("Apply Style to Selection", "სტილის გამოყენება მონიშნულზე"), + ("Create New Style", "ახალი სტილის შექმნა"), + ("Clear Overrides", "გადაფარვების გასუფთავება"), + ("Shape:", "ფორმა:"), + ("(no attributes)", "(ატრიბუტების გარეშე)"), + ("Enter a style name.", "შეიყვანეთ სტილის სახელი."), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..f0f7f58a1 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("New Object Style", "Nowy styl obiektu"), + ("Object Style Options", "Opcje stylu obiektu"), + ("Object Style Options…", "Opcje stylu obiektu…"), + ("Basic Attributes", "Atrybuty podstawowe"), + ("Stroke & Corner Options", "Opcje obrysu i narożników"), + ("Effects for:", "Efekty dla:"), + ("Apply Style to Selection", "Zastosuj styl do zaznaczenia"), + ("Create New Style", "Utwórz nowy styl"), + ("Clear Overrides", "Wyczyść zmiany"), + ("Shape:", "Kształt:"), + ("(no attributes)", "(brak atrybutów)"), + ("Enter a style name.", "Wpisz nazwę stylu."), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..b922a37c9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("New Object Style", "Novo estilo de objeto"), + ("Object Style Options", "Opções de estilo de objeto"), + ("Object Style Options…", "Opções de estilo de objeto…"), + ("Basic Attributes", "Atributos básicos"), + ("Stroke & Corner Options", "Opções de traçado e canto"), + ("Effects for:", "Efeitos para:"), + ("Apply Style to Selection", "Aplicar estilo à seleção"), + ("Create New Style", "Criar novo estilo"), + ("Clear Overrides", "Limpar substituições"), + ("Shape:", "Forma:"), + ("(no attributes)", "(sem atributos)"), + ("Enter a style name.", "Digite um nome de estilo."), + ("Corner Options", "Opções de canto"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..d8630b712 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("New Object Style", "Новый стиль объекта"), + ("Object Style Options", "Параметры стиля объекта"), + ("Object Style Options…", "Параметры стиля объекта…"), + ("Basic Attributes", "Основные атрибуты"), + ("Stroke & Corner Options", "Параметры обводки и углов"), + ("Effects for:", "Эффекты для:"), + ("Apply Style to Selection", "Применить стиль к выделенному"), + ("Create New Style", "Создать новый стиль"), + ("Clear Overrides", "Удалить переопределения"), + ("Shape:", "Форма:"), + ("(no attributes)", "(нет атрибутов)"), + ("Enter a style name.", "Введите имя стиля."), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..242cb0d1e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,19 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("New Object Style", "Новий стиль об'єкта"), + ("Object Style Options", "Параметри стилю об'єкта"), + ("Object Style Options…", "Параметри стилю об'єкта…"), + ("Basic Attributes", "Основні атрибути"), + ("Stroke & Corner Options", "Параметри обведення й кутів"), + ("Effects for:", "Ефекти для:"), + ("Apply Style to Selection", "Застосувати стиль до виділеного"), + ("Create New Style", "Створити новий стиль"), + ("Clear Overrides", "Очистити перевизначення"), + ("Shape:", "Форма:"), + ("(no attributes)", "(немає атрибутів)"), + ("Enter a style name.", "Введіть назву стилю."), + ("Corner Options", "Параметри кутів"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb98cac7f..1842bdb49 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +pub mod object_style_dialog; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 79b8b2c4e..237e64f55 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,6 +39,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), + ( + "objectStyle.dialog", + "Object Style…", + None, + "{name?: style} — Object Style Options for the style `name`, or (without it) New Object Style from the selected object", + ), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), @@ -878,6 +884,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Stroke|{\"panel\": \"stroke\"}", ">Styles", "ui:window.panel|Character Styles|{\"panel\": \"characterStyles\"}", + "ui:window.panel|Object Styles|{\"panel\": \"objectStyles\"}", "ui:window.panel|Paragraph Styles|{\"panel\": \"paragraphStyles\"}", "<", "ui:window.panel|Text Wrap|{\"panel\": \"textWrap\"}", @@ -1188,6 +1195,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + match p.get("name").and_then(Value::as_str) { + Some(n) => crate::object_style_dialog::open_edit(app, n), + None => crate::object_style_dialog::open_new(app), + } + Ok(Value::Null) + } "app.loadSwatches" => { if let Some(pick) = app.services.pick_open.as_mut() { return Some(match pick("swatches") { diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs new file mode 100644 index 000000000..b9e1b3ce5 --- /dev/null +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -0,0 +1,467 @@ +//! The Object Styles panel's New Object Style and Object Style Options dialog, laid out as +//! InDesign's: the style's name on top; on the left its Basic Attributes (a checkbox includes one +//! in the style, a click shows its settings) and its effects; on the right the settings of the +//! attribute shown (General sums the style up); Apply Style to Selection for a new style. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::dialogs::Dialog; +use crate::theme::semibold; + +/// The dialog's id. +pub const ID: &str = "objectStyle"; + +/// Basic Attributes: (key, label). General isn't an attribute: it sums the style up. +const ATTRIBUTES: [(&str, &str); 4] = [("general", "General"), ("fill", "Fill"), ("stroke", "Stroke"), ("corners", "Stroke & Corner Options")]; + +/// The dialog for a new style from the selected object (its fill, stroke, corners and effects, +/// as InDesign's New Object Style), named "Object Style n". +pub fn open_new(app: &mut DesignApp) { + let Some(st) = app.session.active() else { return }; + let it = st.selection.items.first().and_then(|i| st.doc.item(*i)).cloned(); + let taken = |n: &str| st.doc.styles.object_style(n).is_some(); + let name = (1..).map(|i| format!("Object Style {i}")).find(|n| !taken(n)).unwrap_or_default(); + let from = it.is_some(); + let fields = json!({ + "new": true, + "name": name, + "basedOn": designcraft_doc::NO_OBJECT_STYLE, + "fill": it.as_ref().map(|i| i.fill.clone()).unwrap_or_default(), + "stroke": it.as_ref().map(|i| i.stroke.clone()).unwrap_or_default(), + "corners": it.as_ref().map(|i| i.corners).unwrap_or_default(), + "fx": crate::effects_dialog::effects_json(&it.map(|i| i.effects).unwrap_or_default()), + "hadEffects": from, + "inc": {"fill": from, "stroke": from, "corners": from}, + "page": "general", + "apply": from, + }); + app.ui.dialog = Some(Dialog::new(ID, fields)); +} + +/// Object Style Options for style `name`. +pub fn open_edit(app: &mut DesignApp, name: &str) { + let Some(os) = app.session.active().and_then(|st| st.doc.styles.object_style(name).cloned()) else { return }; + if name == designcraft_doc::NO_OBJECT_STYLE { + return; + } + let fields = json!({ + "new": false, + "orig": name, + "name": name, + "basedOn": os.based_on.as_deref().unwrap_or(designcraft_doc::NO_OBJECT_STYLE), + "fill": os.fill.clone().unwrap_or_default(), + "stroke": os.stroke.clone().unwrap_or_default(), + "corners": os.corners.unwrap_or_default(), + "fx": crate::effects_dialog::effects_json(&os.effects.clone().unwrap_or_default()), + "hadEffects": os.effects.is_some(), + "inc": {"fill": os.fill.is_some(), "stroke": os.stroke.is_some(), "corners": os.corners.is_some()}, + "page": "general", + "apply": false, + }); + app.ui.dialog = Some(Dialog::new(ID, fields)); +} + +/// The dialog's title: New Object Style or Object Style Options. +pub fn title(d: &Dialog) -> &'static str { + if d.b("new") { "New Object Style" } else { "Object Style Options" } +} + +fn included(d: &Dialog, key: &str) -> bool { + d.fields.get("inc").and_then(|i| i[key].as_bool()).unwrap_or(false) +} + +/// Whether the style sets effects: it did, or one is on now. +fn sets_effects(d: &Dialog) -> bool { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + d.b("hadEffects") || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k)) +} + +/// The style's attributes as the commands take them (null: not in the style). +fn attrs(d: &Dialog) -> serde_json::Map { + let mut p = serde_json::Map::new(); + for k in ["fill", "stroke", "corners"] { + p.insert(k.into(), if included(d, k) { d.fields.get(k).cloned().unwrap_or(Value::Null) } else { Value::Null }); + } + p.insert("effects".into(), if sets_effects(d) { d.fields.get("fx").cloned().unwrap_or(Value::Null) } else { Value::Null }); + p +} + +/// OK: the new style (applied to the selection when asked), or the edited one. +pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + let name = d.s("name").trim().to_string(); + if name.is_empty() { + return Err(crate::i18n::tr(&app.ui.language, "Enter a style name.").to_string()); + } + let mut p = attrs(d); + if d.b("new") { + p.insert("name".into(), json!(name)); + p.insert("fromSelection".into(), json!(false)); + let r = app.run("style.object.create", Value::Object(p)).map_err(|e| e.to_string())?; + let made = r["name"].as_str().unwrap_or(&name).to_string(); + if d.b("apply") && app.session.active().is_some_and(|st| !st.selection.items.is_empty()) { + app.run("style.object.apply", json!({"name": made})).map_err(|e| e.to_string())?; + } + Ok(r) + } else { + let orig = d.s("orig"); + p.insert("name".into(), json!(orig)); + if name != orig { + p.insert("rename".into(), json!(name)); + } + // A style that didn't set effects and still doesn't: nothing to say about them. + if p.get("effects").is_some_and(Value::is_null) && !d.b("hadEffects") { + p.remove("effects"); + } + app.run("style.object.edit", Value::Object(p)).map_err(|e| e.to_string()) + } +} + +fn set(d: &mut Dialog, path: &[&str], v: Value) { + let Some((first, rest)) = path.split_first() else { return }; + let mut cur = d.fields.entry(first.to_string()).or_insert(Value::Null); + for k in rest { + if !cur.is_object() { + *cur = json!({}); + } + cur = &mut cur[*k]; + } + *cur = v; +} + +fn get<'a>(d: &'a Dialog, path: &[&str]) -> &'a Value { + let Some((first, rest)) = path.split_first() else { return &Value::Null }; + let mut v = d.fields.get(*first).unwrap_or(&Value::Null); + for k in rest { + v = &v[*k]; + } + v +} + +/// A row of the left-hand lists: a checkbox (when `check` is given) and the label, which shows +/// `page`. +fn list_row(ui: &mut egui::Ui, d: &mut Dialog, page: &str, label: &str, check: Option) -> Option { + let mut out = None; + ui.horizontal(|ui| { + match check { + Some(mut on) => { + if ui.checkbox(&mut on, "").changed() { + out = Some(on); + } + } + None => ui.add_space(24.0), + } + let cur = d.fields.get("page").and_then(Value::as_str) == Some(page); + if ui.selectable_label(cur, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert("page".into(), json!(page)); + } + }); + out +} + +/// The document's swatches, as a list to pick from (InDesign's Fill and Stroke pages). +fn swatch_list(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str) { + let Some(doc) = app.session.active().map(|st| &st.doc) else { return }; + let cur = get(d, &[key, "swatch"]).as_str().unwrap_or("").to_string(); + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(("os_swatches", key.to_string())).max_height(220.0).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + for w in doc.swatches.iter().filter(|w| !w.hidden || w.name == cur) { + let picked = ui + .horizontal(|ui| { + let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); + let r = crate::widgets::swatch_chip(ui, 14.0, c, g); + ui.selectable_label(w.name == cur, &w.name).clicked() || r.clicked() + }) + .inner; + if picked { + set(d, &[key, "swatch"], json!(w.name)); + } + } + }); + }); +} + +/// A number field of the dialog at `path` (a percentage of 0–1 with `percent`). +fn number(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], suffix: &str, range: (f64, f64), percent: bool) { + let v = get(d, path).as_f64().unwrap_or(0.0); + let shown = if percent { (v * 100.0).round() } else { v }; + let id = format!("os_{}", path.join("_")); + let decimals = if percent { 0 } else { 2 }; + if let Some(n) = crate::widgets::NumField::number(&id, Some(shown), suffix, decimals).width(90.0).spinner().range(range.0, range.1).show(ui) { + set(d, path, json!(if percent { n / 100.0 } else { n })); + } +} + +/// One line per attribute the style sets (InDesign's Style Settings). +fn settings(app: &DesignApp, d: &Dialog) -> Vec { + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let mut out = Vec::new(); + if included(d, "fill") { + let f = get(d, &["fill"]); + out.push(format!("{}: {} {:.0}%", tr("Fill"), f["swatch"].as_str().unwrap_or(""), f["tint"].as_f64().unwrap_or(1.0) * 100.0)); + } + if included(d, "stroke") { + let s = get(d, &["stroke"]); + out.push(format!("{}: {}, {} pt", tr("Stroke"), s["swatch"].as_str().unwrap_or(""), s["weight"].as_f64().unwrap_or(0.0))); + } + if included(d, "corners") { + let c = &get(d, &["corners"])["corners"][0]; + let shape: designcraft_geom::corners::CornerShape = serde_json::from_value(c["shape"].clone()).unwrap_or_default(); + out.push(format!("{}: {} {} pt", tr("Corner Options"), tr(shape.label()), c["size"].as_f64().unwrap_or(0.0))); + } + if sets_effects(d) { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + let on: Vec = + crate::effects_dialog::PAGES.iter().skip(1).filter(|(k, _)| crate::effects_dialog::is_on(&fx, k)).map(|(_, l)| tr(l)).collect(); + out.push(format!("{}: {}", tr("Effects"), if on.is_empty() { tr("(no effects)") } else { on.join(", ") })); + } + out +} + +/// The settings of the attribute shown. +fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let title = ATTRIBUTES.iter().find(|a| a.0 == page).map(|a| a.1); + if let Some(title) = title { + crate::rtl::label(ui, egui::RichText::new(tr(title)).font(semibold(14.0))); + ui.add_space(6.0); + } + match page { + "general" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Based On:")); + // Based On isn't editable yet: the style's parent, as it is. + ui.add_enabled_ui(false, |ui| { + let based = d.s("basedOn"); + egui::ComboBox::from_id_salt("os_based_on") + .selected_text(crate::i18n::style_name(&app.ui.language, &based)) + .width(220.0) + .show_ui(ui, |_| {}); + }); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Style Settings:")).font(semibold(12.0))); + let t = crate::theme::Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.divider)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.set_min_height(160.0); + let lines = settings(app, d); + if lines.is_empty() { + crate::rtl::label(ui, egui::RichText::new(tr("(no attributes)")).color(t.text_dim)); + } + for l in lines { + crate::rtl::label(ui, l); + } + }); + } + "fill" | "stroke" => { + let key = page; + ui.add_enabled_ui(included(d, key), |ui| { + swatch_list(app, ui, d, key); + ui.add_space(6.0); + ui.horizontal(|ui| { + if key == "stroke" { + crate::rtl::label(ui, tr("Weight:")); + number(ui, d, &["stroke", "weight"], " pt", (0.0, 1000.0), false); + ui.add_space(12.0); + } + crate::rtl::label(ui, tr("Tint:")); + number(ui, d, &[key, "tint"], "%", (0.0, 100.0), true); + }); + }); + } + "corners" => { + ui.add_enabled_ui(included(d, "corners"), |ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Corner Options")).font(semibold(12.0))); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Size:")); + let size = get(d, &["corners", "corners"])[0]["size"].as_f64().unwrap_or(0.0); + if let Some(v) = + crate::widgets::NumField::number("os_corner_size", Some(size), " pt", 2).width(90.0).spinner().range(0.0, 10_000.0).show(ui) + { + let mut c = get(d, &["corners"]).clone(); + if let Some(cs) = c["corners"].as_array_mut() { + cs.iter_mut().for_each(|k| k["size"] = json!(v)); + } + d.fields.insert("corners".into(), c); + } + ui.add_space(12.0); + crate::rtl::label(ui, tr("Shape:")); + let cur: designcraft_geom::corners::CornerShape = + serde_json::from_value(get(d, &["corners", "corners"])[0]["shape"].clone()).unwrap_or_default(); + let mut picked = None; + egui::ComboBox::from_id_salt("os_corner_shape").selected_text(tr(cur.label())).width(140.0).show_ui(ui, |ui| { + for s in designcraft_geom::corners::CornerShape::ALL { + if ui.selectable_label(s == cur, tr(s.label())).clicked() { + picked = Some(s); + } + } + }); + if let Some(s) = picked { + let mut c = get(d, &["corners"]).clone(); + if let Some(cs) = c["corners"].as_array_mut() { + cs.iter_mut().for_each(|k| k["shape"] = json!(s)); + } + d.fields.insert("corners".into(), c); + } + }); + }); + } + p => crate::effects_dialog::page_ui(app, ui, d, p), + } +} + +/// The dialog's body. +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Style Name:")); + let mut name = d.s("name"); + if ui.add(egui::TextEdit::singleline(&mut name).desired_width(300.0)).changed() { + d.fields.insert("name".into(), json!(name)); + } + }); + ui.add_space(8.0); + let page = d.fields.get("page").and_then(Value::as_str).unwrap_or("general").to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(230.0); + crate::rtl::label(ui, egui::RichText::new(tr("Basic Attributes")).font(semibold(12.0))); + for (key, label) in ATTRIBUTES { + let check = (key != "general").then(|| included(d, key)); + if let Some(on) = list_row(ui, d, key, &tr(label), check) { + set(d, &["inc", key], json!(on)); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Effects for:")); + // Stroke, Fill and Text levels aren't in the document model yet: the object only. + ui.add_enabled_ui(false, |ui| { + egui::ComboBox::from_id_salt("os_fx_level").selected_text(tr("Object")).width(100.0).show_ui(ui, |_| {}); + }); + }); + for (key, label) in crate::effects_dialog::PAGES.iter().skip(1) { + let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); + if let Some(on) = list_row(ui, d, key, &tr(label), Some(crate::effects_dialog::is_on(&fx, key))) + && let Some(f) = d.fields.get_mut("fx") + { + if on { + crate::effects_dialog::turn_on(f, key); + } else if *key == "feather" { + f["feather"] = json!(0.0); + } else { + f[*key]["on"] = json!(false); + } + } + } + }); + ui.add_space(16.0); + ui.vertical(|ui| { + ui.set_min_width(440.0); + page_ui(app, ui, d, &page); + }); + }); + ui.add_space(10.0); + if d.b("new") { + let mut apply = d.b("apply"); + if ui.checkbox(&mut apply, crate::rtl::widget(ui, tr("Apply Style to Selection"))).changed() { + d.fields.insert("apply".into(), json!(apply)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_frame() -> (DesignApp, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 172, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + (app, id) + } + + fn frame(app: &mut DesignApp, ctx: &egui::Context) -> Vec { + let mut texts = Vec::new(); + for _ in 0..3 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn walk(s: egui::epaint::Shape, texts: &mut Vec) { + match s { + egui::epaint::Shape::Text(t) => texts.push(t.galley.text().to_string()), + egui::epaint::Shape::Vec(v) => v.into_iter().for_each(|s| walk(s, texts)), + _ => {} + } + } + texts.clear(); + for c in out.shapes { + walk(c.shape, &mut texts); + } + } + texts + } + + /// The course's chapter 15: New Object Style from a frame with rounded corners, a paper + /// stroke and a drop shadow, named and applied to the selection; then Object Style Options + /// with a thicker stroke: the frames using the style follow. + #[test] + fn a_new_object_style_from_the_selection_and_its_options() { + let (mut app, a) = app_with_frame(); + app.run("object.cornerOptions", json!({"shape": "rounded", "size": 12, "ids": [a]})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Paper]", "weight": 2.5, "ids": [a]})).unwrap(); + app.run("object.dropShadow", json!({"on": true, "ids": [a]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + open_new(&mut app); + let texts = frame(&mut app, &ctx); + for t in [ + "New Object Style", + "Style Name:", + "Basic Attributes", + "Fill", + "Stroke & Corner Options", + "Effects for:", + "Drop Shadow", + "Style Settings:", + "Apply Style to Selection", + ] { + assert!(texts.iter().any(|x| x.trim() == t), "{t}: {texts:?}"); + } + assert!(texts.iter().any(|x| x.starts_with("Stroke: [Paper], 2.5 pt")), "{texts:?}"); + assert!(texts.iter().any(|x| x.starts_with("Effects: Drop Shadow")), "{texts:?}"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Rounded Corner Image, White Stroke")); + d.fields.insert("page".into(), json!("corners")); + } + let texts = frame(&mut app, &ctx); + assert!(texts.iter().any(|x| x.trim() == "Rounded"), "{texts:?}"); + crate::dialogs::confirm(&mut app).unwrap(); + let name = "Rounded Corner Image, White Stroke"; + let doc = |app: &DesignApp| app.session.active().unwrap().doc.clone(); + let item = |app: &DesignApp, id: u64| doc(app).item(designcraft_doc::ItemId(id)).unwrap().clone(); + assert_eq!(item(&app, a).object_style, name, "applied to the selection"); + let os = doc(&app).styles.object_style(name).cloned().unwrap(); + assert!(os.corners.is_some() && os.effects.as_ref().unwrap().drop_shadow.on && os.stroke.as_ref().unwrap().weight == 2.5); + + let b = app.run("frame.create", json!({"rect": [200, 72, 300, 172], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("style.object.apply", json!({"name": name, "ids": [b]})).unwrap(); + open_edit(&mut app, name); + if let Some(d) = app.ui.dialog.as_mut() { + assert_eq!(title(d), "Object Style Options"); + set(d, &["stroke", "weight"], json!(5.0)); + set(d, &["inc", "fill"], json!(false)); + } + frame(&mut app, &ctx); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((item(&app, a).stroke.weight, item(&app, b).stroke.weight), (5.0, 5.0)); + assert!(doc(&app).styles.object_style(name).unwrap().fill.is_none(), "Fill unchecked: no longer in the style"); + assert!(doc(&app).styles.object_style(name).unwrap().effects.as_ref().unwrap().drop_shadow.on, "effects kept"); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index f7e6fa803..0f2295340 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -1,4 +1,4 @@ -//! Paragraph Styles and Character Styles panels. +//! Paragraph Styles, Character Styles and Object Styles panels. use egui::{Sense, vec2}; use serde_json::json; @@ -105,6 +105,106 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } +/// The selected object's style, and whether the object differs from it (an override: `+`). +fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { + let st = app.session.active()?; + let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?; + let mut styled = it.clone(); + if let Some(os) = st.doc.styles.object_style(&it.object_style) { + os.apply_to(&mut styled); + } + Some((it.object_style.clone(), styled != *it)) +} + +/// Window › Styles › Object Styles, as InDesign's: the selected object's style on top (`+` when +/// it is overridden), the document's object styles (a click applies one to the selection, a +/// double-click opens its options; the default graphic and text frame styles are marked), and at +/// the bottom Clear Overrides and Create New Style (⌥/Alt-click: the New Object Style dialog). +pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let names: Vec = st.doc.styles.object.iter().map(|s| s.name.clone()).collect(); + let (default_graphic, default_text) = (st.doc.styles.default_graphic_frame.clone(), st.doc.styles.default_text_frame.clone()); + let current = object_style_of_selection(app); + let selected = current.is_some(); + let shown = |n: &str, over: bool| format!("{}{}", crate::i18n::style_name(&lang, n), if over { "+" } else { "" }); + crate::rtl::label(ui, egui::RichText::new(current.as_ref().map_or_else(String::new, |(n, over)| shown(n, *over))).size(11.0).color(t.text_dim)); + for n in &names { + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let is_current = current.as_ref().is_some_and(|(c, _)| c == n); + if is_current { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let label = shown(n, is_current && current.as_ref().is_some_and(|c| c.1)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_current, &label)); + crate::rtl::paint(ui.painter(), row.min + vec2(24.0, 11.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(12.5), t.text); + // The default styles of new graphic and text frames, as InDesign marks them. + let mark = egui::Rect::from_center_size(egui::pos2(row.max.x - 14.0, row.center().y), vec2(11.0, 11.0)); + if *n == default_graphic { + ui.painter().rect_stroke(mark, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + } + if *n == default_text { + ui.painter().rect_stroke(mark, 0.0, egui::Stroke::new(1.0, t.icon), egui::StrokeKind::Inside); + ui.painter().text(mark.center(), egui::Align2::CENTER_CENTER, "T", egui::FontId::proportional(8.5), t.icon); + } + let editable = n != designcraft_doc::NO_OBJECT_STYLE; + if resp.double_clicked() && editable { + crate::object_style_dialog::open_edit(app, n); + } else if resp.clicked() && selected { + let _ = app.run("style.object.apply", json!({"name": n})); + } + resp.context_menu(|ui| { + let label = crate::i18n::style_name(&lang, n); + if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Edit \"{name}\"…").replace("{name}", label))).clicked() { + crate::object_style_dialog::open_edit(app, n); + ui.close(); + } + if ui + .add_enabled(selected, egui::Button::new(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&lang, "Apply"), label)))) + .clicked() + { + let _ = app.run("style.object.apply", json!({"name": n})); + ui.close(); + } + }); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "style-new", 20.0, false, crate::i18n::tr(&lang, "Create New Style")).clicked() { + if ui.input(|i| i.modifiers.alt) { + crate::object_style_dialog::open_new(app); + } else if selected { + let _ = app.run("style.object.create", json!({"name": "Object Style 1", "fromSelection": true})); + } else { + crate::object_style_dialog::open_new(app); + } + } + let over = current.as_ref().is_some_and(|c| c.1); + if ui.add_enabled_ui(over, |ui| icons::button(ui, "style-clear", 20.0, false, crate::i18n::tr(&lang, "Clear Overrides"))).inner.clicked() + && over + { + let _ = app.run("style.object.clearOverrides", json!({})); + } + crate::menus::menu_button(ui, "☰", |ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "New Object Style…"))).clicked() { + crate::object_style_dialog::open_new(app); + ui.close(); + } + if let Some((n, _)) = current.as_ref().filter(|(n, _)| n != designcraft_doc::NO_OBJECT_STYLE) + && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Object Style Options…"))).clicked() + { + crate::object_style_dialog::open_edit(app, n); + ui.close(); + } + }); + }); + }); +} + /// One style in the list: click applies (⌥ clears overrides), double-click edits, /// right-click: edit, apply, break link, move to group. fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, overridden: bool, groups: &[String], t: &Tokens) { @@ -235,6 +335,47 @@ mod tests { assert_eq!(h.state().session.doc().unwrap().doc.styles.para("Header").unwrap().chars.size, Some(24.0)); } + /// The Object Styles panel: the selected frame's style is marked (`+` once overridden), a + /// click applies a style, the eraser clears the overrides, + makes a style from the selection. + #[test] + fn object_styles_panel_applies_marks_overrides_and_creates() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 136, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("style.object.create", json!({"name": "Framed", "fromSelection": false, "stroke": {"swatch": "[Paper]", "weight": 3}})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + object(app, ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Framed").click(); + h.run_steps(3); + // The style on top and its row. + assert_eq!(h.get_all_by_label("Framed").count(), 2); + let style = |h: &Harness| h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().clone(); + assert_eq!((style(&h).object_style.as_str(), style(&h).stroke.weight), ("Framed", 3.0)); + h.state_mut().run("object.stroke", json!({"weight": 1})).unwrap(); + h.run_steps(3); + assert_eq!(h.get_all_by_label("Framed+").count(), 2); + h.get_by_label("Clear Overrides").click(); + h.run_steps(3); + assert_eq!(style(&h).stroke.weight, 3.0); + assert_eq!(h.get_all_by_label("Framed").count(), 2); + h.get_by_label("Create New Style").click(); + h.run_steps(3); + let styles = h.state().session.active().unwrap().doc.styles.object.iter().map(|o| o.name.clone()).collect::>(); + assert!(styles.contains(&"Object Style 1".to_string()), "{styles:?}"); + } + #[test] fn paragraph_color_override_is_marked_and_cleared_from_the_row_menu() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());