G4: capture character styles from selected text

Co-authored-by: Codex <[email protected]>
This commit is contained in:
sanyamsingh001andCodex authored and bflatastic committed 2026-10-14 05:15:34 +07:00
1 parent 123b8ad8ea
commit 5f4da3beb2
3 files changed
+541 -9

No files matched your search

+112 -5
View File
@@ -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<String, Valu
}
}
/// The selected text's attributes: the Type tool range's start (`id`, `start`) or the first
/// selected text object's first run.
/// The selected text's attributes: the first selected character for a nonempty range,
/// 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>>> {
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<Option<Map<Stri
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));
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`.
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<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);
}
}
}
}
+425 -3
View File
@@ -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<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);
}
}
}
+4 -1
View File
@@ -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 |