From d7da446cd7aa5a10fbd6c1de19ff5c83ae92c252 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:39:15 -0800 Subject: [PATCH 1/2] Text Wrap: Offset is a distance in the document's units Text Wrap Options showed Offset as a plain number in points. It is now a distance field in the document's General unit: a bare number is in that unit, a typed unit is read as typed, and OK sends points. The field's range is the one object.textWrap.options keeps an Offset to, -1000 to 1000 pt, read from the engine's TEXT_WRAP_OFFSET_RANGE. A value typed outside it, or text that doesn't read, is refused: the field flashes and the dialog stays open. The ends as the field shows them in another unit (352.7778 mm for 1000 pt) read back as the ends. In the dialogs drawn by the shared dialog window, a click on OK no longer confirms the dialog when the field it takes the focus from refuses its text, as Enter already did not. OK pressed from the keyboard after such a click still confirms. --- crates/engine/src/cmd/textwrap.rs | 6 +- crates/engine/src/lib.rs | 1 + crates/ui-egui/src/dialogs.rs | 17 +++- crates/ui-egui/src/dialogs/command.rs | 137 ++++++++++++++++++++++++++ crates/ui-egui/src/dialogs/form.rs | 9 +- crates/ui-egui/src/widgets.rs | 12 +-- crates/ui-egui/src/widgets/numeric.rs | 29 +++++- crates/ui-egui/src/widgets/text.rs | 15 ++- docs/target-app-parity.md | 1 + 9 files changed, 212 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index c7c9adb261..d5eefe9d4d 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -34,8 +34,12 @@ pub fn specs() -> Vec { ] } +/// The Offset a wrap object takes, in points. +pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive = -1000.0..=1000.0; + fn wrap_param(p: &Value, base: TextWrap) -> TextWrap { - TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) } + let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end()); + TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) } } fn make(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e3f2b91ee7..4b47966463 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,6 +33,7 @@ pub use cmd::params::{check_keys, check_params}; pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step}; +pub use cmd::textwrap::TEXT_WRAP_OFFSET_RANGE; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use cmd::{EyedropperOptions, PathfinderOptions}; pub use journal::Journal; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 552fbc0c06..1f3a01528c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -783,6 +783,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { return window(app, ctx); } let mut ok = false; + let mut ok_clicked = false; let mut ok_enabled = true; let mut cancel = false; let mut reset = false; @@ -843,7 +844,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app))) && widgets::primary_button_enabled(ui, label, ok_enabled).clicked() { - ok = true; + ok_clicked = true; } ui.add_space(8.0); // While Alt (Option) is held, Cancel reads Reset on a dialog that has one. @@ -878,6 +879,20 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }); let rejected = ctx.data_mut(|d| d.remove_temp::(widgets::dialog_reject_commit_flag())).unwrap_or(false); + // A field that refused its text when the press on OK took its focus keeps the dialog open, as + // Enter does. Only a pointer click has such a press; the keyboard and screen readers press OK + // in one frame. + let press = egui::Id::unique("dialog-press-pass"); + let (pressed, released) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_released())); + if pressed { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|m| m.insert_temp(press, pass)); + } + let (pressed_at, refused_at) = ctx.data(|m| (m.get_temp::(press), m.get_temp::(widgets::dialog_reject_pass()))); + let refused_during_click = released && matches!((refused_at, pressed_at), (Some(r), Some(p)) if r >= p); + if ok_clicked && !rejected && !refused_during_click { + ok = true; + } if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) { ok = true; } diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index cb23628a4c..3f591ccd30 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -128,10 +128,20 @@ fn lengths(command: &str) -> &'static [&'static str] { "type.pathOptions" => &["spacing"], "object.pathfinder.trap" => &["thickness"], crate::addons::cutting::BRIDGES => &["width"], + "object.textWrap.options" => &["offset"], _ => &[], } } +/// The range (points) of a distance parameter of the commands this dialog edits, where the +/// command has one: the field refuses a distance typed outside it. +pub(super) fn length_range(command: &str, key: &str) -> Option> { + match (command, key) { + ("object.textWrap.options", "offset") => Some(vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE), + _ => None, + } +} + /// The order the fields of the commands this dialog edits come in, where the order of their /// names isn't it (the rest follow, by name). fn order(command: &str) -> &'static [&'static str] { @@ -505,4 +515,131 @@ mod tests { assert_eq!(crate::panels::pattern_options::repeat_fields(&app).unwrap()["instances"], 4); assert!(!app.session.in_interaction()); } + + /// Text Wrap Options › Offset is a distance, and its range is the one `object.textWrap.options` + /// keeps an Offset to. + #[test] + fn text_wrap_offset_is_a_distance_within_the_commands_range() { + assert!(lengths("object.textWrap.options").contains(&"offset")); + assert!(!lengths("object.textWrap.options").contains(&"invert")); + let range = length_range("object.textWrap.options", "offset").expect("a range"); + assert_eq!(range, vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE); + assert_eq!(length_range("object.textWrap.options", "invert"), None); + assert_eq!(length_range("text.areaOptions", "inset"), None); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + for (sent, end) in [(5000.0, *range.end()), (-5000.0, *range.start())] { + assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": sent })).unwrap()["offset"], json!(end)); + } + } + + /// Replaces the text of the dialog's Offset field with `text`, leaving the field focused. + fn type_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + h.get_by_role_and_label(Role::TextInput, "Offset:").focus(); + h.run_steps(2); + h.key_press_modifiers(all, egui::Key::A); + h.get_by_role_and_label(Role::TextInput, "Offset:").type_text(text); + h.run_steps(2); + } + + /// Replaces the text of the dialog's Offset field with `text` and presses Enter. + fn enter_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) { + type_offset(h, text); + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + + /// Text Wrap Options › Offset reads a bare number in the document's units and a typed unit as + /// typed, and OK sends points. A distance typed outside the command's range, or text that + /// doesn't read, is refused: the dialog stays open and the Offset isn't changed. + #[test] + fn text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("document.setUnits", json!({"units": "millimeters"})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap.make", json!({})).unwrap(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + let offset = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| { + h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap() + }; + // The ends as the field shows them in millimeters (352.7778 mm is 1000.00006 pt) read + // back as the ends. + let ends = [("-1000 pt", -1000.0), ("352.7778", 1000.0), ("-352.7778", -1000.0), ("1000 pt", 1000.0)]; + for (text, want) in [("10", 10.0 * 72.0 / 25.4), ("-12 pt", -12.0)].into_iter().chain(ends) { + crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({})); + h.run_steps(3); + enter_offset(&mut h, text); + assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it"); + assert!((offset(&mut h) - want).abs() < 1e-9, "{text}: {} pt", offset(&mut h)); + } + for text in ["1000.5 pt", "-2000", "abc"] { + crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({})); + h.run_steps(3); + enter_offset(&mut h, text); + let d = h.state().ui.dialog.as_ref().unwrap_or_else(|| panic!("{text}: the dialog stays open")); + assert_eq!(d.fields.get("offset"), Some(&json!(1000.0)), "{text}: the field keeps its value"); + crate::dialogs::cancel(h.state_mut()); + assert_eq!(offset(&mut h), 1000.0, "{text}: the Offset is unchanged"); + } + // A click on OK with a value outside the range still in the field is refused too. + crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({})); + h.run_steps(3); + type_offset(&mut h, "2000 pt"); + click_ok(&mut h); + assert!(h.state().ui.dialog.is_some(), "OK leaves the dialog open"); + assert_eq!(offset(&mut h), 1000.0, "the Offset is unchanged"); + // After it, a value inside the range committed with Tab and OK pressed from the keyboard + // apply. + type_offset(&mut h, "-7 pt"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + { + use egui_kittest::kittest::Queryable; + h.get_by_label("OK").focus(); + } + h.run_steps(2); + h.key_press(egui::Key::Space); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none(), "OK from the keyboard applied it"); + assert_eq!(offset(&mut h), -7.0); + // One inside it applies. + crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({})); + h.run_steps(3); + type_offset(&mut h, "-5 pt"); + click_ok(&mut h); + assert!(h.state().ui.dialog.is_none(), "OK applied it"); + assert_eq!(offset(&mut h), -5.0); + } + + /// Clicks the dialog's OK button with the pointer, the press and the release in frames of + /// their own. + fn click_ok(h: &mut egui_kittest::Harness<'_, VectorcraftApp>) { + use egui_kittest::kittest::Queryable; + let ok = h.get_by_label("OK").rect().center(); + let (button, modifiers) = (egui::PointerButton::Primary, egui::Modifiers::default()); + h.event(egui::Event::PointerMoved(ok)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: ok, button, pressed: true, modifiers }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: ok, button, pressed: false, modifiers }); + h.run_steps(3); + } } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index bbddd222f5..033e194e50 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -453,7 +453,14 @@ pub(super) fn param_fields( if is_length(&k) { // An effect's documented range keeps the field to the distance it applies. let range = vectorcraft_effects::length_range(&d.str("__effect"), &k); - if spinners { + // A command's own range refuses a distance typed outside it. + if let Some(bounds) = super::command::length_range(&d.str("__command"), &k) { + let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0)); + if let Some(x) = crate::widgets::num_field_refusing(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) { + d.fields.insert(k.clone(), json!(x)); + changed = true; + } + } else if spinners { let bounds = range.unwrap_or(if k == "blur" { 0.0..=f64::INFINITY } else { crate::widgets::ANY }); let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0)); if let Some(x) = crate::widgets::spin_length_within(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4e1bc4f6a5..36145224d5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -56,18 +56,18 @@ pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_labe pub(crate) use labels::{label_field, take_previous_label, text_width}; pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit}; pub use numeric::{ - SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, num_field_within_empty, opt_field, - opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped, size_field, size_field_named, slider, - spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning, spin_length_within, spin_plain, - spin_plain_named, spin_range, + SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_refusing, num_field_within, + num_field_within_empty, opt_field, opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped, + size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning, + spin_length_within, spin_plain, spin_plain_named, spin_range, }; #[cfg(test)] use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth}; pub(crate) use panel_chrome::{PANEL_FOOTER, panel_footer, panel_footer_bottom, panel_footer_space, panel_list_height}; pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar}; pub(crate) use text::{ - arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter, - reject_dialog_commit, set_dialog_open, take_dialog_focus, + arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, dialog_reject_pass, disarm_field_focus, enter_modifiers, focus_field, + own_field_shift_enter, reject_dialog_commit, set_dialog_open, take_dialog_focus, }; pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 3eec21b928..89ce8d70d5 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -67,6 +67,21 @@ pub fn num_field_within( unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false, "") } +/// [`num_field_within`] that refuses a value typed outside `bounds` (points), as it refuses text +/// that doesn't read: the field flashes, keeps its value and holds the dialog open. ↑/↓, the wheel +/// and a scrub still stop at the ends. +pub fn num_field_refusing( + ui: &mut Ui, + id: impl std::hash::Hash, + value: Option, + unit: Unit, + width: f32, + bounds: RangeInclusive, +) -> Option { + let label = take_previous_label(ui); + unit_field_from(ui, id, label, None, value, unit, width, false, bounds, &[], false, None, "", true) +} + /// [`num_field_within`] whose empty value reads `empty` (Transform Each's Absolute scales read /// "Mixed" when the selected objects differ): typing replaces it, as it replaces the value shown. pub fn num_field_within_empty( @@ -122,11 +137,12 @@ fn unit_field( live: bool, empty: &str, ) -> Option { - unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty) + unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty, false) } /// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with -/// ↑/↓ and the wheel instead of doing nothing. +/// ↑/↓ and the wheel instead of doing nothing. With `refuse_outside`, a value typed outside +/// `bounds` is refused as text that doesn't read is, rather than kept at the nearer end. #[allow(clippy::too_many_arguments)] fn unit_field_from( ui: &mut Ui, @@ -142,6 +158,7 @@ fn unit_field_from( live: bool, blank_from: Option, empty: &str, + refuse_outside: bool, ) -> Option { let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.format(v)).unwrap_or_else(|| empty.to_string()); @@ -163,7 +180,11 @@ fn unit_field_from( let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit); let commit = commit && buf != shown; if commit { - match read(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None }) { + let typed_value = read(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None }); + // A refused value is one outside `bounds` that the field doesn't show as an end: the end + // shown in the field's unit ("352.7778 mm" for 1000 pt) reads back as that end. + let typed_value = typed_value.filter(|&v| !refuse_outside || bounds.contains(&v) || unit.format(within(v, &bounds)) == unit.format(v)); + match typed_value { Some(v) => { let clamped = within(v, &bounds); let formatted = unit.format(clamped); @@ -1017,7 +1038,7 @@ pub fn spin_field_stops_named( let stops = if value.is_none() { &BLANK_WEIGHT_STOPS[..] } else { presets }; let fmt = |v: f64| unit.format(v); spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, stops, &fmt, &mut |ui, fw, label| { - unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "") + unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "", false) }) .and_then(SpinPick::value) } diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index eec3cd3a94..8a9bfe1b8d 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -94,9 +94,20 @@ pub(crate) fn dialog_reject_commit_flag() -> egui::Id { egui::Id::unique("dialog-reject-commit") } -/// Suppress confirming an open dialog on Enter during this frame when an invalid value was committed. +/// The pass (`cumulative_pass_nr`) in which a field last rejected an invalid commit: a click on a +/// dialog's OK whose press came at or before it doesn't confirm the dialog. +pub(crate) fn dialog_reject_pass() -> egui::Id { + egui::Id::unique("dialog-reject-pass") +} + +/// Suppress confirming an open dialog on Enter during this frame, or by the click on OK that took +/// the focus from the field, when an invalid value was committed. pub(crate) fn reject_dialog_commit(ctx: &egui::Context) { - ctx.data_mut(|d| d.insert_temp(dialog_reject_commit_flag(), true)); + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + d.insert_temp(dialog_reject_commit_flag(), true); + d.insert_temp(dialog_reject_pass(), pass); + }); } /// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26681e5b7f..5ae03e1093 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1286,6 +1286,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests). + Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. From aef3acd51bdf65032f0aee27d914aeb6244e05da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:44:37 -0800 Subject: [PATCH 2/2] Text Wrap: Offset in picas reads bare numbers as the other length fields do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a document in picas, a bare number typed in Text Wrap Options › Offset follows Preferences › Units › Numbers Without Units Are Points, as every length field drawn by the shared unit field does: points while it is on (the default), picas while it is off. A test covers both settings, and the evidence line in docs/target-app-parity.md states the rule. --- crates/ui-egui/src/dialogs/command.rs | 37 +++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 3f591ccd30..6656b45bf5 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -629,6 +629,43 @@ mod tests { assert_eq!(offset(&mut h), -5.0); } + /// In a document in picas, Text Wrap Options › Offset reads a bare number as the other length + /// fields in picas do: in points with Preferences › Units › Numbers Without Units Are Points on + /// (the default), in picas with it off. A typed unit is read as typed either way. + #[test] + fn text_wrap_offset_in_picas_follows_numbers_without_units_are_points() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("document.setUnits", json!({"units": "picas"})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("object.textWrap.make", json!({})).unwrap(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + let apply = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str| { + crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({})); + h.run_steps(3); + enter_offset(h, text); + assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it"); + h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap() + }; + assert_eq!(apply(&mut h, "10"), 10.0, "on by default: a bare number is in points"); + assert_eq!(apply(&mut h, "2p6"), 30.0, "picas and points as typed"); + assert_eq!(apply(&mut h, "-1in"), -72.0, "a typed unit wins"); + crate::widgets::set_bare_numbers_are_points(&h.ctx, false); + assert_eq!(apply(&mut h, "10"), 120.0, "off: a bare number is in picas"); + assert_eq!(apply(&mut h, "-12 pt"), -12.0, "a typed unit wins"); + } + /// Clicks the dialog's OK button with the pointer, the press and the release in frames of /// their own. fn click_ok(h: &mut egui_kittest::Harness<'_, VectorcraftApp>) { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ae03e1093..6cd911988c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1286,7 +1286,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests). - Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html). + Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. In a document in picas, a bare number follows Preferences › Units › Numbers Without Units Are Points as the other length fields do: it is in points while that preference is on (the default) and in picas while it is off (`dialogs::command::tests::text_wrap_offset_in_picas_follows_numbers_without_units_are_points`). An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.