diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..50f3cf8c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -343,6 +343,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-15** · Existing paragraph and character styles preview their attribute edits in + Style Options; Cancel restores the document and OK records one edit. + - **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. Newest first. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a2..ab83f1765 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2208,6 +2208,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { _ => " OK ", }; ui.horizontal(|ui| { + if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && !d.b("new") { + check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + style_options_preview(app, &mut d); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -2270,6 +2274,10 @@ fn find_change_params(d: &Dialog) -> Value { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && d.fields.contains_key(PREVIEWED) { + // Restore the original before the ordinary command commits one undoable edit. + app.session.cancel_interaction(); + } if d.id != "pdfImport" || !d.b("async") { app.cancel_pdf_import(); } @@ -2799,6 +2807,62 @@ fn regex_ok(p: &str) -> Result<(), ()> { regex::Regex::new(p).map(|_| ()).map_err(|_| ()) } +/// Preview the changed style attributes through the same engine commands as OK. Renaming and +/// export tagging have no visual effect and wait for OK, so the dialog can keep looking up the +/// original style while the preview is visible. The interaction restores its starting document +/// before each update, including after a field is cleared or Preview is switched off. +fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") || d.b("new") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let paragraph = d.id == "paragraphStyleOptions"; + let mut params = json!({"name": d.s("name"), "chars": {}}); + if paragraph { + params["para"] = json!({}); + } + for (key, value) in &d.fields { + if let Some(attr) = key.strip_prefix("c.") { + params["chars"][attr] = value.clone(); + } else if paragraph && let Some(attr) = key.strip_prefix("p.") { + if let Some((attr, field)) = attr.split_once('.') { + if params["para"][attr].is_null() { + params["para"][attr] = json!({}); + } + params["para"][attr][field] = value.clone(); + } else { + params["para"][attr] = value.clone(); + } + } + } + if let Some(based) = d.fields.get("basedOn") { + params["basedOn"] = match based.as_str() { + Some(designcraft_doc::NO_PARA_STYLE | designcraft_doc::NO_CHAR_STYLE) => Value::Null, + _ => based.clone(), + }; + } + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(error) = app.session.begin_interaction("Style Options") + { + d.fields.insert("status".into(), json!(error.to_string())); + return; + } + let command = if paragraph { "style.paragraph.edit" } else { "style.character.edit" }; + match app.session.preview(command, params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(error) => d.fields.insert("status".into(), json!(error.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); let new = d.b("new"); @@ -4809,6 +4873,87 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn style_options_preview_updates_all_uses_cancels_and_commits_once() { + use egui_kittest::{Harness, kittest::Queryable}; + for paragraph in [true, false] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect":[36,36,300,300],"content":"text","text":"First\nSecond"})).unwrap(); + app.run("text.select", json!({"story":f["story"],"anchor":0,"focus":12})).unwrap(); + let kind = if paragraph { "paragraph" } else { "character" }; + app.run(&format!("style.{kind}.create"), json!({"name":"Tutorial","chars":{"size":10}})).unwrap(); + app.run(&format!("style.{kind}.apply"), json!({"name":"Tutorial"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let journal = app.session.journal.len(); + open_style_options(&mut app, paragraph, "Tutorial"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("style_preview_test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Preview").click(); + h.run_steps(3); + h.state_mut().ui.dialog.as_mut().unwrap().fields.insert("c.size".into(), json!(18)); + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 18.0); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + assert_eq!(app.session.journal.len(), journal); + // Repeated previews replace each other, never accumulate undo entries. + app.ui.dialog.as_mut().unwrap().fields.insert("c.size".into(), json!(20)); + h.run_steps(3); + assert_eq!(h.state_mut().run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 20.0); + h.get_by_label("Preview").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, before); + h.get_by_label("Preview").click(); + h.run_steps(3); + cancel(h.state_mut()); + assert_eq!(h.state().session.doc().unwrap().doc, before); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo); + open_style_options(h.state_mut(), paragraph, "Tutorial"); + let d = h.state_mut().ui.dialog.as_mut().unwrap(); + d.fields.insert("preview".into(), json!(true)); + d.fields.insert("c.size".into(), json!(24)); + h.run_steps(3); + confirm(h.state_mut()).unwrap(); + assert_eq!(h.state().session.doc().unwrap().history.undo.len(), undo + 1); + // Both paragraphs/character runs still use the globally edited definition. + for pos in [0, 6] { + h.state_mut().run("text.select", json!({"story":f["story"],"anchor":pos,"focus":pos+1})).unwrap(); + assert_eq!(h.state_mut().run("type.selectionAttrs", json!({})).unwrap()["chars"]["size"], 24.0); + } + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc().unwrap().doc, before); + } + } + + #[test] + fn invalid_style_preview_leaves_the_starting_document_and_can_be_cancelled() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.character.create", json!({"name":"Emphasis"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let mut d = Dialog::new("characterStyleOptions", json!({"name":"Emphasis","preview":true,"basedOn":"Emphasis"})); + style_options_preview(&mut app, &mut d); + assert!(d.fields.contains_key("status")); + assert_eq!(app.session.doc().unwrap().doc, before); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(app.session.doc().unwrap().interaction.is_none()); + } + #[test] fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf9..0bf86ec2d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -61,6 +61,13 @@ context menus, typing at a caret) are things a layout artist does every minute, ## Remaining effort +Paragraph and Character Style Options now preview edits to existing styles. Preview updates +every use, remains outside undo history, and restores the starting document when disabled or +cancelled. OK applies the final edit once. The color-preview step at about 4:00 in +[Jessica Garrity's styles tutorial](https://www.youtube.com/watch?v=pG9JkAPLgs8) is reproducible +offscreen. New-style creation, renaming and export tagging are committed on OK; they are not +included in the visual preview. + | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 |