Merge pull request #1634 from sanyamsingh001/codex/r61-style-range-fix

G4: capture character styles from selected text
This commit is contained in:
BFlat 2026-10-14 05:41:28 +07:00 committed by GitHub
commit a55453d89c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 541 additions and 9 deletions

View File

@ -58,7 +58,7 @@ macro_rules! style_cmds {
concat!($p, ".new"), concat!($p, ".new"),
concat!("New ", $what, " Style"), concat!("New ", $what, " Style"),
&["Window", "Type", $menu], &["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), |s, p| new(s, p, $kind),
true, true,
), ),
@ -74,7 +74,7 @@ macro_rules! style_cmds {
concat!($p, ".redefine"), concat!($p, ".redefine"),
concat!("Redefine ", $what, " Style"), concat!("Redefine ", $what, " Style"),
&["Window", "Type", $menu], &["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), |s, p| redefine(s, p, $kind),
true, true,
), ),
@ -266,8 +266,9 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
} }
} }
/// The selected text's attributes: the Type tool range's start (`id`, `start`) or the first /// The selected text's attributes: the first selected character for a nonempty range,
/// selected text object's first run. /// the insertion style at a lone `start`, or the first selected text object's first run.
/// Paragraph capture always samples the paragraph at `start`.
fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<String, Value>>> { fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<String, Value>>> {
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
let id = match id_param(p, "id") { let id = match id_param(p, "id") {
@ -277,7 +278,13 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) }; let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) };
let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)); let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
Ok(Some(match kind { Ok(Some(match kind {
Kind::Char => 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`. // The paragraph at `start`.
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, 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(); let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
assert_eq!(back.para_styles, d.para_styles); assert_eq!(back.para_styles, d.para_styles);
} }
fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option<usize>, 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, &params).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);
}
}
}
} }

View File

@ -7,7 +7,7 @@
use egui::{Sense, Ui, vec2}; use egui::{Sense, Ui, vec2};
use serde_json::{Map, Value, json}; 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 super::{pstate, selection_len, set_pstate};
use crate::VectorcraftApp; use crate::VectorcraftApp;
use crate::theme::Tokens; use crate::theme::Tokens;
@ -134,10 +134,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
24.0, 24.0,
) )
.clicked() .clicked()
&& let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({})))
{ {
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)); set_pstate(ui.ctx(), kind.key(), r["name"].as_str().map(str::to_string));
} }
}
if widgets::icon_button_enabled( if widgets::icon_button_enabled(
ui, ui,
"trash-2", "trash-2",
@ -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 has_text = current(app, kind).is_some() || selection_len(app) > 0;
let p = kind.prefix(); let p = kind.prefix();
if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) { 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(); 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) 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) 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 && let Some(n) = &sel
{ {
end_typing(app);
app.run(&format!("{p}.redefine"), target(app, kind, json!({ "name": n }))).ok(); 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use vectorcraft_doc::{NodeId, NodeKind};
use super::super::tests_appearance::{click, frame_events, text_rect};
#[test] #[test]
fn panels_draw_and_apply_headless() { 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)); 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}"); 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<String>)> {
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<String> = 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<egui::Event>) {
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<String> = 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);
}
}
} }

View File

@ -1,6 +1,6 @@
# Type parity # 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 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 [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 | | CJK: ruby, manual tate-chu-yoko | missing | #633 |
| Inline symbols in text | beyond Illustrator | not scored | | 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 ## Revision history
| Date | Change | Summary | | 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 | 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 | 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 | 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-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 | | 2026-10-12 | minor | Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged |