Object Styles panel and the New Object Style / Object Style Options dialog

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 <noreply@anthropic.com>
This commit is contained in:
paaspaas00andClaude Opus 5.5 committed 2026-10-21 09:39:44 +02:00
1 parent 0e57712bba
commit 17da588d84
17 files changed
+773 -5

No files matched your search

+1
View File
@@ -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.
+4
View File
@@ -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<Value, String> {
}
"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.
+2
View File
@@ -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),
+4 -4
View File
@@ -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);
+39
View File
@@ -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", "編集", "编辑"]),
+12
View File
@@ -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", "عدد ثابت"),
+12
View File
@@ -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"),
+13
View File
@@ -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"),
+12
View File
@@ -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", "გაფრთხილება"),
+12
View File
@@ -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"),
+13
View File
@@ -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"),
+12
View File
@@ -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", "Выделить все"),
+13
View File
@@ -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", "Фіксована кількість"),
+1
View File
@@ -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;
+14
View File
@@ -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<Result<Value
crate::effects_dialog::open(app, p.get("page").and_then(Value::as_str).unwrap_or("transparency"));
Ok(Value::Null)
}
"objectStyle.dialog" => {
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") {
+467
View File
@@ -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<String, Value> {
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<Value, String> {
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<bool>) -> Option<bool> {
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<String> {
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<String> =
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<String> {
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<String>) {
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");
}
}
+142 -1
View File
@@ -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<String> = 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::<bool>(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<DesignApp>| 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::<Vec<_>>();
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());