diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index fc929e3c..c94bc774 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -58,7 +58,7 @@ macro_rules! style_cmds { concat!($p, ".new"), concat!("New ", $what, " Style"), &["Window", "Type", $menu], - "{name?, attrs?: {…} (default: those of the selected text, or of text `id` at `start`), id?, start?: byte} → {name}", + "{name?, attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start", |s, p| new(s, p, $kind), true, ), @@ -74,7 +74,7 @@ macro_rules! style_cmds { concat!($p, ".redefine"), concat!("Redefine ", $what, " Style"), &["Window", "Type", $menu], - "{name, id?, start?} from the selected text; users keep their overrides", + "{name, id?, start?: byte, end?: byte} from selected text; nonempty character ranges sample their first selected character, start alone keeps insertion affinity, paragraph styles sample start; users keep their overrides", |s, p| redefine(s, p, $kind), true, ), @@ -266,8 +266,9 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map Result>> { let d = &s.doc()?.doc; let id = match id_param(p, "id") { @@ -277,7 +278,13 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result char_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)), + Kind::Char => { + let style = match p.get("end").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)) { + Some(end) => vectorcraft_text::edit::insertion_style(&t.runs, at.min(end), at.max(end)), + None => vectorcraft_text::edit::style_at(&t.runs, at), + }; + char_attrs(&style) + } // The paragraph at `start`. Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)), })) @@ -738,4 +745,104 @@ mod tests { let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); assert_eq!(back.para_styles, d.para_styles); } + + fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option, expected_size: f64) { + let (mut s, id) = session_with_text(text); + s.execute("text.setStyle", &json!({"id": id, "size": 18})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": text.len(), "size": 34})).unwrap(); + if redefine { + s.execute("charStyle.new", &json!({"name": "Captured", "attrs": {"size": 24}})).unwrap(); + } + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let mut params = json!({"name": "Captured", "id": id, "start": start}); + if let Some(end) = end { + params["end"] = json!(end); + } + let command = if redefine { "charStyle.redefine" } else { "charStyle.new" }; + s.execute(command, ¶ms).unwrap(); + let st = s.doc().unwrap(); + let def = st.doc.char_styles.iter().find(|d| d.name == "Captured").unwrap(); + assert_eq!(def.attrs.get("size").and_then(Value::as_f64), Some(expected_size), "{command} {text:?} {start}..{end:?}"); + assert!(!def.attrs.contains_key("style_name")); + assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id)), "capture does not apply the definition"); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); + assert!(st.is_dirty()); + let after = st.doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().selection, selection); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.doc().unwrap().selection, selection); + } + + fn capture_boundary_ranges(redefine: bool) { + for (text, start, end, size) in [ + ("ABcdef", 2, 5, 34.0), + ("é漢字", 2, 5, 34.0), + ("ABcdef", 5, 2, 34.0), // command ranges are ordered before selected-character sampling + ("ABcdef", 1, 5, 18.0), // a mixed range samples its first selected character + ("ABcdef", 2, 2, 18.0), // collapsed range retains insertion affinity + ("ABcdef", 3, 3, 34.0), + ("ABcdef", 99, 100, 34.0), // beyond the text retains the final run + ] { + capture_boundary_command(redefine, text, start, Some(end), size); + } + } + + #[test] + fn char_style_new_range_capture_boundary() { + capture_boundary_ranges(false); + } + + #[test] + fn char_style_redefine_range_capture_boundary() { + capture_boundary_ranges(true); + } + + #[test] + fn char_style_start_only_capture_affinity_controls() { + for redefine in [false, true] { + for text in ["ABcdef", "é漢字"] { + for (start, size) in [(0, 18.0), (2, 18.0), (3, 34.0), (99, 34.0)] { + capture_boundary_command(redefine, text, start, None, size); + } + } + } + } + + #[test] + fn para_style_range_capture_samples_start_paragraph() { + for redefine in [false, true] { + for (start, end, justify) in [(0, 7, "Left"), (4, 7, "Right"), (4, 0, "Right")] { + let (mut s, id) = session_with_text("One\nTwo"); + s.execute("text.setStyle", &json!({"id": id, "justify": "left"})).unwrap(); + s.execute("text.setStyle", &json!({"id": id, "start": 4, "end": 7, "justify": "right"})).unwrap(); + if redefine { + s.execute("paraStyle.new", &json!({"name": "Captured", "attrs": {"justify": "Center"}})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let command = if redefine { "paraStyle.redefine" } else { "paraStyle.new" }; + s.execute(command, &json!({"name": "Captured", "id": id, "start": start, "end": end})).unwrap(); + let st = s.doc().unwrap(); + let def = st.doc.para_styles.iter().find(|d| d.name == "Captured").unwrap(); + assert_eq!(def.attrs["justify"], json!(justify), "paragraph capture still samples start, not the end or a normalized range"); + assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id))); + assert_eq!(st.selection, selection); + assert_eq!(st.history.undo.len(), undo + 1); + let after = st.doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + } + } + } } diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 2426b068..50d5fe87 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -7,7 +7,7 @@ use egui::{Sense, Ui, vec2}; use serde_json::{Map, Value, json}; -use super::character::{text_editing, text_style}; +use super::character::{end_typing, text_editing, text_style}; use super::{pstate, selection_len, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; @@ -134,9 +134,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { 24.0, ) .clicked() - && let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({}))) { - set_pstate(ui.ctx(), kind.key(), r["name"].as_str().map(str::to_string)); + end_typing(app); + if let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({}))) { + set_pstate(ui.ctx(), kind.key(), r["name"].as_str().map(str::to_string)); + } } if widgets::icon_button_enabled( ui, @@ -162,6 +164,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let has_text = current(app, kind).is_some() || selection_len(app) > 0; let p = kind.prefix(); 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 menu_item(ui, &crate::i18n::fmt(tl!("Duplicate {kind} Style"), &[("kind", kind.what_label())]), sel.is_some(), false) @@ -183,6 +187,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if menu_item(ui, &crate::i18n::fmt(tl!("Redefine {kind} Style"), &[("kind", kind.what_label())]), sel.is_some() && has_text, false) && let Some(n) = &sel { + end_typing(app); app.run(&format!("{p}.redefine"), target(app, kind, json!({ "name": n }))).ok(); } if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false) @@ -196,6 +201,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { #[cfg(test)] mod tests { use super::*; + use vectorcraft_doc::{NodeId, NodeKind}; + + use super::super::tests_appearance::{click, frame_events, text_rect}; #[test] fn panels_draw_and_apply_headless() { @@ -234,4 +242,418 @@ mod tests { let shown = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui, Kind::Char)); assert!(shown.lines().any(|line| line == "Automatic"), "no override after clearing: {shown}"); } + + fn capture_app() -> (VectorcraftApp, NodeId, egui::Context) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("charStyle.new", json!({"name": "Untouched", "attrs": {"size": 22}})).unwrap(); + let other = app.run("text.create", json!({"x": 20, "y": 100, "text": "Other", "size": 22})).unwrap()["id"].clone(); + app.run("charStyle.apply", json!({"name": "Untouched", "id": other})).unwrap(); + let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "ABcdef", "size": 18})).unwrap()["id"].as_u64().unwrap()); + app.run("text.setRangeStyle", json!({"id": id.0, "start": 2, "end": 6, "size": 34})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + assert_eq!(text_editing(&app), None); + (app, id, ctx) + } + + fn select_type_range(app: &mut VectorcraftApp, id: NodeId, start: usize, end: usize) { + // The same editing handshake and selection API as character::tests::scaled_range_fields_follow_the_type_tool_selection. + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": start, "end": end})); + super::super::character::end_typing(app); + assert_eq!(app.session.tool_options()["typing"], json!(false)); + assert!(!app.session.in_interaction(), "settle typing before taking the history baseline"); + assert_eq!(text_editing(app), Some((id, start, end))); + } + + /// Click production panel/menu content with the existing hover/press/release helper. + /// A live range guard and command counter distinguish a dispatch failure from a missed click or lost Type selection. + fn click_style_ui(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId, range: Option<(usize, usize)>, is_menu: bool, label: &str) { + let expected = range.map(|(start, end)| (id, start, end)); + let mut expected_target = json!({}); + if let Some((start, end)) = range { + expected_target["id"] = json!(id.0); + if end > start { + expected_target["start"] = json!(start); + expected_target["end"] = json!(end); + } + } + let commands = app.run_count; + let mut dispatched = false; + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + // On the release frame this is immediately before the production menu/row handles the click and builds its target. + assert_eq!(text_editing(app), expected, "Type selection before {label}"); + assert_eq!(target(app, Kind::Char, json!({})), expected_target, "actual panel target before {label}"); + let before = app.run_count; + if is_menu { + menu(app, ui, Kind::Char); + } else { + show(app, ui, Kind::Char); + } + if app.run_count != before { + assert!(!dispatched, "{label} dispatched more than once"); + assert_eq!(app.run_count, before.wrapping_add(1), "one command from {label}"); + dispatched = true; + } + }; + frame_events(ctx, app, vec![], &mut draw); + let texts = frame_events(ctx, app, vec![], &mut draw); + // widgets::menu_item prefixes an unchecked label with three spaces; the panel row does not. + let painted = if is_menu { format!(" {label}") } else { label.to_string() }; + let pos = text_rect(&texts, &painted).center(); + click(ctx, app, pos, &mut draw); + assert!(dispatched, "pointer click did not dispatch {label}; do not interpret this as parameter rejection"); + assert_eq!(app.run_count, commands.wrapping_add(1), "exactly one real panel command"); + assert_eq!(text_editing(app), expected, "Type selection after {label}"); + } + + fn text_runs(app: &VectorcraftApp, id: NodeId) -> Vec<(String, f64, Option)> { + let st = app.session.doc().unwrap(); + let NodeKind::Text(text) = &st.doc.node(id).unwrap().kind else { panic!("expected text") }; + text.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.style_name.clone())).collect() + } + + fn capture_from_menu(range: Option<(usize, usize)>, redefine: bool) { + capture_from_ui(range, redefine, false, false); + } + + 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") }; + let len = first.len() + tail.len(); + if unicode { + app.run("text.setText", json!({"id": id.0, "text": format!("{first}{tail}")})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id.0, "start": first.len(), "end": len, "size": 34})).unwrap(); + } + let mut follower = None; + let mut local_override = None; + if redefine { + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + // Select the definition through its real panel row; no test-only panel selection is installed. + click_style_ui(&ctx, &mut app, id, None, false, "Body"); + let selected: Option = pstate(&ctx, Kind::Char.key()); + assert_eq!(selected.as_deref(), Some("Body")); + for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { + let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); + app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); + *slot = Some(user); + } + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": len, "size": 18})).unwrap(); + app.run("text.setRangeStyle", json!({"id": id.0, "start": first.len(), "end": len, "size": 34})).unwrap(); + } + let link = redefine.then(|| "Body".to_string()); + assert_eq!(text_runs(&app, id), vec![(first.into(), 18.0, link.clone()), (tail.into(), 34.0, link)]); + if let Some((start, end)) = range { + select_type_range(&mut app, id, start, end); + } + assert!(!app.session.in_interaction()); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + assert!(!app.session.doc().unwrap().is_dirty()); + let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; + if footer { + click_new_style_footer_range(&ctx, &mut app, id, range.expect("footer fixture has a range")); + } else { + click_style_ui(&ctx, &mut app, id, range, true, label); + } + let after = app.session.doc().unwrap().doc.clone(); + let name = if redefine { "Body" } else { "Character Style 1" }; + 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 }; + let count = before.char_styles.len() + usize::from(!redefine); + let history = &app.session.doc().unwrap().history; + assert_eq!( + (after.char_styles.len(), size, history.undo.len(), history.redo.len()), + (count, Some(expected_size), undo + 1, 0), + "{label} dispatched with live range {range:?}; capture must use the selected start and make one style edit" + ); + assert!(!definition.unwrap().attrs.contains_key("style_name")); + for old in &before.char_styles { + if old.name != name { + assert_eq!(after.char_styles.iter().find(|s| s.name == old.name), Some(old)); + } + } + // New captures without applying. Redefine changes its unmodified follower, preserving overrides and unrelated text. + before.walk(|node| { + if matches!(&node.kind, NodeKind::Text(_)) && Some(node.id) != follower { + assert_eq!(after.node(node.id), Some(node)); + } + }); + if let Some(user) = follower { + assert_eq!(text_runs(&app, user), vec![("Follower".into(), expected_size, Some("Body".into()))]); + } + if let Some(user) = local_override { + assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); + } + assert_eq!(app.session.doc().unwrap().selection, selection); + assert!(app.session.doc().unwrap().is_dirty()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert!(!app.session.doc().unwrap().is_dirty()); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + assert!(app.session.doc().unwrap().is_dirty()); + } + + // Start 3 is inside the second run; style_at chooses the preceding run at an exact boundary. + #[test] + fn selected_type_range_new_character_style_from_menu() { + capture_from_menu(Some((3, 5)), false); + } + + #[test] + fn selected_type_range_redefine_character_style_from_menu() { + capture_from_menu(Some((3, 5)), true); + } + + #[test] + fn new_character_style_object_and_caret_controls() { + // The current collapsed target omits start even at caret 3, so capture still uses object-start size 18. + for range in [None, Some((3, 3))] { + capture_from_menu(range, false); + } + } + + #[test] + fn redefine_character_style_object_and_caret_controls() { + for range in [None, Some((3, 3))] { + capture_from_menu(range, true); + } + } + + #[test] + fn character_style_range_apply_from_panel_control() { + let (mut app, id, ctx) = capture_app(); + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 26}})).unwrap(); + select_type_range(&mut app, id, 3, 5); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + click_style_ui(&ctx, &mut app, id, Some((3, 5)), false, "Body"); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!( + text_runs(&app, id), + vec![("AB".into(), 18.0, None), ("c".into(), 34.0, None), ("de".into(), 26.0, Some("Body".into())), ("f".into(), 34.0, None)] + ); + assert_eq!(after.char_styles, before.char_styles, "Apply does not redefine Body"); + before.walk(|node| { + if node.id != id && matches!(&node.kind, NodeKind::Text(_)) { + assert_eq!(after.node(node.id), Some(node)); + } + }); + assert_eq!((app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()), (undo + 1, 0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + } + + /// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing + /// settlement here: the capture dispatch itself must close the live typing interaction. + fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { + frame_events(ctx, app, events, |app, ui| { + crate::shortcuts::handle(app, ui.ctx()); + menu(app, ui, Kind::Char); + }); + } + + fn click_new_style_footer(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId) { + click_new_style_footer_range(ctx, app, id, (0, 5)); + } + + fn click_new_style_footer_range(ctx: &egui::Context, app: &mut VectorcraftApp, id: NodeId, range: (usize, usize)) { + let (start, end) = range; + let pos = std::cell::Cell::new(egui::Pos2::ZERO); + let commands = app.run_count; + let mut dispatched = false; + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + assert_eq!(text_editing(app), Some((id, start, end))); + assert_eq!(target(app, Kind::Char, json!({})), json!({"id": id.0, "start": start, "end": end})); + ui.set_width(280.0); + let right = ui.max_rect().right(); + let before = app.run_count; + show(app, ui, Kind::Char); + // The final row is the production bottom bar: New and Delete are its two 24-point + // buttons, four points apart. Use its laid-out bottom/right, not screen coordinates. + pos.set(egui::pos2(right - 40.0, ui.min_rect().bottom() - 12.0)); + if app.run_count != before { + assert!(!dispatched, "footer New dispatched more than once"); + assert_eq!(app.run_count, before.wrapping_add(1)); + dispatched = true; + } + }; + frame_events(ctx, app, vec![], &mut draw); + frame_events(ctx, app, vec![], &mut draw); + click(ctx, app, pos.get(), &mut draw); + assert!(dispatched, "pointer click did not dispatch footer New"); + assert_eq!(app.run_count, commands.wrapping_add(1)); + let selected: Option = pstate(ctx, Kind::Char.key()); + assert_eq!(selected.as_deref(), Some("Character Style 1"), "production footer stores the new definition"); + assert_eq!(text_editing(app), Some((id, start, end)), "footer capture preserves the Type range"); + } + + fn capture_during_live_typing(redefine: bool, footer: bool) { + let (mut app, id, ctx) = capture_app(); + let mut follower = None; + let mut local_override = None; + if redefine { + app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); + click_style_ui(&ctx, &mut app, id, None, false, "Body"); + for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] { + let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap()); + app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap(); + app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap(); + *slot = Some(user); + } + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + // This override is already the captured size, so Redefine does not alter the edited + // runs and accidentally make TextTool::replace restart the typing interaction. + app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap(); + } + let link = redefine.then(|| "Body".to_string()); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert_eq!(text_editing(&app), Some((id, 0, 6))); + capture_input(&ctx, &mut app, vec![egui::Event::Text("Typed".into())]); + assert_eq!(text_runs(&app, id), vec![("Typed".into(), 18.0, link.clone())]); + assert_eq!(text_editing(&app), Some((id, 5, 5))); + assert!(app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + let typed = app.session.doc().unwrap().doc.clone(); + + // Ctrl+A/Cmd+A is Select All's real command route, including key release; it must leave + // the typing interaction live until the subsequent production menu dispatch. + for pressed in [true, false] { + capture_input( + &ctx, + &mut app, + vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }], + ); + } + assert_eq!(text_editing(&app), Some((id, 0, 5))); + assert!(app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + assert_eq!(app.session.doc().unwrap().selection, selection); + let label = if redefine { "Redefine Character Style" } else { "New Character Style…" }; + if footer { + click_new_style_footer(&ctx, &mut app, id); + } else { + click_style_ui(&ctx, &mut app, id, Some((0, 5)), true, label); + } + let captured = app.session.doc().unwrap().doc.clone(); + let capture_history = (app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len()); + let capture_settled = !app.session.in_interaction() && app.session.tool_options()["typing"] == json!(false); + let name = if redefine { "Body" } else { "Character Style 1" }; + let definition = captured.char_styles.iter().find(|s| s.name == name).unwrap(); + assert_eq!(definition.attrs.get("size").and_then(Value::as_f64), Some(18.0)); + assert_eq!(captured.char_styles.len(), typed.char_styles.len() + usize::from(!redefine)); + assert_eq!(text_runs(&app, id), vec![("Typed".into(), 18.0, link.clone())]); + assert_eq!(app.session.doc().unwrap().selection, selection); + typed.walk(|node| { + if matches!(&node.kind, NodeKind::Text(_)) && Some(node.id) != follower { + assert_eq!(captured.node(node.id), Some(node)); + } + }); + if let Some(user) = follower { + assert_eq!(text_runs(&app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]); + } + if let Some(user) = local_override { + assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]); + } + + // Check persistence before the history assertions: RED must expose the lost definition, + // not merely report that typing was still live after capture. + capture_input(&ctx, &mut app, vec![egui::Event::Text("After".into())]); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(text_runs(&app, id), vec![("After".into(), 18.0, link)]); + assert_eq!(after.char_styles, captured.char_styles, "further typing must preserve the captured definition"); + captured.walk(|node| { + if node.id != id && matches!(&node.kind, NodeKind::Text(_)) { + assert_eq!(after.node(node.id), Some(node), "further typing preserves followers and unrelated text"); + } + }); + assert!(capture_settled, "capture ends the prior typing interaction without ending text editing"); + assert_eq!(capture_history, (undo + 2, 0), "typing and capture are separate history entries"); + assert_eq!(text_editing(&app), Some((id, 5, 5))); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert!(app.session.in_interaction(), "further text starts its own typing interaction"); + assert_eq!(app.session.tool_options()["typing"], json!(true)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + + // Undo itself commits the new live typing interaction. Three separate snapshots prove + // that typing, capture and further typing can each be undone/redone independently. + for (step, expected) in [&captured, &typed, &before].into_iter().enumerate() { + let result = app.run("edit.undo", json!({})).unwrap(); + if step != 1 { + assert_eq!(result["undone"], json!("Typing")); + } + let st = app.session.doc().unwrap(); + assert_eq!(&st.doc, expected); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1)); + assert_eq!(st.is_dirty(), step != 2); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.tool_options()["typing"], json!(false)); + } + for (step, expected) in [&typed, &captured, &after].into_iter().enumerate() { + let result = app.run("edit.redo", json!({})).unwrap(); + if step != 1 { + assert_eq!(result["redone"], json!("Typing")); + } + let st = app.session.doc().unwrap(); + assert_eq!(&st.doc, expected); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step)); + assert!(st.is_dirty()); + } + } + + #[test] + fn new_character_style_survives_live_typing_and_undo_redo() { + capture_during_live_typing(false, false); + } + + #[test] + fn redefine_character_style_survives_live_typing_and_undo_redo() { + capture_during_live_typing(true, false); + } + + #[test] + fn new_character_style_from_footer_survives_live_typing_and_undo_redo() { + capture_during_live_typing(false, true); + } + + #[test] + fn character_style_boundary_new_from_menu() { + capture_from_menu(Some((2, 5)), false); + } + + #[test] + fn character_style_boundary_redefine_from_menu() { + capture_from_menu(Some((2, 5)), true); + } + + #[test] + fn character_style_boundary_new_from_footer() { + capture_from_ui(Some((2, 5)), false, true, false); + } + + #[test] + fn character_style_unicode_boundary_capture_controls() { + // Byte 2 is the start of 漢 after é; 2..5 selects exactly that complete character. + for (redefine, footer) in [(false, false), (true, false), (false, true)] { + capture_from_ui(Some((2, 5)), redefine, footer, true); + } + } } diff --git a/docs/type-parity.md b/docs/type-parity.md index 6b65be58..7673cab7 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -53,12 +53,15 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: ruby, manual tate-chu-yoko | missing | #633 | | Inline symbols in text | beyond Illustrator | not scored | +Character Styles menu New/Redefine and footer New capture the first selected character of a nonempty Type selection, including exact style-run and UTF-8 boundaries; New adds a definition without applying it. New/Redefine commands accept optional `end` for range capture. Start-only command affinity, existing UI caret/object behavior and paragraph capture at `start` remain unchanged. Pointer regressions assert captured definitions, linked-text updates and local overrides, complete-document Undo/Redo and saved state, with whole-object, collapsed-caret and range-Apply controls. Capture closes prior typing while preserving the Type selection. Live text and Select All regressions exercise menu New/Redefine and footer New, asserting that further typing retains the captured definition and that typing, capture and further typing have separate Undo/Redo steps. Focused engine and egui input tests pass for boundary capture, all three live-typing capture routes and the existing compatibility controls. Full workspace CI passes. + ## Revision history | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | Paragraph/Properties alignment buttons show mixed alignment without changing range commands; headless regressions, full integration CI and visual review pass; other mixed paragraph fields, scores and hours unchanged | | 2026-10-13 | minor | The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); scores and hours unchanged | +| 2026-10-13 | minor | Character Styles range capture samples the first selected character at run boundaries and closes prior typing; boundary and typing-history input tests and full workspace CI pass; lifecycle and range controls retained; scores and hours unchanged | | 2026-10-13 | minor | The commands that make or restyle type, and MCP `add_text`, report area type that does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471); a tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458); scores and hours unchanged | | 2026-10-13 | minor | Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444); text frames, wrap shapes and type paths with coordinates beyond a billion points hold no text, and layout limits its search for room, instead of hanging (#1451); scores and hours unchanged | | 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged |