Effects: Free Distort's corners are dragged in its dialog

Free Distort's dialog showed the four corners as read-only text, and
so did the Appearance panel's inline editor. Only effect.apply and
effect.setParams could set them.

The dialog now 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, and the preview follows. 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. Each handle has a
screen-reader name. On the corners row of the Appearance panel's
inline editor, Edit… opens the dialog.

Docs: docs/effects-parity.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 04:04:49 +07:00
committed by bflatastic
parent 940f425a01
commit fc0e926cda
9 changed files with 330 additions and 2 deletions
+5 -1
View File
@@ -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();
+172
View File
@@ -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);
}
+1
View File
@@ -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;
+122
View File
@@ -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::Event>) -> 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;
+5
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -736,6 +736,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, 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<usize>, 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<usize>, 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".
@@ -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();
+1 -1
View File
@@ -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) |
File diff suppressed because one or more lines are too long