diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index e95055f75..9fc39a09c 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -11,7 +11,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_color::Color; -use super::{DialogSpec, form, transform_each}; +use super::{DialogSpec, form, free_distort, transform_each}; use crate::panels::c32; use crate::state::Dialog; use crate::theme::Tokens; @@ -141,6 +141,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()), None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()), None if id == "artistic.filmGrain" => film_grain_fields(ui, d), + None if id == free_distort::FREE_DISTORT => { + free_distort::fields(app, ui, d); + values_changed(d) + } None => { let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); diff --git a/crates/ui-egui/src/dialogs/free_distort.rs b/crates/ui-egui/src/dialogs/free_distort.rs new file mode 100644 index 000000000..5d29c0a8c --- /dev/null +++ b/crates/ui-egui/src/dialogs/free_distort.rs @@ -0,0 +1,172 @@ +//! Free Distort's dialog body (Effect › Distort & Transform › Free Distort…): a box in the +//! proportions of the first selected object's bounds with a handle on each of its four corners. +//! Dragging a handle moves that corner. A handle focused by a click or Tab moves with the arrow +//! keys, and ten times as far with Shift. Reset puts every corner back on the box. The corners are +//! the effect's `corners` field, which the effect dialog previews and applies. + +use egui::{Key, Modifiers, Pos2, Rect, Sense, Stroke, Vec2, vec2}; +use serde_json::{Value, json}; + +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The Free Distort effect. +pub(crate) const FREE_DISTORT: &str = "distort.freeDistort"; + +/// The corners on the box: top left, top right, bottom right and bottom left, in unit box +/// coordinates. +pub(super) const IDENTITY: [[f64; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + +/// The area the handles move in. +const PAD: Vec2 = vec2(260.0, 200.0); +/// The space between the box and the edges of the area. +const MARGIN: f32 = 40.0; +/// The drawn size of a handle. +const HANDLE: f32 = 9.0; +/// The size of the square that takes a press on a handle. +const GRAB: f32 = 16.0; +/// How far an arrow key moves a corner, as a share of the box's side. +const STEP: f64 = 0.01; + +/// The handles' names for screen readers, in the order of the corners. +fn names() -> [&'static str; 4] { + [tl!("Top Left"), tl!("Top Right"), tl!("Bottom Right"), tl!("Bottom Left")] +} + +/// The corners in `d.fields["corners"]`. A missing or malformed corner reads as its place on the +/// box, as the effect reads it. +pub(super) fn corners(d: &Dialog) -> [[f64; 2]; 4] { + let mut c = IDENTITY; + if let Some(list) = d.fields.get("corners").and_then(Value::as_array) { + for (slot, v) in c.iter_mut().zip(list) { + if let Some(a) = v.as_array() + && let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) + && x.is_finite() + && y.is_finite() + { + *slot = [x, y]; + } + } + } + c +} + +/// The width of the first selected object's bounds over their height, limited to 1/8 to 8; 1 when +/// nothing is selected or the bounds are empty. +fn aspect(app: &VectorcraftApp) -> f32 { + let bounds = app.session.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))).and_then(|n| n.geometric_bounds()); + match bounds { + Some(b) if b.width() > 0.0 || b.height() > 0.0 => (b.width().max(1e-9) / b.height().max(1e-9)).clamp(0.125, 8.0) as f32, + _ => 1.0, + } +} + +/// A coordinate kept to four decimals (a hundredth of a percent of the box). +fn round(x: f64) -> f64 { + (x * 1e4).round() / 1e4 +} + +/// The box, the handles and Reset. Changes go to `d.fields["corners"]`. +pub(super) fn fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let before = corners(d); + let mut c = before; + let (pad, _) = ui.allocate_exact_size(PAD, Sense::hover()); + let inner = pad.shrink(MARGIN); + let aspect = aspect(app); + let size = if aspect >= inner.width() / inner.height() { + vec2(inner.width(), inner.width() / aspect) + } else { + vec2(inner.height() * aspect, inner.height()) + }; + let bx = Rect::from_center_size(inner.center(), size); + let to_screen = |[x, y]: [f64; 2]| Pos2::new(bx.left() + x as f32 * bx.width(), bx.top() + y as f32 * bx.height()); + let to_unit = |p: Pos2| [f64::from((p.x - bx.left()) / bx.width()), f64::from((p.y - bx.top()) / bx.height())]; + let (lo, hi) = (to_unit(pad.min), to_unit(pad.max)); + let names = names(); + // Per handle: whether it is held or hovered, and whether it has the keyboard focus. + let mut states = [(false, false); 4]; + for (i, corner) in c.iter_mut().enumerate() { + let id = ui.id().with(("free-distort-corner", i)); + let at = pad.clamp(to_screen(*corner)); + let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(GRAB)), id, Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), names[i])); + if resp.drag_started() || resp.clicked() { + resp.request_focus(); + } + if resp.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + { + // The offset from the press to the handle's center, kept for the drag. + ui.data_mut(|m| m.insert_temp(id, at - origin)); + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + let grip: Vec2 = ui.data(|m| m.get_temp(id)).unwrap_or_default(); + *corner = to_unit(pad.clamp(p + grip)).map(round); + } + if resp.has_focus() { + ui.memory_mut(|m| { + m.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + for (key, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)] { + // Shift first: a key without modifiers also matches the key with Shift. + for (modifiers, step) in [(Modifiers::SHIFT, STEP * 10.0), (Modifiers::NONE, STEP)] { + if ui.input_mut(|i| i.consume_key(modifiers, key)) { + // A corner stays inside the area, or, when it lies outside, no farther out. + let [x, y] = &mut *corner; + for (v, d, lo, hi) in [(x, dx, lo[0], hi[0]), (y, dy, lo[1], hi[1])] { + *v = round((*v + d * step).clamp(lo.min(*v), hi.max(*v))); + } + } + } + } + } + states[i] = (resp.dragged() || resp.hovered(), resp.has_focus()); + if resp.dragged() || resp.hovered() { + ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + } + ui.add_space(4.0); + let reset = ui + .add_enabled_ui(c != IDENTITY, |ui| { + let r = widgets::flat_button(ui, tl!("Reset"), 64.0); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset"))); + r + }) + .inner; + if reset.clicked() { + c = IDENTITY; + } + paint(ui, pad, bx, c.map(to_screen), states); + if c != before { + d.fields.insert("corners".into(), json!(c)); + } +} + +/// The pad: the box at rest, the distorted box with a grid across it, and the handles (filled in +/// the accent color while focused). +fn paint(ui: &egui::Ui, pad: Rect, bx: Rect, q: [Pos2; 4], states: [(bool, bool); 4]) { + let t = Tokens::get(ui.ctx()); + let painter = ui.painter_at(pad); + painter.rect_filled(pad, 2.0, t.input); + painter.rect_stroke(bx, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Middle); + let at = |u: f32, v: f32| q[0].lerp(q[1], u).lerp(q[3].lerp(q[2], u), v); + let faint = Stroke::new(1.0, t.text_dim.gamma_multiply(0.6)); + for k in 1..4 { + let f = k as f32 / 4.0; + painter.line_segment([at(f, 0.0), at(f, 1.0)], faint); + painter.line_segment([at(0.0, f), at(1.0, f)], faint); + } + painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.5, t.text))); + for (p, (active, focused)) in q.into_iter().zip(states) { + let r = Rect::from_center_size(pad.clamp(p), Vec2::splat(HANDLE)); + painter.rect_filled(r, 1.0, if focused { t.accent } else { t.panel }); + painter.rect_stroke(r, 1.0, Stroke::new(if active { 2.0 } else { 1.0 }, t.text), egui::StrokeKind::Middle); + if focused { + painter.rect_stroke(r.expand(3.0), 2.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Outside); + } + } + painter.rect_stroke(pad, 2.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 4bb017ae0..ee34495f1 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -35,6 +35,7 @@ pub mod flare_options; pub mod flatten; pub mod flattener_presets; mod form; +pub(crate) mod free_distort; pub mod freehand; mod gradient_stop; pub mod graph_data; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index c9570b1fc..46b211232 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -899,6 +899,128 @@ fn zig_zag_has_sliders_and_radio_buttons() { assert!(!lines.contains(&"Ridges:"), "{lines:?}"); } +/// Frames of the dialog layer on one context (so a drag and the keyboard focus last from frame to +/// frame), with the screen-reader tree turned on. +struct Frames { + ctx: egui::Context, + time: f64, +} + +impl Frames { + fn new() -> Self { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + Self { ctx, time: 0.0 } + } + + fn run(&mut self, app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { + self.time += 0.1; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 900.0)); + let input = egui::RawInput { events, time: Some(self.time), screen_rect: Some(screen), ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out + } + + /// The center of the button that screen readers name `label`. + fn button(&mut self, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 { + self.run(app, vec![]); + let out = self.run(app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button); + let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds"); + egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5) + } + + fn press(&mut self, app: &mut VectorcraftApp, at: egui::Pos2, pressed: bool) { + let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + self.run(app, vec![egui::Event::PointerMoved(at), button]); + } + + fn drag(&mut self, app: &mut VectorcraftApp, from: egui::Pos2, by: egui::Vec2) { + self.press(app, from, true); + for k in 1..=4 { + self.run(app, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]); + } + self.press(app, from + by, false); + } + + fn key(&mut self, app: &mut VectorcraftApp, key: egui::Key, modifiers: egui::Modifiers) { + let event = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + self.run(app, vec![egui::Event::ModifiersChanged(modifiers), event]); + self.run(app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + } +} + +/// The corners in the open dialog, and those of the first object's first effect. +fn free_distort_corners(app: &VectorcraftApp) -> (serde_json::Value, serde_json::Value) { + let s = app.session.active().unwrap(); + let node = s.doc.node(s.selection.objects[0]).unwrap(); + (app.ui.dialog.as_ref().unwrap().fields["corners"].clone(), node.appearance.effects[0].params["corners"].clone()) +} + +/// Free Distort's dialog moves the corner whose handle is dragged and leaves the other three; the +/// preview and OK apply them, and the arrow keys move the handle that a click focused. +#[test] +fn free_distort_drags_one_corner_and_previews_it() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("effect.dialog", json!({"effect": "distort.freeDistort"})).unwrap(); + let mut f = Frames::new(); + // The box keeps the object's 2:1 proportions: 180 by 90 screen points. + let top_right = f.button(&mut app, "Top Right"); + f.drag(&mut app, top_right, egui::vec2(27.0, 9.0)); + let want = json!([[0.0, 0.0], [1.15, 0.1], [1.0, 1.0], [0.0, 1.0]]); + assert_eq!(free_distort_corners(&app), (want.clone(), want.clone()), "the dragged corner alone moves, and the preview follows"); + let bottom_left = f.button(&mut app, "Bottom Left"); + f.press(&mut app, bottom_left, true); + f.press(&mut app, bottom_left, false); + f.key(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE); + f.key(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT); + let want = json!([[0.0, 0.0], [1.15, 0.1], [1.0, 1.0], [-0.01, 1.1]]); + assert_eq!(free_distort_corners(&app), (want.clone(), want.clone())); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + let s = app.session.active().unwrap(); + assert_eq!(s.doc.node(s.selection.objects[0]).unwrap().appearance.effects[0].params["corners"], want); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before, "one undo step"); +} + +/// An arrow key moves a corner that lies outside the dialog's area by one step, and Reset puts the +/// corners of an applied Free Distort back on the box; OK keeps that. +#[test] +fn free_distort_reset_puts_the_corners_back() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let moved = json!([[0.2, 0.1], [0.9, 0.0], [1.0, 3.0], [0.0, 1.0]]); + app.run("effect.apply", json!({"effect": "distort.freeDistort", "params": {"corners": moved}})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.freeDistort", "index": 0})).unwrap(); + let mut f = Frames::new(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], moved); + let bottom_right = f.button(&mut app, "Bottom Right"); + f.press(&mut app, bottom_right, true); + f.press(&mut app, bottom_right, false); + f.key(&mut app, egui::Key::ArrowUp, egui::Modifiers::NONE); + let stepped = json!([[0.2, 0.1], [0.9, 0.0], [1.0, 2.99], [0.0, 1.0]]); + assert_eq!(free_distort_corners(&app), (stepped.clone(), stepped)); + let reset = f.button(&mut app, "Reset"); + f.press(&mut app, reset, true); + f.press(&mut app, reset, false); + f.run(&mut app, vec![]); + let rest = json!(super::free_distort::IDENTITY); + assert_eq!(free_distort_corners(&app), (rest.clone(), rest.clone())); + confirm(&mut app).unwrap(); + let s = app.session.active().unwrap(); + let effects = &s.doc.node(s.selection.objects[0]).unwrap().appearance.effects; + assert_eq!((effects.len(), effects[0].params["corners"].clone()), (1, rest)); +} + #[test] fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() { use vectorcraft_doc::NodeId; diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a2894b475..2d25b08a9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4356,6 +4356,11 @@ theme Appearance Erscheinungsbild @msg give the path `id` geben Sie die `id` des Pfads an @msg invalid stroke widths ungültige Konturbreiten @msg object has no weighted stroke das Objekt hat keine Kontur mit einer gültigen positiven Stärke +# Free Distort: the names of the handles on the four corners of its box. + Top Left Oben links + Top Right Oben rechts + Bottom Right Unten rechts + Bottom Left Unten links # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Einheitlicher Eckenradius Top Left: Oben links: diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index fcafdedb5..77420c453 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -736,6 +736,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } } let mut change: Option<(String, Value)> = None; + let mut edit = false; let built_in = built_in_effect(&e.id); let (unit, relative) = (app.session.general_unit(), params.get("relative").and_then(Value::as_bool).unwrap_or(false)); egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| { @@ -788,6 +789,10 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: change = Some((key.clone(), json!(buf))); } } + // Free Distort's corners are dragged in its dialog. + _ if e.id == crate::dialogs::free_distort::FREE_DISTORT => { + edit |= widgets::flat_button(ui, tl!("Edit…"), 48.0).clicked(); + } other => { widgets::dim_label(ui, &other.to_string()); } @@ -798,6 +803,9 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: if let Some((key, v)) = change { app.run("effect.setParams", json!({"item": item, "index": k, "params": {key: v}})).ok(); } + if edit { + app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok(); + } } /// "offsetX" → "Offset X". diff --git a/crates/ui-egui/src/panels/tests_effectedit.rs b/crates/ui-egui/src/panels/tests_effectedit.rs index f000cbd00..8a7b4168b 100644 --- a/crates/ui-egui/src/panels/tests_effectedit.rs +++ b/crates/ui-egui/src/panels/tests_effectedit.rs @@ -72,6 +72,21 @@ fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() { assert_eq!(effects(&app, None)[0].params["size"], json!(9)); } +#[test] +fn free_distorts_inline_editor_opens_its_dialog_for_the_corners() { + let mut app = app_with_rect(); + let corners = json!([[0.2, 0.0], [1.0, 0.1], [1.0, 1.0], [0.0, 1.0]]); + run(&mut app, "effect.apply", json!({"effect": "distort.freeDistort", "params": {"corners": corners}})); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click(&ctx, &mut app, text_rect(&texts, "Edit…").center(), appearance::show); + let d = app.ui.dialog.clone().expect("Free Distort's dialog"); + assert_eq!((d.kind.as_str(), d.str("__effect"), d.fields["__index"].clone()), ("effect", "distort.freeDistort".into(), json!(0))); + assert_eq!(d.fields["corners"], corners); +} + #[test] fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() { let mut app = app_with_rect(); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 3670f615d..903992f92 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 2/3 | Outline Object | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | | Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ae118edeb..76517d720 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -502,6 +502,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html). Nearly unchanged appearance is expected when reducing points. Percentage/auto-simplify controls, the corner-angle UI and Show Original are not implemented; the checklist is partial, without raising scores. Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape. + - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type 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). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`).