mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
Merge pull request #2053 from sanyamsingh001/codex/r76-warp-options
G1: Add Warp Options controls for live effects
This commit is contained in:
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -301,12 +301,23 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !params.is_null() && !params.is_object() {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
let warp_id = p
|
||||
.get("warpStyle")
|
||||
.map(|v| -> Result<String> {
|
||||
let style = v.as_str().filter(|style| effects::WarpStyle::from_id(style).is_some()).ok_or_else(|| bad(C, "unknown Warp style"))?;
|
||||
Ok(format!("warp.{style}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
}
|
||||
if let Some(e) = effect
|
||||
&& effects::is_three_d(&e.id)
|
||||
&& visible != Some(false)
|
||||
{
|
||||
@@ -323,6 +334,9 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let Some(id) = &warp_id {
|
||||
e.id.clone_from(id);
|
||||
}
|
||||
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) {
|
||||
if !cur.is_object() {
|
||||
*cur = effects::merged_params(&e.id, &Value::Null);
|
||||
@@ -783,6 +797,56 @@ mod tests {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_style_change_preserves_slot_and_visibility() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
|
||||
s.execute("effect.apply", &json!({"effect": effect, "item": 1})).unwrap();
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 1, "item": 1, "visible": false})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let result = s.execute("effect.setParams", &json!({"index": 1, "item": 1, "warpStyle": "flag", "params": {"bend": 25.0}}));
|
||||
assert!(result.is_ok(), "an applied warp can change style in place: {result:?}");
|
||||
let old = before.node(id).unwrap().appearance.effects_at(Some(1)).unwrap();
|
||||
let n = node(&s, id);
|
||||
let new = n.appearance.effects_at(Some(1)).unwrap();
|
||||
assert_eq!(new.len(), 3);
|
||||
assert_eq!(new[0], old[0]);
|
||||
assert_eq!(new[2], old[2]);
|
||||
assert_eq!(new[1].id, "warp.flag");
|
||||
assert_eq!(new[1].params["bend"], 25.0);
|
||||
assert!(!new[1].visible);
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_non_warp_target_rejection_is_atomic() {
|
||||
let (mut s, first) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let second = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.twist", "item": null})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first.0, second]})).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();
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "item": null, "warpStyle": "flag"})).is_err());
|
||||
let state = s.doc().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(state.history.undo.len(), undo);
|
||||
assert!(!state.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_remove_and_set_params() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
|
||||
@@ -17,7 +17,13 @@ use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| heading_label(d), body, confirm, preview: true, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| if super::warp_effect::is_warp(&d.str("__effect")) { tl!("Warp Options").to_string() } else { heading_label(d) },
|
||||
body,
|
||||
confirm,
|
||||
preview: true,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
|
||||
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
|
||||
@@ -127,6 +133,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let (id, relative) = (d.str("__effect"), d.bool("relative"));
|
||||
if super::warp_effect::is_warp(&id) {
|
||||
super::warp_effect::body(ui, d);
|
||||
let (cmd, params) = command(d);
|
||||
form::preview(app, ui, d, "Warp Options", cmd, params);
|
||||
return false;
|
||||
}
|
||||
if vectorcraft_effects::is_three_d(&id) {
|
||||
super::revolve::body(app, ui, d);
|
||||
let (cmd, params) = command(d);
|
||||
@@ -522,7 +534,14 @@ fn command(d: &Dialog) -> (&'static str, Value) {
|
||||
let params = form::params(d);
|
||||
let item = d.fields.get("__item").cloned();
|
||||
if let Some(index) = d.fields.get("__index") {
|
||||
return ("effect.setParams", json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params}));
|
||||
let mut p = json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params});
|
||||
let id = d.str("__effect");
|
||||
// The displayed style comes from the first target. Preserve other targets' Warp IDs
|
||||
// unless the user deliberately chooses Style, including after a preview rollback.
|
||||
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
|
||||
p["warpStyle"] = json!(id.strip_prefix("warp."));
|
||||
}
|
||||
return ("effect.setParams", p);
|
||||
}
|
||||
let mut p = json!({"effect": d.str("__effect"), "params": params});
|
||||
if let Some(item) = item {
|
||||
@@ -533,6 +552,13 @@ fn command(d: &Dialog) -> (&'static str, Value) {
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let (cmd, p) = command(d);
|
||||
if super::warp_effect::is_warp(&d.str("__effect")) {
|
||||
let r = form::commit_preview(app, cmd, p.clone())?;
|
||||
if cmd == "effect.apply" {
|
||||
app.last_effect = Some((d.str("__effect"), p["params"].clone()));
|
||||
}
|
||||
return Ok(r);
|
||||
}
|
||||
let r = if app.session.in_interaction() {
|
||||
// Live preview already applied: keep it (the interaction commits as one undo step).
|
||||
let _ = app.session.preview(cmd, &p);
|
||||
|
||||
@@ -98,6 +98,7 @@ mod tools;
|
||||
mod transform;
|
||||
pub mod transform_each;
|
||||
pub mod variables;
|
||||
mod warp_effect;
|
||||
pub mod width_point;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -576,3 +577,6 @@ mod tests_rectangular_grid;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_shortcut_import;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_warp;
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
//! Effect > Warp uses the existing modal and live-effect commands, without changing envelope art.
|
||||
use egui::{Event, Pos2, Rect, Shape, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app() -> (VectorcraftApp, NodeId) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 50, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
(app, NodeId(id))
|
||||
}
|
||||
|
||||
fn context() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
fn frame_output(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> egui::FullOutput {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0))), events, ..Default::default() };
|
||||
let mut output = ctx.run_ui(input, |ui| show(app, ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let output = frame_output(app, ctx, events);
|
||||
fn collect(shape: &Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) {
|
||||
match shape {
|
||||
Shape::Text(text) => {
|
||||
let rect = text.visual_bounding_rect();
|
||||
if clip.contains(rect.center()) {
|
||||
texts.push((text.galley.text().to_string(), rect));
|
||||
}
|
||||
}
|
||||
Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, clip, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
for shape in &output.shapes {
|
||||
collect(&shape.shape, shape.clip_rect, &mut texts);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
fn settle(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, Rect)> {
|
||||
for _ in 0..3 {
|
||||
frame(app, ctx, vec![]);
|
||||
}
|
||||
frame(app, ctx, vec![])
|
||||
}
|
||||
|
||||
fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
|
||||
let texts = frame(app, ctx, vec![]);
|
||||
let at = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing {label:?}: {texts:?}")).1.center();
|
||||
click_at(app, ctx, at);
|
||||
}
|
||||
|
||||
fn click_at(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
|
||||
for event in [
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() },
|
||||
] {
|
||||
frame(app, ctx, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, values: Value) {
|
||||
// Supply dialog inputs as the control channel does; the production frame previews them.
|
||||
app.ui.dialog.as_mut().unwrap().fields.extend(values.as_object().unwrap().clone());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_controls_cover_every_existing_style() {
|
||||
for (style, label) in vectorcraft_effects::WARP_STYLES {
|
||||
let (mut app, _) = app();
|
||||
app.run("effect.dialog", json!({"effect": format!("warp.{style}"), "item": null})).unwrap();
|
||||
let ctx = context();
|
||||
let texts = settle(&mut app, &ctx);
|
||||
for want in [
|
||||
"Warp Options",
|
||||
"Style:",
|
||||
label.trim_end_matches('…'),
|
||||
"Horizontal",
|
||||
"Vertical",
|
||||
"Bend:",
|
||||
"Distortion",
|
||||
"Horizontal:",
|
||||
"Vertical:",
|
||||
"Preview",
|
||||
"OK",
|
||||
"Cancel",
|
||||
] {
|
||||
assert!(texts.iter().any(|(text, _)| text == want), "{style}: missing {want:?}: {texts:?}");
|
||||
}
|
||||
assert!(!texts.iter().any(|(text, _)| text == "Orientation:"));
|
||||
cancel(&mut app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_style_orientation_clicks_preview_and_cancel_preserve_redo() {
|
||||
let (mut app, id) = app();
|
||||
app.run("object.move", json!({"dx": 10, "dy": 0})).unwrap();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
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();
|
||||
let redo = app.session.doc().unwrap().history.redo.len();
|
||||
assert_eq!(redo, 1);
|
||||
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let ctx = context();
|
||||
settle(&mut app, &ctx);
|
||||
click_text(&mut app, &ctx, "Arc");
|
||||
click_text(&mut app, &ctx, "Arc Lower");
|
||||
click_text(&mut app, &ctx, "Vertical");
|
||||
set(&mut app, json!({"bend": -30.0, "horizontal": 20.0, "vertical": -15.0}));
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(pos2(10.0, 10.0))]);
|
||||
let state = app.session.doc().unwrap();
|
||||
let effects = &state.doc.node(id).unwrap().appearance.effects;
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert_eq!(effects[0].id, "warp.arcLower");
|
||||
assert_eq!(effects[0].params["orientation"], "vertical");
|
||||
assert_eq!(effects[0].params["bend"], -30.0);
|
||||
assert_eq!(effects[0].params["horizontal"], 20.0);
|
||||
assert_eq!(effects[0].params["vertical"], -15.0);
|
||||
assert_eq!(state.history.undo.len(), undo);
|
||||
click_text(&mut app, &ctx, "Cancel");
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo));
|
||||
assert!(!state.is_dirty());
|
||||
assert!(!app.session.in_interaction() && app.ui.dialog.is_none());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_ne!(app.session.doc().unwrap().doc, before, "the original Redo remains usable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_existing_style_edit_keeps_stack_target_visibility_and_history() {
|
||||
for item in [Value::Null, json!(1)] {
|
||||
let (mut app, id) = app();
|
||||
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
|
||||
app.run("effect.apply", json!({"effect": effect, "item": item})).unwrap();
|
||||
}
|
||||
app.run("effect.setParams", json!({"index": 1, "item": item, "visible": false, "params": {"bend": -25.0}})).unwrap();
|
||||
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();
|
||||
let last_effect = app.last_effect.clone();
|
||||
app.run("effect.dialog", json!({"effect": "warp.arc", "index": 1, "item": item})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["bend"], -25.0);
|
||||
let ctx = context();
|
||||
settle(&mut app, &ctx);
|
||||
click_text(&mut app, &ctx, "Arc");
|
||||
click_text(&mut app, &ctx, "Arc Lower");
|
||||
set(&mut app, json!({"bend": 35.0}));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
click_text(&mut app, &ctx, "OK");
|
||||
let state = app.session.doc().unwrap();
|
||||
let target = item.as_u64().map(|n| n as usize);
|
||||
let old = before.node(id).unwrap().appearance.effects_at(target).unwrap();
|
||||
let new = state.doc.node(id).unwrap().appearance.effects_at(target).unwrap();
|
||||
assert_eq!(new.len(), old.len());
|
||||
assert_eq!(new[0], old[0]);
|
||||
assert_eq!(new[2], old[2]);
|
||||
assert_eq!(new[1].id, "warp.arcLower");
|
||||
assert_eq!(new[1].params["bend"], 35.0);
|
||||
assert!(!new[1].visible);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(state.history.undo.len(), undo + 1);
|
||||
assert_eq!(app.last_effect, last_effect, "editing does not replace Last Effect");
|
||||
let after = state.doc.clone();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_preview_toggle_and_ok_use_latest_values() {
|
||||
let (mut app, id) = app();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let ctx = context();
|
||||
settle(&mut app, &ctx);
|
||||
// Exercise the checkbox transition; assigning its stored boolean bypasses the click.
|
||||
click_text(&mut app, &ctx, "Preview");
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().bool("preview"));
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "clicking Preview off rolls back");
|
||||
set(&mut app, json!({"bend": 75.0}));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "turning Preview off rolls back");
|
||||
click_text(&mut app, &ctx, "Preview");
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("preview"));
|
||||
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["bend"], 75.0);
|
||||
set(&mut app, json!({"bend": -40.0}));
|
||||
confirm(&mut app).unwrap();
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].params["bend"], -40.0);
|
||||
assert_eq!(state.history.undo.len(), undo + 1);
|
||||
assert_eq!(app.last_effect.as_ref().unwrap().1["bend"], -40.0);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// Locate the actual painted number beside its row label, without requesting widget focus.
|
||||
fn click_number(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
|
||||
let texts = frame(app, ctx, vec![]);
|
||||
let row = texts.iter().find(|(text, _)| text == label).unwrap().1;
|
||||
let number = texts
|
||||
.iter()
|
||||
.find(|(text, rect)| {
|
||||
rect.left() > row.right() && (rect.center().y - row.center().y).abs() < 6.0 && text.trim_end_matches('%').parse::<f64>().is_ok()
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no numeric field beside {label}: {texts:?}"));
|
||||
click_at(app, ctx, number.1.center());
|
||||
}
|
||||
|
||||
fn replace_number(text: &str) -> Vec<Event> {
|
||||
let modifiers = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
|
||||
vec![
|
||||
Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key: egui::Key::A, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
Event::Text(text.into()),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_numeric_entry_immediate_accept_uses_latest_values() {
|
||||
for preview in [true, false] {
|
||||
for enter in [true, false] {
|
||||
let (mut app, id) = app();
|
||||
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.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let ctx = context();
|
||||
settle(&mut app, &ctx);
|
||||
if !preview {
|
||||
click_text(&mut app, &ctx, "Preview");
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
// Actual keyboard input: moving to the next field commits the previous one.
|
||||
click_number(&mut app, &ctx, "Horizontal:");
|
||||
frame(&mut app, &ctx, replace_number("23%"));
|
||||
click_number(&mut app, &ctx, "Vertical:");
|
||||
frame(&mut app, &ctx, replace_number("-19%"));
|
||||
click_number(&mut app, &ctx, "Bend:");
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
let ok = texts.iter().find(|(text, _)| text == "OK").unwrap().1.center();
|
||||
if preview {
|
||||
let effect = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0];
|
||||
assert_eq!(effect.params["horizontal"], 23.0);
|
||||
assert_eq!(effect.params["vertical"], -19.0);
|
||||
assert_eq!(effect.params["bend"], 50.0);
|
||||
} else {
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "typing with Preview off must not edit the document");
|
||||
}
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
|
||||
let mut events = replace_number("-37%");
|
||||
if enter {
|
||||
// The final Text and Enter arrive in the same frame, with no idle preview first.
|
||||
events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||||
}
|
||||
frame(&mut app, &ctx, events);
|
||||
if !enter {
|
||||
// Click immediately after typing; don't blur the field through another control.
|
||||
click_at(&mut app, &ctx, ok);
|
||||
}
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction(), "preview={preview}, enter={enter}");
|
||||
let state = app.session.doc().unwrap();
|
||||
let effects = &state.doc.node(id).unwrap().appearance.effects;
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert_eq!(effects[0].id, "warp.arc");
|
||||
assert_eq!(effects[0].params["bend"], -37.0);
|
||||
assert_eq!(effects[0].params["horizontal"], 23.0);
|
||||
assert_eq!(effects[0].params["vertical"], -19.0);
|
||||
assert_eq!(effects[0].params["orientation"], "horizontal");
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(state.history.undo.len(), undo + 1);
|
||||
assert_eq!(app.last_effect.as_ref().unwrap().1["bend"], -37.0);
|
||||
let after = state.doc.clone();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_slider_drag_previews_exact_parameter_and_cancel_restores() {
|
||||
let (mut app, id) = app();
|
||||
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.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let ctx = context();
|
||||
let texts = settle(&mut app, &ctx);
|
||||
let row = texts.iter().find(|(text, _)| text == "Bend:").unwrap().1;
|
||||
// The real color_slider paints one outlined rail in this row. Locate that painted rail,
|
||||
// not a hardcoded screen position or an injected dialog value.
|
||||
fn rail(shape: &Shape, clip: Rect, row: Rect) -> Option<Rect> {
|
||||
match shape {
|
||||
Shape::Rect(shape)
|
||||
if shape.rect.width() > 80.0
|
||||
&& (shape.rect.height() - 7.0).abs() < 0.1
|
||||
&& shape.rect.left() > row.right()
|
||||
&& (shape.rect.center().y - row.center().y).abs() < 6.0
|
||||
&& clip.contains(shape.rect.center()) =>
|
||||
{
|
||||
Some(shape.rect)
|
||||
}
|
||||
Shape::Vec(shapes) => shapes.iter().find_map(|shape| rail(shape, clip, row)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let output = frame_output(&mut app, &ctx, vec![]);
|
||||
let bar = output.shapes.iter().find_map(|shape| rail(&shape.shape, shape.clip_rect, row)).expect("Bend must have a visible slider rail");
|
||||
let from = pos2(bar.left() + 0.75 * bar.width(), bar.center().y);
|
||||
let to = pos2(bar.left() + 0.25 * bar.width(), bar.center().y);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
|
||||
frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }],
|
||||
);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||||
let effect = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0];
|
||||
assert_eq!(effect.params["bend"], -50.0, "the held slider must update the actual live effect");
|
||||
assert_eq!(effect.params["horizontal"], 0.0);
|
||||
assert_eq!(effect.params["vertical"], 0.0);
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
|
||||
frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }],
|
||||
);
|
||||
assert_eq!(app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["bend"], -50.0);
|
||||
click_text(&mut app, &ctx, "Cancel");
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(state.history.undo.len(), undo);
|
||||
assert!(!state.is_dirty());
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_mixed_styles_change_only_after_explicit_style_choice() {
|
||||
for item in [Value::Null, json!(1)] {
|
||||
for edit in ["none", "bend", "style"] {
|
||||
for accept in [false, true] {
|
||||
let (mut app, first) = app();
|
||||
for effect in ["path.offsetPath", "warp.arc", "stylize.roundCorners"] {
|
||||
app.run("effect.apply", json!({"effect": effect, "item": item})).unwrap();
|
||||
}
|
||||
let second =
|
||||
NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
for effect in ["path.offsetPath", "warp.flag", "stylize.roundCorners"] {
|
||||
app.run("effect.apply", json!({"effect": effect, "item": item})).unwrap();
|
||||
}
|
||||
app.run("effect.setParams", json!({"index": 1, "item": item, "visible": false})).unwrap();
|
||||
app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap();
|
||||
app.run("object.move", json!({"dx": 5, "dy": 0})).unwrap();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
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();
|
||||
let redo = app.session.doc().unwrap().history.redo.len();
|
||||
assert_eq!(redo, 1);
|
||||
let last_effect = app.last_effect.clone();
|
||||
let mut expected = before.clone();
|
||||
for id in [first, second] {
|
||||
let node = std::sync::Arc::make_mut(&mut expected).node_mut(id).unwrap();
|
||||
let effects = node.appearance.effects_mut(item.as_u64().map(|i| i as usize)).unwrap();
|
||||
match edit {
|
||||
"bend" => effects[1].params["bend"] = json!(25.0),
|
||||
"style" => effects[1].id = "warp.arcLower".into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Ordinary Effect menu route: first object's Arc is already present, then Edit.
|
||||
app.run("effect.dialog", json!({"effect": "warp.arc", "item": item})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS);
|
||||
let ctx = context();
|
||||
settle(&mut app, &ctx);
|
||||
click_text(&mut app, &ctx, "Edit");
|
||||
settle(&mut app, &ctx);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before, "opening must not broadcast the first target's style");
|
||||
match edit {
|
||||
"bend" => {
|
||||
click_number(&mut app, &ctx, "Bend:");
|
||||
frame(&mut app, &ctx, replace_number("25%"));
|
||||
// Blur via the existing orientation choice; it stays Horizontal.
|
||||
click_text(&mut app, &ctx, "Horizontal");
|
||||
}
|
||||
"style" => {
|
||||
click_text(&mut app, &ctx, "Arc");
|
||||
click_text(&mut app, &ctx, "Arc Lower");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
assert_eq!(app.session.doc().unwrap().doc, expected, "edit={edit}, item={item}");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
|
||||
click_text(&mut app, &ctx, "Preview");
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().bool("preview"));
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
click_text(&mut app, &ctx, "Preview");
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("preview"));
|
||||
assert_eq!(app.session.doc().unwrap().doc, expected, "Style intent survives Preview off/on");
|
||||
click_text(&mut app, &ctx, if accept { "OK" } else { "Cancel" });
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(app.last_effect, last_effect);
|
||||
if accept {
|
||||
// Whole-document equality also checks both targets, sibling effects and
|
||||
// the first visible / second hidden Warp. Existing OK history stays one step.
|
||||
assert_eq!(state.doc, expected);
|
||||
assert_eq!(state.history.undo.len(), undo + 1);
|
||||
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, expected);
|
||||
} else {
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo));
|
||||
assert!(!state.is_dirty());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_ne!(app.session.doc().unwrap().doc, before, "Cancel keeps the original Redo usable");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn warp_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
|
||||
frame(app, ctx, vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]);
|
||||
frame(
|
||||
app,
|
||||
ctx,
|
||||
vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, Event::ModifiersChanged(egui::Modifiers::NONE)],
|
||||
);
|
||||
}
|
||||
|
||||
fn tab_to_warp_control(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str, modifiers: egui::Modifiers) -> egui::Id {
|
||||
// The popup container is also a Tab stop: its rectangle contains every option.
|
||||
// Require the real widget's focus announcement as well as its visible label.
|
||||
for _ in 0..32 {
|
||||
let mut events = frame_output(
|
||||
app,
|
||||
ctx,
|
||||
vec![Event::ModifiersChanged(modifiers), Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }],
|
||||
)
|
||||
.platform_output
|
||||
.events;
|
||||
events.extend(
|
||||
frame_output(
|
||||
app,
|
||||
ctx,
|
||||
vec![
|
||||
Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
Event::ModifiersChanged(egui::Modifiers::NONE),
|
||||
],
|
||||
)
|
||||
.platform_output
|
||||
.events,
|
||||
);
|
||||
if !events.iter().any(|event| {
|
||||
let egui::output::OutputEvent::FocusGained(info) = event else { return false };
|
||||
info.enabled
|
||||
&& match info.typ {
|
||||
egui::WidgetType::ComboBox => info.current_text_value.as_deref() == Some(label),
|
||||
egui::WidgetType::Button => info.label.as_deref() == Some(label),
|
||||
_ => false,
|
||||
}
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let texts = frame(app, ctx, vec![]);
|
||||
let at = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing keyboard target {label}")).1.center();
|
||||
if let Some(id) = ctx.memory(|m| m.focused())
|
||||
&& ctx.read_response(id).is_some_and(|response| response.rect.contains(at) && ctx.layer_id_at(at) == Some(response.layer_id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
panic!("Tab traversal did not focus {label}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
|
||||
for preview in [true, false] {
|
||||
let (mut app, id) = app();
|
||||
app.run("object.move", json!({"dx": 10, "dy": 0})).unwrap();
|
||||
let moved = app.session.doc().unwrap().doc.clone();
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
app.session.doc_mut().unwrap().mark_saved();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let selection = app.session.doc().unwrap().selection.clone();
|
||||
let entries = |items: &[vectorcraft_engine::HistoryEntry]| {
|
||||
items.iter().map(|entry| (entry.label.clone(), entry.doc.clone(), entry.selection.clone())).collect::<Vec<_>>()
|
||||
};
|
||||
let undo = entries(&app.session.doc().unwrap().history.undo);
|
||||
let redo = entries(&app.session.doc().unwrap().history.redo);
|
||||
assert_eq!(redo.len(), 1);
|
||||
let last_effect = app.last_effect.clone();
|
||||
app.run("effect.dialog", json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let ctx = context();
|
||||
settle(&mut app, &ctx);
|
||||
if !preview {
|
||||
click_text(&mut app, &ctx, "Preview");
|
||||
}
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().bool("preview"), preview);
|
||||
let style_id = tab_to_warp_control(&mut app, &ctx, "Arc", egui::Modifiers::SHIFT);
|
||||
// Both directions are actual key events, including returning to the Style combo.
|
||||
warp_key(&mut app, &ctx, egui::Key::Tab, egui::Modifiers::NONE);
|
||||
assert_ne!(ctx.memory(|m| m.focused()), Some(style_id));
|
||||
warp_key(&mut app, &ctx, egui::Key::Tab, egui::Modifiers::SHIFT);
|
||||
assert_eq!(ctx.memory(|m| m.focused()), Some(style_id));
|
||||
assert!(!egui::ComboBox::is_open(&ctx, style_id));
|
||||
warp_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE);
|
||||
assert!(app.ui.dialog.is_some(), "WARP_STYLE_ENTER_OPEN: opening Style must not accept Warp Options");
|
||||
assert!(egui::ComboBox::is_open(&ctx, style_id));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__effect"), "warp.arc");
|
||||
assert_eq!(entries(&app.session.doc().unwrap().history.undo), undo);
|
||||
assert_eq!(entries(&app.session.doc().unwrap().history.redo), redo);
|
||||
assert_eq!(app.last_effect, last_effect);
|
||||
|
||||
tab_to_warp_control(&mut app, &ctx, "Arc Lower", egui::Modifiers::NONE);
|
||||
warp_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE);
|
||||
assert!(app.ui.dialog.is_some(), "WARP_STYLE_ENTER_CHOICE: choosing Style must not accept Warp Options");
|
||||
assert!(!egui::ComboBox::is_open(&ctx, style_id));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__effect"), "warp.arcLower");
|
||||
assert!(app.ui.dialog.as_ref().unwrap().bool("__warp_style_changed"));
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(entries(&state.history.undo), undo);
|
||||
assert_eq!(entries(&state.history.redo), redo);
|
||||
assert_eq!(app.last_effect, last_effect);
|
||||
if preview {
|
||||
assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].id, "warp.arcLower");
|
||||
assert!(app.session.in_interaction());
|
||||
} else {
|
||||
assert_eq!(state.doc, before);
|
||||
assert!(!state.is_dirty() && !app.session.in_interaction());
|
||||
}
|
||||
click_text(&mut app, &ctx, "Cancel");
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.doc, before);
|
||||
assert_eq!(state.selection, selection);
|
||||
assert_eq!(entries(&state.history.undo), undo);
|
||||
assert_eq!(entries(&state.history.redo), redo);
|
||||
assert!(!state.is_dirty());
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, moved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema.
|
||||
use egui::{Pos2, Rect, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point};
|
||||
|
||||
use super::form;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
pub(super) fn is_warp(id: &str) -> bool {
|
||||
id.strip_prefix("warp.").and_then(WarpStyle::from_id).is_some()
|
||||
}
|
||||
|
||||
fn shown(label: &str) -> String {
|
||||
tl!(label).trim_end_matches('…').to_string()
|
||||
}
|
||||
|
||||
/// An original three-by-three grid, bent by VectorCraft's own warp map. No reference icon artwork.
|
||||
fn style_preview(ui: &mut egui::Ui, suffix: &str) {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover());
|
||||
let Some(style) = WarpStyle::from_id(suffix) else { return };
|
||||
let mut lines = vec![];
|
||||
let mut bounds = Rect::NOTHING;
|
||||
for vertical in [false, true] {
|
||||
for fixed in [-1.0, 0.0, 1.0] {
|
||||
let line: Vec<Pos2> = (0..=16)
|
||||
.map(|i| {
|
||||
let along = -1.0 + f64::from(i) / 8.0;
|
||||
let (x, y) = if vertical { (fixed, along) } else { (along, fixed) };
|
||||
let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y);
|
||||
let p = Pos2::new(x as f32, y as f32);
|
||||
bounds.extend_with(p);
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
let room = rect.shrink(2.0);
|
||||
let scale = (room.width() / bounds.width().max(0.001)).min(room.height() / bounds.height().max(0.001));
|
||||
let stroke = Stroke::new(1.0, Tokens::get(ui.ctx()).text);
|
||||
for line in lines {
|
||||
let points = line.into_iter().map(|p| room.center() + (p - bounds.center()) * scale).collect();
|
||||
ui.painter().add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
const LABEL_W: f32 = 84.0;
|
||||
let id = d.str("__effect");
|
||||
let suffix = id.strip_prefix("warp.").unwrap_or_default();
|
||||
let current = WARP_STYLES.iter().find(|(style, _)| *style == suffix).map_or_else(|| suffix.to_string(), |(_, label)| shown(label));
|
||||
widgets::label_row(ui, tl!("Style:"), LABEL_W, |ui| {
|
||||
style_preview(ui, suffix);
|
||||
if let Some((style, label)) = widgets::combo(ui, "warp-style", ¤t, 170.0, false, |ui| {
|
||||
let mut chosen = None;
|
||||
for (style, label) in WARP_STYLES {
|
||||
ui.horizontal(|ui| {
|
||||
style_preview(ui, style);
|
||||
if ui.add(egui::Button::selectable(style == suffix, shown(label))).clicked() {
|
||||
chosen = Some((style, label));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
chosen
|
||||
}) {
|
||||
d.fields.insert("__effect".into(), json!(format!("warp.{style}")));
|
||||
d.fields.insert("__warp_style_changed".into(), json!(true));
|
||||
d.fields.insert("__label".into(), json!(label.trim_end_matches('…')));
|
||||
}
|
||||
});
|
||||
let orientation = d.str("orientation");
|
||||
widgets::label_row(ui, "", LABEL_W, |ui| {
|
||||
for (value, label) in [("horizontal", tl!("Horizontal")), ("vertical", tl!("Vertical"))] {
|
||||
if widgets::radio(ui, label, orientation == value, true) {
|
||||
d.fields.insert("orientation".into(), json!(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
form::slider_w(ui, d, ("bend", tl!("Bend:"), LABEL_W), -100.0..=100.0, "%", &move |_| rail);
|
||||
ui.add_space(6.0);
|
||||
widgets::subheader(ui, tl!("Distortion"));
|
||||
for (key, label) in [("horizontal", tl!("Horizontal:")), ("vertical", tl!("Vertical:"))] {
|
||||
form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail);
|
||||
}
|
||||
}
|
||||
@@ -755,13 +755,19 @@ pub(crate) fn combo<R>(
|
||||
if searchable {
|
||||
return searchable_combo(ui, ui.id().with(id), current, width - 4.0, list);
|
||||
}
|
||||
egui::ComboBox::from_id_salt(ui.id().with(id))
|
||||
let id = ui.id().with(id);
|
||||
let button_id = ui.make_persistent_id(egui::IdSalt::new(id));
|
||||
let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id);
|
||||
let response = egui::ComboBox::from_id_salt(id)
|
||||
.selected_text(egui::RichText::new(current).size(12.0))
|
||||
.width(width - 4.0)
|
||||
.height(ui.spacing().combo_height)
|
||||
.show_ui(ui, list)
|
||||
.inner
|
||||
.flatten()
|
||||
.show_ui(ui, list);
|
||||
// The button or open list handles Enter before the modal can accept it.
|
||||
if enter_for_combo {
|
||||
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
}
|
||||
response.inner.flatten()
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
+4
-3
File diff suppressed because one or more lines are too long
+3
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -182,6 +182,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
| Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. |
|
||||
| Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) |
|
||||
| Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) |
|
||||
| Object › Expand on radial, grid and mirror repeats makes groups of their instances | done | #1765 (#1756) |
|
||||
@@ -225,6 +226,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete leaves separate surviving runs; dedicated Remove Anchors remains connected. Keyboard and history regression coverage added; scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Curvature: the Curvature tool's curve: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; the rubber band, dragged, inserted and converted points use the same rule; the Pencil's fitting unchanged (#1236, in part); scores unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Quick Image Trace presets reveal the adjustment panel with Advanced open; Properties reopens it. Sliders keep their preset row until release so the re-trace is committed. UI regressions added; scores and remaining estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user