From aea9296fa2207519eefce117316c2baf44ec7708 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 23:45:42 +0700 Subject: [PATCH] Character and Paragraph Styles: a style made by the New style dialog or the panel menu is highlighted Squashed from #4713 onto main. --- crates/ui-egui/src/dialogs/new_text_style.rs | 9 +- crates/ui-egui/src/panels/text_styles.rs | 16 ++- .../ui-egui/src/panels/text_styles/tests.rs | 115 ++++++++++++++++++ crates/ui-egui/src/state.rs | 5 + 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs index 1949f0054..f945332e6 100644 --- a/crates/ui-egui/src/dialogs/new_text_style.rs +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -48,13 +48,18 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } /// OK: make the style with the name. A name another style has, or an empty one, keeps the dialog -/// open. +/// open. The panel highlights the new style and scrolls it into view when it next draws. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut params = d.fields.get("__target").filter(|t| t.is_object()).cloned().unwrap_or_else(|| json!({})); params["name"] = json!(d.str("name")); let r = app.run(if para(d) { "paraStyle.new" } else { "charStyle.new" }, params); - if r.is_ok() { + if let Ok(v) = &r { app.ui.dialog = None; + if let Some((uid, name)) = app.session.active().and_then(|doc| v["name"].as_str().map(|name| (doc.uid, name))) { + let para = para(d); + app.ui.made_text_style.retain(|(pending_uid, pending_para, _)| *pending_uid != uid || *pending_para != para); + app.ui.made_text_style.push((uid, para, name.to_string())); + } } r } diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 7c65cf93d..8eebeae19 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -227,6 +227,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { // A highlighted style that is no longer listed (renamed in Style Options, deleted by a command // or undone) is no longer highlighted. let mut sel = highlighted(app, ui.ctx(), kind, &listed); + // A style made by the New style dialog waits for this kind's panel on its own document. + app.ui.made_text_style.retain(|(uid, _, _)| app.session.documents().iter().any(|doc| doc.uid == *uid)); + if let Some(index) = app + .ui + .made_text_style + .iter() + .position(|(uid, para, _)| *para == (kind == Kind::Para) && app.session.active().is_some_and(|doc| doc.uid == *uid)) + { + let (_, _, name) = app.ui.made_text_style.remove(index); + highlight_made(ui.ctx(), kind, vec![name]); + sel = highlighted(app, ui.ctx(), kind, &listed); + } let scroll_to: Option = pstate(ui.ctx(), kind.scroll_key()); widgets::list_box(ui, |ui| { for (name, attrs, _) in &all { @@ -330,7 +342,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) { // A later typing preview must not restore the document from before this capture. end_typing(app); - app.run(&format!("{p}.new"), target(app, kind, json!({}))).ok(); + if let Ok(r) = app.run(&format!("{p}.new"), target(app, kind, json!({}))) { + highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string).into_iter().collect()); + } } if menu_item(ui, &crate::i18n::fmt(tl!("Duplicate {kind} Style"), &[("kind", kind.what_label())]), !many.is_empty(), false) { let names: Vec = listed.iter().filter(|n| many.iter().any(|s| s == *n)).map(|n| n.to_string()).collect(); diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 465baeac2..d20962461 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -368,6 +368,15 @@ fn capture_from_menu(range: Option<(usize, usize)>, redefine: bool) { capture_from_ui(range, redefine, false, false); } +fn assert_new_style_highlight(ctx: &egui::Context, redefine: bool, name: &str) { + if !redefine { + let selected: Option = pstate(ctx, Kind::Char.key()); + assert_eq!(selected.as_deref(), Some(name), "New highlights the style it made"); + let scroll_to: Option = pstate(ctx, Kind::Char.scroll_key()); + assert_eq!(scroll_to.as_deref(), Some(name), "New requests a scroll to the new style"); + } +} + fn capture_from_ui(range: Option<(usize, usize)>, redefine: bool, footer: bool, unicode: bool) { let (mut app, id, ctx) = capture_app(); let (first, tail) = if unicode { ("é", "漢字") } else { ("AB", "cdef") }; @@ -413,6 +422,7 @@ fn capture_from_ui(range: Option<(usize, usize)>, redefine: bool, footer: bool, } let after = app.session.doc().unwrap().doc.clone(); let name = if redefine { "Body" } else { "Character Style 1" }; + assert_new_style_highlight(&ctx, redefine, name); let definition = after.char_styles.iter().find(|s| s.name == name); let size = definition.and_then(|s| s.attrs.get("size")).and_then(Value::as_f64); let expected_size = if range.is_some_and(|(a, b)| b > a && a >= first.len()) { 34.0 } else { 18.0 }; @@ -840,6 +850,10 @@ fn alt_clicking_new_character_style_asks_for_its_name() { set_name(&mut app, "Untouched"); assert!(crate::dialogs::confirm(&mut app).is_err(), "names are unique"); assert!(app.ui.dialog.is_some(), "a taken name keeps the dialog open"); + assert!(app.ui.made_text_style.is_empty(), "a taken name does not request a highlight"); + set_name(&mut app, ""); + assert!(crate::dialogs::confirm(&mut app).is_err(), "a name is required"); + assert!(app.ui.made_text_style.is_empty(), "an empty name does not request a highlight"); set_name(&mut app, "Heading"); let undo = app.session.doc().unwrap().history.undo.len(); crate::dialogs::confirm(&mut app).unwrap(); @@ -852,6 +866,107 @@ fn alt_clicking_new_character_style_asks_for_its_name() { assert_eq!(text_editing(&app), Some((id, 3, 5)), "the Type selection stays"); } +/// The dialog highlights its new style and scrolls the matching panel to its row. +#[test] +fn a_style_made_in_the_new_style_dialog_is_highlighted() { + for kind in [Kind::Char, Kind::Para] { + let (mut app, id, ctx) = capture_app(); + if kind == Kind::Para { + app.run("paraStyle.new", json!({"name": "Untouched"})).unwrap(); + } + for i in 1..=12 { + app.run(&format!("{}.new", kind.prefix()), json!({"name": format!("Item {i:02}")})).unwrap(); + } + select_type_range(&mut app, id, 3, 5); + let panel = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + egui::ScrollArea::vertical().id_salt("styles").max_height(80.0).show(ui, |ui| show(app, ui, kind)); + }; + let before = frame_events(&ctx, &mut app, vec![], panel); + let top = text_rect(&before, "Untouched").top() - 30.0; + set_pstate(&ctx, kind.key(), Some("Untouched".to_string())); + alt_click_new(&ctx, &mut app, kind); + set_name(&mut app, "Heading"); + crate::dialogs::confirm(&mut app).unwrap(); + let other = if kind == Kind::Char { Kind::Para } else { Kind::Char }; + frame_events(&ctx, &mut app, vec![], |app, ui| show(app, ui, other)); + assert_eq!(pstate::>(&ctx, other.key()), None, "{}: the other panel highlights nothing", kind.what()); + assert_eq!( + app.ui.made_text_style.first().map(|(_, _, name)| name.as_str()), + Some("Heading"), + "{}: the other panel leaves the new style pending", + kind.what() + ); + let mut after = vec![]; + for _ in 0..3 { + let time = ctx.input(|i| i.time) + 1.0; + after = frame_raw(&ctx, &mut app, egui::RawInput { time: Some(time), ..Default::default() }, panel); + } + let highlighted: Option = pstate(&ctx, kind.key()); + assert_eq!(highlighted.as_deref(), Some("Heading"), "{}: the new style replaces the old highlight", kind.what()); + assert!(app.ui.made_text_style.is_empty(), "{}: the panel consumes its pending highlight", kind.what()); + let row = text_rect(&after, "Heading"); + assert!(row.top() >= top && row.bottom() <= top + 80.0 + 30.0, "{}: Heading at {row:?}, panel from {top}", kind.what()); + } +} + +#[test] +fn new_character_and_paragraph_style_highlights_wait_for_their_panels() { + let (mut app, id, ctx) = capture_app(); + select_type_range(&mut app, id, 3, 5); + for (kind, name) in [(Kind::Char, "Heading Char"), (Kind::Para, "Heading Para")] { + open_new(&mut app, kind).unwrap(); + set_name(&mut app, name); + crate::dialogs::confirm(&mut app).unwrap(); + } + frame_events(&ctx, &mut app, vec![], |app, ui| show(app, ui, Kind::Char)); + assert_eq!(pstate::>(&ctx, Kind::Char.key()).as_deref(), Some("Heading Char")); + assert_eq!(app.ui.made_text_style.len(), 1); + frame_events(&ctx, &mut app, vec![], |app, ui| show(app, ui, Kind::Para)); + assert_eq!(pstate::>(&ctx, Kind::Para.key()).as_deref(), Some("Heading Para")); + assert!(app.ui.made_text_style.is_empty()); +} + +/// A style made in one document is highlighted only when that document's panel draws. +#[test] +fn a_new_style_dialog_highlight_waits_for_its_document() { + for kind in [Kind::Char, Kind::Para] { + let (mut app, id, ctx) = capture_app(); + let first_uid = app.session.active().unwrap().uid; + select_type_range(&mut app, id, 3, 5); + alt_click_new(&ctx, &mut app, kind); + set_name(&mut app, "Heading"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.made_text_style.first().map(|(uid, _, _)| *uid), Some(first_uid)); + + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Heading"})).unwrap(); + frame_events(&ctx, &mut app, vec![], |app, ui| show(app, ui, kind)); + assert_eq!(pstate::>(&ctx, kind.key()), None, "{}: the second document highlights nothing", kind.what()); + assert!(!app.ui.made_text_style.is_empty(), "{}: the first document's highlight stays pending", kind.what()); + + assert!(app.session.set_active(0)); + frame_events(&ctx, &mut app, vec![], |app, ui| show(app, ui, kind)); + assert_eq!(pstate::>(&ctx, kind.key()).as_deref(), Some("Heading"), "{}: the first document is highlighted", kind.what()); + assert!(app.ui.made_text_style.is_empty()); + } +} + +#[test] +fn a_closed_document_drops_its_pending_style_highlight() { + let (mut app, id, ctx) = capture_app(); + select_type_range(&mut app, id, 3, 5); + alt_click_new(&ctx, &mut app, Kind::Char); + set_name(&mut app, "Heading"); + crate::dialogs::confirm(&mut app).unwrap(); + + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("charStyle.new", json!({"name": "Heading"})).unwrap(); + app.session.execute("file.close", &json!({"index": 0})).unwrap(); + frame_events(&ctx, &mut app, vec![], |app, ui| show(app, ui, Kind::Char)); + assert_eq!(pstate::>(&ctx, Kind::Char.key()), None); + assert!(app.ui.made_text_style.is_empty(), "the closed document's highlight is discarded"); +} + /// OK makes the style from the text's attributes as they were when the dialog opened: a later /// format change of the Type selection, or a change of the selected objects, does not reach it. #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0409a78c7..72a8be32f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -520,6 +520,10 @@ pub struct UiState { /// dialog is open. #[serde(skip)] pub dialog_file: Option>, + /// Pending new style highlights: document uid, paragraph-style flag, and name. Each document + /// and style kind has at most one pending highlight. + #[serde(skip)] + pub made_text_style: Vec<(u64, bool, String)>, /// The documents (uids) whose Missing Fonts dialog waits until no dialog is open, the next one to /// show first ([`crate::dialogs::settle`]). #[serde(skip)] @@ -879,6 +883,7 @@ impl Default for UiState { svg_options: Value::Null, place_link: true, dialog_file: None, + made_text_style: vec![], pending_fonts: vec![], fonts_dialog: None, dialog_search: None,