mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #1634 from sanyamsingh001/codex/r61-style-range-fix
G4: capture character styles from selected text
This commit is contained in:
3 files changed
+541
-9
No files matched your search
@@ -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, ¶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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user