M3.42: Appearance editing depth — drag effect rows to reorder them or move them into/out of fills and strokes (Alt copies), click an effect name to edit it in its prefilled dialog, choosing an applied effect asks to edit it or add another, Show All Hidden Attributes, multi-select and Alt-drag duplicate of fills/strokes, drag the thumbnail onto art to copy the appearance, stroke rows note brush/Dashed/profile, Shift-click a chip for the mixer, Properties fx opens the effect menu
Engine: effect.move {from, to, fromItem?, toItem?, copy?}; appearance.showAllHidden;
appearance.duplicateItem takes to? and indices?; appearance.removeItem takes indices?;
Appearance::has_hidden/show_all. UI: effect.dialog takes index? (edit in place via
effect.setParams) and opens the effectExists question for an effect already in the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6bd27a05da
commit
10ea4d5daf
|
|
@ -582,10 +582,45 @@ impl Appearance {
|
|||
}
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
/// Is any fill, stroke or effect (the object's or an item's) hidden?
|
||||
pub fn has_hidden(&self) -> bool {
|
||||
let hidden = |fx: &[Effect]| fx.iter().any(|e| !e.visible);
|
||||
hidden(&self.effects) || self.items.iter().any(|i| !i.visible() || hidden(i.effects()))
|
||||
}
|
||||
|
||||
/// Make every hidden fill, stroke and effect visible (Show All Hidden Attributes). Returns
|
||||
/// whether anything was hidden.
|
||||
pub fn show_all(&mut self) -> bool {
|
||||
let had = self.has_hidden();
|
||||
let show = |fx: &mut Vec<Effect>| fx.iter_mut().for_each(|e| e.visible = true);
|
||||
show(&mut self.effects);
|
||||
for i in &mut self.items {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) => f.visible = true,
|
||||
AppearanceItem::Stroke(s) => s.visible = true,
|
||||
}
|
||||
show(i.effects_mut());
|
||||
}
|
||||
had
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hidden_attributes_show_again() {
|
||||
let mut a = Appearance::default_art();
|
||||
assert!(!a.has_hidden() && !a.show_all());
|
||||
a.stroke_mut().unwrap().visible = false;
|
||||
a.items[0].effects_mut().push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: false });
|
||||
assert!(a.has_hidden());
|
||||
assert!(a.show_all());
|
||||
assert!(!a.has_hidden() && a.items.iter().all(AppearanceItem::visible) && a.items[0].effects()[0].visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_appearance() {
|
||||
let a = Appearance::default_art();
|
||||
|
|
|
|||
|
|
@ -66,7 +66,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
set_item
|
||||
),
|
||||
cmd!("appearance.removeItem", "Remove Item", [], None, "{index, ids?}", has_selection, remove_item),
|
||||
cmd!(
|
||||
"appearance.removeItem",
|
||||
"Remove Item",
|
||||
[],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack",
|
||||
has_selection,
|
||||
remove_item
|
||||
),
|
||||
cmd!(
|
||||
"appearance.addEffect",
|
||||
"Add Effect",
|
||||
|
|
@ -76,7 +84,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_selection,
|
||||
super::effectcmd::apply
|
||||
),
|
||||
cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index, ids?}", has_selection, duplicate_item),
|
||||
cmd!(
|
||||
"appearance.duplicateItem",
|
||||
"Duplicate Item",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?} copy fills/strokes with their effects",
|
||||
has_selection,
|
||||
duplicate_item
|
||||
),
|
||||
cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item),
|
||||
cmd!(
|
||||
"appearance.copyFrom",
|
||||
|
|
@ -96,6 +112,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_selection,
|
||||
set_active_item
|
||||
),
|
||||
cmd!(
|
||||
"appearance.showAllHidden",
|
||||
"Show All Hidden Attributes",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
|
||||
has_selection,
|
||||
show_all_hidden
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -146,12 +171,17 @@ pub(crate) enum ItemTarget {
|
|||
|
||||
/// Parse the `item` param of `cmd` (see the module docs).
|
||||
pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result<ItemTarget> {
|
||||
match p.get("item") {
|
||||
item_target_at(s, p, "item", cmd)
|
||||
}
|
||||
|
||||
/// [`item_target`] read from param `key` (e.g. `fromItem`).
|
||||
pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<ItemTarget> {
|
||||
match p.get(key) {
|
||||
Some(Value::Null) => Ok(ItemTarget::Top),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.map(|i| ItemTarget::Item { index: i as usize, explicit: true })
|
||||
.ok_or_else(|| bad(cmd, "`item` must be an appearance item index (paint order) or null")),
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
|
||||
None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top),
|
||||
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })),
|
||||
}
|
||||
|
|
@ -385,45 +415,92 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// The paint-order item indices a command acts on (`indices`, else `index`), ascending and
|
||||
/// without repeats.
|
||||
fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let mut v = match p.get("indices") {
|
||||
Some(a) => a
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::<Option<Vec<_>>>())
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?,
|
||||
None => vec![index_param(p, "index", cmd)?],
|
||||
};
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = index_param(p, "index", "appearance.removeItem")?;
|
||||
let idx = item_indices(p, "appearance.removeItem")?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
s.edit("Remove Item", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& idx < n.appearance.items.len()
|
||||
{
|
||||
n.appearance.items.remove(idx);
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
let items = &mut n.appearance.items;
|
||||
// From the top down, so the lower indices stay valid.
|
||||
for &i in idx.iter().rev() {
|
||||
if i < items.len() {
|
||||
items.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| match a.cmp(&idx) {
|
||||
std::cmp::Ordering::Less => Some(a),
|
||||
std::cmp::Ordering::Equal => None,
|
||||
std::cmp::Ordering::Greater => Some(a - 1),
|
||||
});
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
}
|
||||
|
||||
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = index_param(p, "index", "appearance.duplicateItem")?;
|
||||
const C: &str = "appearance.duplicateItem";
|
||||
let idx = item_indices(p, C)?;
|
||||
let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?;
|
||||
if to.is_some() && idx.len() > 1 {
|
||||
return Err(bad(C, "`to` takes a single `index`"));
|
||||
}
|
||||
let ids = appearance_targets(s, p)?;
|
||||
// Where the first object's copy landed (the active item lives in the first object's stack).
|
||||
let mut first_at = None;
|
||||
s.edit("Duplicate Item", |d, _| {
|
||||
let mut any = false;
|
||||
for id in &ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
if let Some(item) = n.appearance.items.get(idx).cloned() {
|
||||
n.appearance.items.insert(idx + 1, item);
|
||||
any = true;
|
||||
let items = &mut n.appearance.items;
|
||||
// From the top down, so the lower indices stay valid.
|
||||
for &i in idx.iter().rev() {
|
||||
if let Some(item) = items.get(i).cloned() {
|
||||
let at = to.map_or(i + 1, |t| t.min(items.len()));
|
||||
first_at.get_or_insert(at);
|
||||
items.insert(at, item);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) }
|
||||
if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) }
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| Some(if a > idx { a + 1 } else { a }));
|
||||
// Rows at or above each copy move up by one.
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
Some(match to {
|
||||
Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)),
|
||||
None => a + idx.iter().filter(|i| **i < a).count(),
|
||||
})
|
||||
});
|
||||
ok()
|
||||
}
|
||||
|
||||
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let ids = s.edit("Show All Hidden Attributes", |d, _| {
|
||||
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
|
||||
if shown.is_empty() {
|
||||
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
|
||||
}
|
||||
Ok(shown)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.moveItem";
|
||||
let from = index_param(p, "from", C)?;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use vectorcraft_doc::{Effect, Node, NodeKind};
|
|||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target};
|
||||
use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
cmd!(
|
||||
"effect.move",
|
||||
"Move Effect",
|
||||
[],
|
||||
None,
|
||||
"{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?: [..]} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
|
||||
has_selection,
|
||||
move_effect
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +184,38 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.move";
|
||||
let from = index_param(p, "from", C)?;
|
||||
let to = index_param(p, "to", C)?;
|
||||
let src = item_target_at(s, p, "fromItem", C)?;
|
||||
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
|
||||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
let roots = appearance_targets(s, p)?;
|
||||
// Where the effect landed in the first object.
|
||||
let mut landed = None;
|
||||
let ids = s.edit(if copy { "Duplicate Effect" } else { "Move Effect" }, |d, _| {
|
||||
let mut done = vec![];
|
||||
for id in &roots {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let (si, di) = (src.effects_item(&n.appearance, C)?, dst.effects_item(&n.appearance, C)?);
|
||||
let Some(fx) = n.appearance.effects_mut(si).filter(|fx| from < fx.len()) else { continue };
|
||||
let e = if copy { fx[from].clone() } else { fx.remove(from) };
|
||||
let Some(fx) = n.appearance.effects_mut(di) else { continue };
|
||||
let at = to.min(fx.len());
|
||||
fx.insert(at, e);
|
||||
landed.get_or_insert((at, di));
|
||||
done.push(*id);
|
||||
}
|
||||
if done.is_empty() {
|
||||
return Err(EngineError::Other(format!("{C}: no effect at index {from}")));
|
||||
}
|
||||
Ok(done)
|
||||
})?;
|
||||
let (index, item) = landed.unzip();
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() }))
|
||||
}
|
||||
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
|
|
|
|||
|
|
@ -750,6 +750,8 @@ mod tests_distort;
|
|||
#[cfg(test)]
|
||||
mod tests_draw2;
|
||||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_file;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
//! Appearance editing depth: moving and copying effects between the object and its fills and
|
||||
//! strokes, duplicating and removing several items, Show All Hidden Attributes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Node;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`.
|
||||
fn session_with_rect() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// Effect ids of the object (`None`) or of item `i`.
|
||||
fn fx(s: &Session, id: NodeId, item: Option<usize>) -> Vec<String> {
|
||||
node(s, id).appearance.effects_at(item).unwrap().iter().map(|e| e.id.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_reorder_within_a_list_and_undo_in_one_step() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
for e in ["distort.twist", "distort.roughen", "stylize.dropShadow"] {
|
||||
run(&mut s, "effect.apply", json!({ "effect": e }));
|
||||
}
|
||||
let r = run(&mut s, "effect.move", json!({"from": 0, "to": 2}));
|
||||
assert_eq!((r["index"].clone(), r["item"].clone()), (json!(2), Value::Null));
|
||||
assert_eq!(fx(&s, id, None), ["distort.roughen", "stylize.dropShadow", "distort.twist"]);
|
||||
// Past the end lands last.
|
||||
run(&mut s, "effect.move", json!({"from": 0, "to": 9}));
|
||||
assert_eq!(fx(&s, id, None), ["stylize.dropShadow", "distort.twist", "distort.roughen"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(fx(&s, id, None), ["distort.twist", "distort.roughen", "stylize.dropShadow"]);
|
||||
assert!(s.execute("effect.move", &json!({"from": 3, "to": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_move_and_copy_between_the_object_and_its_items() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "item": 1}));
|
||||
// Into the stroke, above its own effect.
|
||||
let r = run(&mut s, "effect.move", json!({"from": 0, "fromItem": null, "to": 1, "toItem": 1}));
|
||||
assert_eq!((r["index"].clone(), r["item"].clone()), (json!(1), json!(1)));
|
||||
assert!(fx(&s, id, None).is_empty());
|
||||
assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]);
|
||||
// Out of the stroke onto the fill, as a copy (Alt-drag): the stroke keeps it.
|
||||
run(&mut s, "effect.move", json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 0, "copy": true}));
|
||||
assert_eq!(fx(&s, id, Some(0)), ["path.offsetPath"]);
|
||||
assert_eq!(fx(&s, id, Some(1)), ["path.offsetPath", "distort.twist"]);
|
||||
// Back out to the object, one undo step each.
|
||||
run(&mut s, "effect.move", json!({"from": 1, "fromItem": 1, "to": 0, "toItem": null}));
|
||||
assert_eq!(fx(&s, id, None), ["distort.twist"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(fx(&s, id, None).is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(fx(&s, id, Some(0)).is_empty());
|
||||
// A missing item is an error, not a silent no-op.
|
||||
assert!(s.execute("effect.move", &json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 7})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_selected_works_for_effects() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 0}));
|
||||
run(&mut s, "effect.duplicate", json!({"index": 0, "item": 0}));
|
||||
assert_eq!(fx(&s, id, Some(0)), ["distort.roughen", "distort.roughen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_duplicate_to_a_position_and_several_remove_at_once() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"}));
|
||||
// Alt-dragging the top fill to the bottom copies it there.
|
||||
run(&mut s, "appearance.duplicateItem", json!({"index": 2, "to": 0}));
|
||||
let kinds = |s: &Session| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
|
||||
assert_eq!(kinds(&s), ["fill", "fill", "stroke", "fill"]);
|
||||
assert_eq!(node(&s, id).appearance.items[0].paint(), node(&s, id).appearance.items[3].paint());
|
||||
// Duplicating several copies each above itself.
|
||||
run(&mut s, "appearance.duplicateItem", json!({"indices": [0, 2]}));
|
||||
assert_eq!(kinds(&s), ["fill", "fill", "fill", "stroke", "stroke", "fill"]);
|
||||
assert!(s.execute("appearance.duplicateItem", &json!({"indices": [0, 1], "to": 3})).is_err());
|
||||
// Removing several in one step; the active item follows.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 5}));
|
||||
run(&mut s, "appearance.removeItem", json!({"indices": [4, 0, 1]}));
|
||||
assert_eq!(kinds(&s), ["fill", "stroke", "fill"]);
|
||||
assert_eq!(s.appearance_item(), Some(2));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.items.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_all_hidden_attributes() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
|
||||
assert!(s.execute("appearance.showAllHidden", &json!({})).is_err(), "nothing hidden yet");
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false}));
|
||||
run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": null}));
|
||||
run(&mut s, "effect.setParams", json!({"index": 0, "visible": false, "item": 1}));
|
||||
assert!(node(&s, id).appearance.has_hidden());
|
||||
let r = run(&mut s, "appearance.showAllHidden", json!({}));
|
||||
assert_eq!(r["ids"], json!([id.0]));
|
||||
let n = node(&s, id);
|
||||
assert!(!n.appearance.has_hidden());
|
||||
assert!(n.appearance.effects[0].visible && n.appearance.items[1].effects()[0].visible && n.appearance.items[0].visible());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(node(&s, id).appearance.has_hidden());
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
|
||||
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
|
||||
|
||||
|
|
@ -136,6 +136,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
handle_input(app, ui, &resp, rect);
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
if let Some(drag) = resp.dnd_release_payload::<crate::panels::appearance::AppearanceDrag>()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
drop_appearance(app, drag.0, xf.to_doc(p), xf.zoom);
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
|
|
@ -482,6 +487,19 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
|||
}
|
||||
}
|
||||
|
||||
/// The Appearance panel's thumbnail dropped at `pos` (at `zoom`): the object there takes the
|
||||
/// appearance of `source` (`appearance.copyFrom`).
|
||||
pub(crate) fn drop_appearance(app: &mut VectorcraftApp, source: NodeId, pos: Point, zoom: f64) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false };
|
||||
let Some(target) = vectorcraft_doc::hit::hit_test(&st.doc, pos, opt).map(|h| h.top_object(st.isolation)) else { return };
|
||||
if target != source
|
||||
&& let Err(e) = app.run("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
|
||||
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
|
||||
match app.session.pointer(ev, view) {
|
||||
|
|
@ -1081,4 +1099,32 @@ mod tests {
|
|||
assert_eq!(app.view().unwrap().center, after.center);
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
|
||||
}
|
||||
#[test]
|
||||
fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
|
||||
let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
|
||||
run(&mut app, "transparency.set", json!({"opacity": 40}));
|
||||
let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "select.set", json!({ "ids": [a] }));
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
// The Appearance panel's thumbnail (its drag payload) released over the second rectangle.
|
||||
egui::DragAndDrop::set_payload(&ctx, crate::panels::appearance::AppearanceDrag(NodeId(a)));
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let at = xf.to_screen(Point::new(250.0, 70.0));
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![up]);
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap());
|
||||
assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity));
|
||||
assert_eq!(nb.appearance.effects[0].id, "distort.twist");
|
||||
// Dropped on empty canvas: nothing changes.
|
||||
let undo_len = app.session.active().unwrap().history.undo.len();
|
||||
drop_appearance(&mut app, NodeId(a), Point::new(350.0, 250.0), 1.0);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,115 @@
|
|||
//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a
|
||||
//! live Preview that OK keeps as one undo step and Cancel rolls back. `__item`, when present, is
|
||||
//! the appearance item the effect goes to (else the Appearance panel's active item applies).
|
||||
//! Live effect dialogs (Effect menu, Appearance panel): the effect's parameters (`__effect`, headed
|
||||
//! `__label`) with a live Preview that OK keeps as one undo step and Cancel rolls back. `__item`,
|
||||
//! when present, is the appearance item the effect goes to (else the Appearance panel's active
|
||||
//! item applies). With `__index` the dialog edits that applied effect in place
|
||||
//! (`effect.setParams`) instead of adding one (`effect.apply`).
|
||||
//!
|
||||
//! Choosing an effect that the target already carries first asks ([`EXISTS`]) whether to edit it
|
||||
//! or add another one.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
|
||||
|
||||
/// The dialog kind asking whether to edit an effect that is already applied or add another.
|
||||
/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and
|
||||
/// `__index` (the applied effect's item and position). OK edits it; `discard: true` then OK adds
|
||||
/// a new one.
|
||||
pub const EXISTS: &str = "effectExists";
|
||||
|
||||
pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |d| format!("{} is already applied", d.str("__label")),
|
||||
body: |_, ui, _| {
|
||||
ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim));
|
||||
false
|
||||
},
|
||||
confirm: confirm_exists,
|
||||
ok: Some("Edit"),
|
||||
discard: Some("Add New"),
|
||||
max_width: Some(420.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// `effect.dialog {effect, index?, item?}`: edit applied effect `index` of `item` (null: the
|
||||
/// object's effects; omitted: the active item's, else the object's) prefilled with its values,
|
||||
/// or add `effect`, first asking when that list already has it.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let id = p.get("effect").and_then(Value::as_str).unwrap_or_default();
|
||||
let info = vectorcraft_effects::effect_info(id).ok_or_else(|| format!("unknown effect `{id}`"))?;
|
||||
// The list the effect goes to: `item` as given, else the active item's (the object's without
|
||||
// one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual.
|
||||
let target = p.get("item").cloned().unwrap_or_else(|| json!(app.session.appearance_item()));
|
||||
let applied = target_effects(app, &target);
|
||||
let mut fields = Map::new();
|
||||
fields.insert("__effect".into(), json!(id));
|
||||
fields.insert("__label".into(), json!(info.label.trim_end_matches('…')));
|
||||
if let Some(item) = p.get("item") {
|
||||
fields.insert("__item".into(), item.clone());
|
||||
}
|
||||
match p.get("index").and_then(Value::as_u64) {
|
||||
Some(k) => {
|
||||
let e = applied.get(k as usize).ok_or_else(|| format!("no effect at index {k}"))?;
|
||||
if e.id != id {
|
||||
return Err(format!("effect {k} is `{}`, not `{id}`", e.id));
|
||||
}
|
||||
fields.insert("__item".into(), target);
|
||||
fields.insert("__index".into(), json!(k));
|
||||
fields.extend(params_of(e));
|
||||
}
|
||||
None => {
|
||||
if let Some(k) = applied.iter().rposition(|e| e.id == id) {
|
||||
fields.insert("__target".into(), target);
|
||||
fields.insert("__index".into(), json!(k));
|
||||
app.ui.dialog = Some(Dialog { kind: EXISTS.into(), fields });
|
||||
return Ok(json!({ "pending": EXISTS }));
|
||||
}
|
||||
fields.extend(info.defaults.as_object().cloned().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
fields.insert("preview".into(), json!(true));
|
||||
app.ui.dialog = Some(Dialog { kind: "effect".into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The effects of the first selected object that `item` (an index or null) addresses.
|
||||
fn target_effects(app: &VectorcraftApp, item: &Value) -> Vec<vectorcraft_doc::Effect> {
|
||||
let Some(st) = app.session.active() else { return vec![] };
|
||||
let node = st.selection.objects.first().and_then(|id| st.doc.node(*id));
|
||||
node.and_then(|n| n.appearance.effects_at(item.as_u64().map(|i| i as usize))).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// An applied effect's values over its defaults.
|
||||
fn params_of(e: &vectorcraft_doc::Effect) -> Map<String, Value> {
|
||||
match vectorcraft_effects::merged_params(&e.id, &e.params) {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// OK in [`EXISTS`]: the dialog editing the applied effect, or (`discard`) a fresh one.
|
||||
fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut fields = d.fields.clone();
|
||||
fields.remove("discard");
|
||||
let target = fields.remove("__target").unwrap_or(Value::Null);
|
||||
if d.bool("discard") {
|
||||
fields.remove("__index");
|
||||
let defaults = vectorcraft_effects::default_params(&d.str("__effect"));
|
||||
fields.extend(defaults.and_then(|v| v.as_object().cloned()).unwrap_or_default());
|
||||
} else {
|
||||
let k = fields.get("__index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
fields.extend(target_effects(app, &target).get(k).map(params_of).unwrap_or_default());
|
||||
fields.insert("__item".into(), target);
|
||||
}
|
||||
fields.insert("preview".into(), json!(true));
|
||||
app.ui.dialog = Some(Dialog { kind: "effect".into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let changed = form::param_fields(ui, d);
|
||||
ui.add_space(6.0);
|
||||
|
|
@ -19,32 +119,42 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
if pv && (changed || pv_changed || !app.session.in_interaction()) {
|
||||
let label = d.str("__label");
|
||||
let _ = app.session.begin_interaction(&label);
|
||||
let _ = app.session.preview("effect.apply", &apply_params(d));
|
||||
let (cmd, p) = command(d);
|
||||
let _ = app.session.preview(cmd, &p);
|
||||
} else if !pv && pv_changed {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `effect.apply` params for the dialog's effect, values and target item.
|
||||
fn apply_params(d: &Dialog) -> Value {
|
||||
let mut p = json!({"effect": d.str("__effect"), "params": form::params(d)});
|
||||
if let Some(item) = d.fields.get("__item") {
|
||||
p["item"] = item.clone();
|
||||
/// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the
|
||||
/// dialog's effect with its values on the target item.
|
||||
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}));
|
||||
}
|
||||
p
|
||||
let mut p = json!({"effect": d.str("__effect"), "params": params});
|
||||
if let Some(item) = item {
|
||||
p["item"] = item;
|
||||
}
|
||||
("effect.apply", p)
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let p = apply_params(d);
|
||||
let (cmd, p) = command(d);
|
||||
let r = if app.session.in_interaction() {
|
||||
// Live preview already applied: keep it (the interaction commits as one undo step).
|
||||
let _ = app.session.preview("effect.apply", &p);
|
||||
let _ = app.session.preview(cmd, &p);
|
||||
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
|
||||
} else {
|
||||
app.run("effect.apply", p.clone())
|
||||
app.run(cmd, p.clone())
|
||||
};
|
||||
app.last_effect = Some((d.str("__effect"), p["params"].clone()));
|
||||
// Last Effect repeats applied effects, not edits.
|
||||
if cmd == "effect.apply" {
|
||||
app.last_effect = Some((d.str("__effect"), p["params"].clone()));
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ mod transform;
|
|||
use serde_json::{Value, json};
|
||||
|
||||
pub use color_picker::open as open_color_picker;
|
||||
pub use effect::open as open_effect_dialog;
|
||||
pub use tools::open_tool_dialog;
|
||||
|
||||
use crate::state::Dialog;
|
||||
|
|
@ -140,6 +141,7 @@ registry! {
|
|||
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
|
||||
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
|
||||
ColorPicker: [color_picker::KIND] => color_picker::SPEC,
|
||||
EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"effect.dialog",
|
||||
"Effect…",
|
||||
"",
|
||||
"{effect: id, item?: appearance item index|null (default: the Appearance panel's active item)} open the effect's dialog with live preview",
|
||||
"{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}",
|
||||
),
|
||||
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
|
||||
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
|
||||
|
|
@ -460,23 +460,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null));
|
||||
Ok(app.session.tool_options())
|
||||
}
|
||||
"effect.dialog" => {
|
||||
let id = s("effect").unwrap_or_default();
|
||||
match vectorcraft_effects::effect_catalog().into_iter().find(|e| e.id == id) {
|
||||
Some(e) => {
|
||||
let mut fields = e.defaults.as_object().cloned().unwrap_or_default();
|
||||
fields.insert("__effect".into(), json!(e.id));
|
||||
fields.insert("__label".into(), json!(e.label.trim_end_matches('…')));
|
||||
fields.insert("preview".into(), json!(true));
|
||||
if let Some(item) = p.get("item") {
|
||||
fields.insert("__item".into(), item.clone());
|
||||
}
|
||||
app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err(format!("unknown effect `{id}`")),
|
||||
}
|
||||
}
|
||||
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
|
||||
"ui.recolorDialog" => match app.run("recolor.colors", json!({})) {
|
||||
Ok(v) => {
|
||||
let pairs: Vec<Value> = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
//! Appearance panel: the full stack like Illustrator — the object row, each stroke/fill row (eye,
|
||||
//! disclosure, link label, swatch, weight) with "Opacity: Default" sub-rows, fx rows with inline
|
||||
//! parameter editors (effect.setParams), the object's Opacity row and the bottom bar.
|
||||
//! Appearance panel: the full stack as in the reference app — the object row (its thumbnail drags
|
||||
//! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight,
|
||||
//! brush/dash/profile notes) with "Opacity: Default" sub-rows, fx rows (the name opens the
|
||||
//! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar.
|
||||
//!
|
||||
//! Rows drag: a fill/stroke row reorders the stack, an effect row reorders its list or moves into
|
||||
//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and
|
||||
//! Shift-click select several fills/strokes for Duplicate and Delete.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -10,6 +15,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
|||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId};
|
||||
|
||||
use super::{first_selected, live_run, pstate, set_pstate};
|
||||
use crate::panels::stroke::paint_profile;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, TransparencyEdit, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
|
@ -64,14 +70,63 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) -
|
|||
}
|
||||
|
||||
/// Select a row; its fill/stroke becomes the active item that paint and effect edits target.
|
||||
/// Other selected fills/strokes are deselected.
|
||||
pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) {
|
||||
if app.session.appearance_item() != sel.item() {
|
||||
app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok();
|
||||
}
|
||||
let owner = app.session.active().and_then(|d| d.selection.objects.first().copied());
|
||||
set_pstate(ctx, "ap-sel", (owner, sel));
|
||||
set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::<Vec<usize>>()));
|
||||
}
|
||||
|
||||
/// The selected fill/stroke rows of `n` (paint order, ascending): the active row plus those
|
||||
/// Cmd/Shift-clicked with it.
|
||||
fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec<usize> {
|
||||
let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] };
|
||||
let (owner, mut set): (Option<NodeId>, Vec<usize>) = pstate(ctx, "ap-multi");
|
||||
if owner != Some(n.id) || !set.contains(&active) {
|
||||
return vec![active];
|
||||
}
|
||||
set.retain(|i| *i < n.appearance.items.len());
|
||||
set.sort_unstable();
|
||||
set.dedup();
|
||||
set
|
||||
}
|
||||
|
||||
/// Click on fill/stroke row `i`: Shift extends the selection from the active row, Cmd toggles
|
||||
/// the row, a plain click selects only it.
|
||||
fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, i: usize, m: egui::Modifiers) {
|
||||
let mut set = selected_items(ctx, Some(n), sel);
|
||||
let active = match sel {
|
||||
Sel::Item(a) if m.shift => {
|
||||
set = (a.min(i)..=a.max(i)).collect();
|
||||
a
|
||||
}
|
||||
Sel::Item(a) if m.command => {
|
||||
match set.iter().position(|x| *x == i) {
|
||||
Some(p) if set.len() > 1 => {
|
||||
set.remove(p);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => set.push(i),
|
||||
}
|
||||
if set.contains(&a) { a } else { set[0] }
|
||||
}
|
||||
_ => {
|
||||
set = vec![i];
|
||||
i
|
||||
}
|
||||
};
|
||||
select_row(app, ctx, Sel::Item(active));
|
||||
set_pstate(ctx, "ap-multi", (Some(n.id), set));
|
||||
}
|
||||
|
||||
/// Dragged from the object row's thumbnail and dropped on art: the object under the pointer takes
|
||||
/// this object's appearance (`appearance.copyFrom`, see `canvas::drop_appearance`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AppearanceDrag(pub NodeId);
|
||||
|
||||
fn catalog() -> &'static [(String, String, Vec<String>)] {
|
||||
static C: OnceLock<Vec<(String, String, Vec<String>)>> = OnceLock::new();
|
||||
C.get_or_init(|| {
|
||||
|
|
@ -179,12 +234,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0));
|
||||
let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone());
|
||||
chip(ui, th, &fill);
|
||||
if let Some(n) = node.as_ref().filter(|_| !mixed) {
|
||||
thumbnail_drag(ui, th, n.id, &fill);
|
||||
}
|
||||
}
|
||||
text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), label, true);
|
||||
match &node {
|
||||
// Objects that differ have no common stack to list.
|
||||
Some(_) if mixed => {}
|
||||
Some(n) => stack(app, ui, n, sel),
|
||||
Some(n) => stack(app, ui, n, sel, r),
|
||||
None => default_stack(app, ui),
|
||||
}
|
||||
// Spacer row like Illustrator's empty tail.
|
||||
|
|
@ -193,6 +251,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
bottom(app, ui, node.as_ref(), sel);
|
||||
}
|
||||
|
||||
/// The object row's thumbnail drags this object's appearance onto art (a chip follows the pointer).
|
||||
fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId, fill: &Paint) {
|
||||
let resp = ui.interact(th, ui.id().with("ap-thumb"), Sense::drag()).on_hover_text("Drag onto art to apply this appearance");
|
||||
resp.dnd_set_drag_payload(AppearanceDrag(id));
|
||||
if resp.dragged()
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
let layer = egui::LayerId::new(egui::Order::Tooltip, ui.id().with("ap-thumb-drag"));
|
||||
let r = Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0));
|
||||
let ghost = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(r.expand(1.0)));
|
||||
chip(&ghost, r, fill);
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects"
|
||||
/// or the object's kind ("Path", "Type", …).
|
||||
pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str {
|
||||
|
|
@ -229,16 +302,43 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
text(ui, r.left_center() + vec2(EYE_W + 24.0, 0.0), "Opacity: Default", false);
|
||||
}
|
||||
|
||||
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
|
||||
/// A stack row being dragged.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Dragged {
|
||||
Item(usize),
|
||||
/// Effect `k` of fill/stroke `item` (`None`: of the object).
|
||||
Effect(Option<usize>, usize),
|
||||
}
|
||||
|
||||
/// What a stack row stands for when a dragged row is dropped on it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Slot {
|
||||
/// Fill/stroke row `index` (`sub`: one of its indented sub-rows).
|
||||
Item { index: usize, sub: bool },
|
||||
/// Effect `k` of fill/stroke `item` (`None`: of the object).
|
||||
Effect(Option<usize>, usize),
|
||||
/// The object row (`top`) or the object's Opacity row.
|
||||
Object { top: bool },
|
||||
}
|
||||
|
||||
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut drop: Option<(usize, usize)> = None;
|
||||
let ctx = ui.ctx().clone();
|
||||
let dragging: Option<Dragged> = pstate(&ctx, "ap-drag");
|
||||
let items = selected_items(&ctx, Some(n), sel);
|
||||
let mut rows = vec![(Slot::Object { top: true }, object_row)];
|
||||
let mut stopped = false;
|
||||
let dragging: Option<usize> = pstate(ui.ctx(), "ap-drag");
|
||||
let mut row_rects = vec![];
|
||||
// Starts or ends a row drag.
|
||||
let mut track = |resp: &egui::Response, what: Dragged| {
|
||||
if resp.drag_started() {
|
||||
set_pstate(&ctx, "ap-drag", Some(what));
|
||||
}
|
||||
stopped |= resp.drag_stopped();
|
||||
};
|
||||
for (i, it) in n.appearance.items.iter().enumerate().rev() {
|
||||
let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
|
||||
let (r, resp) = row(ui, sel == Sel::Item(i));
|
||||
row_rects.push((i, r));
|
||||
let (r, resp) = row(ui, items.contains(&i));
|
||||
rows.push((Slot::Item { index: i, sub: false }, r));
|
||||
let (visible, paint) = (it.visible(), it.paint());
|
||||
let is_stroke = !it.is_fill();
|
||||
if eye(ui, r, ("ap-eye", i), visible, true) {
|
||||
|
|
@ -256,11 +356,19 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
|
|||
} else {
|
||||
text(ui, pos2(lx, r.center().y), "Fill:", false);
|
||||
}
|
||||
// Swatch with a swatches popup that sets this item's paint.
|
||||
// Swatch with a swatches popup that sets this item's paint; Shift-click opens the Color
|
||||
// panel's mixer on this row instead.
|
||||
let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0));
|
||||
chip(ui, cr, paint);
|
||||
let cresp = ui.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()).on_hover_text("Click to choose a swatch");
|
||||
egui::Popup::menu(&cresp).show(|ui| {
|
||||
let cresp = ui
|
||||
.interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click())
|
||||
.on_hover_text("Click to choose a swatch, Shift-click to mix a colour");
|
||||
let mixer = cresp.clicked() && ui.input(|inp| inp.modifiers.shift);
|
||||
if mixer {
|
||||
select_row(app, ui.ctx(), Sel::Item(i));
|
||||
app.ui.open_panel = Some("color".into());
|
||||
}
|
||||
egui::Popup::menu(&cresp).open_memory((cresp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| {
|
||||
swatch_picker(app, ui, i);
|
||||
});
|
||||
if let AppearanceItem::Stroke(st) = it {
|
||||
|
|
@ -269,67 +377,139 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel) {
|
|||
if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) {
|
||||
app.run("appearance.setItem", json!({"index": i, "weight": w})).ok();
|
||||
}
|
||||
stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st);
|
||||
}
|
||||
if resp.clicked() {
|
||||
select_row(app, ui.ctx(), Sel::Item(i));
|
||||
let m = ui.input(|inp| inp.modifiers);
|
||||
click_item(app, ui.ctx(), n, sel, i, m);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into());
|
||||
}
|
||||
if resp.drag_started() {
|
||||
set_pstate(ui.ctx(), "ap-drag", Some(i));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
stopped = true;
|
||||
}
|
||||
track(&resp, Dragged::Item(i));
|
||||
if open {
|
||||
opacity_row(app, ui, Some(i), it.opacity(), it.blend());
|
||||
let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend());
|
||||
rows.push((Slot::Item { index: i, sub: true }, r));
|
||||
for (k, e) in it.effects().iter().enumerate() {
|
||||
effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k));
|
||||
let (r, resp) = effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k));
|
||||
rows.push((Slot::Effect(Some(i), k), r));
|
||||
track(&resp, Dragged::Effect(Some(i), k));
|
||||
}
|
||||
}
|
||||
}
|
||||
if stopped
|
||||
&& let Some(from) = dragging
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
drop = Some((from, target_index(&row_rects, p.y)));
|
||||
}
|
||||
if dragging.is_some() && !ui.ctx().input(|i| i.pointer.any_down()) {
|
||||
set_pstate::<Option<usize>>(ui.ctx(), "ap-drag", None);
|
||||
}
|
||||
if let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
&& dragging.is_some()
|
||||
{
|
||||
// Insertion marker.
|
||||
if let Some((_, r)) = row_rects.iter().find(|(_, r)| r.y_range().contains(p.y)) {
|
||||
let y = if p.y < r.center().y { r.top() } else { r.bottom() };
|
||||
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
// Object-level effects.
|
||||
for (k, e) in n.appearance.effects.iter().enumerate() {
|
||||
effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
|
||||
let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k));
|
||||
rows.push((Slot::Effect(None, k), r));
|
||||
track(&resp, Dragged::Effect(None, k));
|
||||
}
|
||||
opacity_row(app, ui, None, n.opacity, n.blend);
|
||||
if let Some((from, to_row)) = drop
|
||||
&& from != to_row
|
||||
let r = opacity_row(app, ui, None, n.opacity, n.blend);
|
||||
rows.push((Slot::Object { top: false }, r));
|
||||
let Some(what) = dragging else { return };
|
||||
let pointer = ui.ctx().pointer_interact_pos();
|
||||
if let Some(p) = pointer
|
||||
&& let Some((slot, r)) = slot_at(&rows, p)
|
||||
{
|
||||
// The engine keeps the moved row active when it was.
|
||||
app.run("appearance.moveItem", json!({"from": from, "to": to_row})).ok();
|
||||
// Drop marker: a line where the row lands, or a frame round the row it goes into.
|
||||
let stroke = Stroke::new(2.0, t.accent);
|
||||
let line = |y: f32| {
|
||||
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], stroke);
|
||||
};
|
||||
match (what, slot) {
|
||||
(Dragged::Effect(..), Slot::Effect(..)) => line(if p.y < r.center().y { r.top() } else { r.bottom() }),
|
||||
(Dragged::Effect(..), _) => {
|
||||
ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside);
|
||||
}
|
||||
(Dragged::Item(from), _) => line(if item_at(n, slot) > from { r.top() } else { r.bottom() }),
|
||||
}
|
||||
}
|
||||
if !ui.ctx().input(|i| i.pointer.any_down()) {
|
||||
set_pstate::<Option<Dragged>>(ui.ctx(), "ap-drag", None);
|
||||
}
|
||||
if stopped
|
||||
&& let Some(p) = pointer
|
||||
&& let Some((cmd, params)) = drop_command(n, &rows, what, p, ui.input(|i| i.modifiers.alt))
|
||||
&& let Ok(r) = app.run(cmd, params)
|
||||
&& let Some(k) = r["index"].as_u64().filter(|_| cmd == "effect.move")
|
||||
{
|
||||
// The dropped effect becomes the selected row (a moved fill/stroke stays active in the engine).
|
||||
let k = k as usize;
|
||||
select_row(app, ui.ctx(), r["item"].as_u64().map_or(Sel::Effect(k), |i| Sel::ItemEffect(i as usize, k)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint-order index a drop at `y` lands on (rows are listed top = last item).
|
||||
fn target_index(rows: &[(usize, Rect)], y: f32) -> usize {
|
||||
rows.iter().find(|(_, r)| r.y_range().contains(y)).map(|(i, _)| *i).unwrap_or_else(|| {
|
||||
if rows.first().is_some_and(|(_, r)| y < r.top()) { rows.first().map(|r| r.0).unwrap_or(0) } else { rows.last().map(|r| r.0).unwrap_or(0) }
|
||||
})
|
||||
/// The row under `p`.
|
||||
fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> {
|
||||
rows.iter().find(|(_, r)| r.contains(p)).copied()
|
||||
}
|
||||
|
||||
/// Paint-order index a dragged fill/stroke lands on when dropped on row `slot`: that row's item;
|
||||
/// the object row stands above the topmost item, the rows below the items under item 0.
|
||||
fn item_at(n: &Node, slot: Slot) -> usize {
|
||||
match slot {
|
||||
Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index,
|
||||
Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1),
|
||||
Slot::Object { top: false } | Slot::Effect(None, _) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a drop of `what` at `p` runs (`copy`: Alt held), or None when it would change
|
||||
/// nothing or lands outside the stack.
|
||||
fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, copy: bool) -> Option<(&'static str, Value)> {
|
||||
let (slot, r) = slot_at(rows, p)?;
|
||||
match what {
|
||||
Dragged::Item(from) => {
|
||||
let to = item_at(n, slot);
|
||||
if copy {
|
||||
// The copy goes where the row was dropped: above that row when dragged up, below it
|
||||
// when dragged down.
|
||||
let at = if to >= from { to + 1 } else { to };
|
||||
Some(("appearance.duplicateItem", json!({"index": from, "to": at})))
|
||||
} else {
|
||||
(to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to})))
|
||||
}
|
||||
}
|
||||
Dragged::Effect(from_item, k) => {
|
||||
let (item, pos) = match slot {
|
||||
Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)),
|
||||
Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()),
|
||||
Slot::Object { .. } => (None, n.appearance.effects.len()),
|
||||
};
|
||||
// Within its own list the effect leaves its old place first.
|
||||
let same = item == from_item && !copy;
|
||||
let pos = if same && pos > k { pos - 1 } else { pos };
|
||||
if same && pos == k {
|
||||
return None;
|
||||
}
|
||||
Some(("effect.move", json!({"from": k, "fromItem": from_item, "to": pos, "toItem": item, "copy": copy})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke row's notes after the weight: its brush, "Dashed" and its width profile (drawn).
|
||||
fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter_at(r);
|
||||
let dashed = st.dash.as_ref().is_some_and(|d| d.is_dashed()).then_some("Dashed");
|
||||
let notes: Vec<&str> = st.brush.as_deref().into_iter().chain(dashed).collect();
|
||||
let mut x = r.left();
|
||||
if !notes.is_empty() {
|
||||
let g = painter.layout_no_wrap(notes.join(", "), egui::FontId::proportional(11.5), t.text_dim);
|
||||
x += g.size().x + 6.0;
|
||||
painter.galley(pos2(r.left(), r.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
}
|
||||
if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) {
|
||||
let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0));
|
||||
if pr.right() <= r.right() {
|
||||
paint_profile(ui, pr, Some(p), t.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it
|
||||
/// opens a popup with the shared opacity and blend controls (`transparency.set` on that item).
|
||||
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) {
|
||||
/// Returns the row's rect.
|
||||
fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opacity: f32, blend: BlendMode) -> Rect {
|
||||
let (r, resp) = row(ui, false);
|
||||
eye(ui, r, ("ap-op-eye", item), true, false);
|
||||
let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 };
|
||||
|
|
@ -345,15 +525,19 @@ fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, opaci
|
|||
Some(TransparencyEdit::Opacity(o, phase)) => live_run(app, "Opacity", "transparency.set", json!({"item": item, "opacity": o}), phase),
|
||||
None => {}
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
/// An effect row: of the object (`item: None`) or, indented under it, of fill/stroke `item`.
|
||||
fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect, selected: bool) {
|
||||
/// Clicking the name opens the effect's dialog to edit it (the disclosure: an inline editor).
|
||||
/// Returns the row's rect and response (for dragging).
|
||||
fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect, selected: bool) -> (Rect, egui::Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let open_key = format!("ap-fx-open-{item:?}-{k}");
|
||||
let open: bool = pstate(ui.ctx(), &open_key);
|
||||
let this = item.map_or(Sel::Effect(k), |i| Sel::ItemEffect(i, k));
|
||||
let (r, resp) = row(ui, selected);
|
||||
let (row_rect, resp) = row(ui, selected);
|
||||
let r = row_rect;
|
||||
if eye(ui, r, ("ap-fx-eye", item, k), e.visible, true) {
|
||||
app.run("effect.setParams", json!({"item": item, "index": k, "visible": !e.visible})).ok();
|
||||
}
|
||||
|
|
@ -364,8 +548,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
|
|||
}
|
||||
let lx = r.left() + EYE_W + 24.0;
|
||||
if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_label(&e.id)) {
|
||||
set_pstate(ui.ctx(), &open_key, !open);
|
||||
select_row(app, ui.ctx(), this);
|
||||
if has_options(&e.id) {
|
||||
app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok();
|
||||
} else {
|
||||
set_pstate(ui.ctx(), &open_key, !open);
|
||||
}
|
||||
}
|
||||
icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon);
|
||||
if resp.clicked() {
|
||||
|
|
@ -377,6 +565,12 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
|
|||
if open {
|
||||
effect_editor(app, ui, item, k, e);
|
||||
}
|
||||
(row_rect, resp)
|
||||
}
|
||||
|
||||
/// Does the effect have a dialog (any parameters)?
|
||||
fn has_options(id: &str) -> bool {
|
||||
vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty()))
|
||||
}
|
||||
|
||||
/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours);
|
||||
|
|
@ -497,31 +691,31 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel)
|
|||
if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() {
|
||||
app.run("appearance.clear", json!({})).ok();
|
||||
}
|
||||
let can_dup = has && sel != Sel::None;
|
||||
if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can_dup, 24.0).clicked() {
|
||||
duplicate_selected(app, sel);
|
||||
let can = has && sel != Sel::None;
|
||||
if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() {
|
||||
duplicate_selected(app, ui, node, sel);
|
||||
}
|
||||
let can_del = has && sel != Sel::None;
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can_del, 24.0).clicked() {
|
||||
delete_selected(app, ui, sel);
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete Selected Item", false, can, 24.0).clicked() {
|
||||
delete_selected(app, ui, node, sel);
|
||||
}
|
||||
let _ = basic;
|
||||
});
|
||||
}
|
||||
|
||||
fn duplicate_selected(app: &mut VectorcraftApp, sel: Sel) {
|
||||
match (sel, sel.effect()) {
|
||||
(Sel::Item(i), _) => app.run("appearance.duplicateItem", json!({"index": i})).ok(),
|
||||
(_, Some(fx)) => app.run("effect.duplicate", fx).ok(),
|
||||
_ => None,
|
||||
/// Duplicate the selected fills/strokes (each copy right above its row) or the selected effect.
|
||||
fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) {
|
||||
match sel.effect() {
|
||||
Some(fx) => app.run("effect.duplicate", fx).ok(),
|
||||
None if sel.item().is_some() => app.run("appearance.duplicateItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).ok(),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
|
||||
fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) {
|
||||
let ok = match (sel, sel.effect()) {
|
||||
(Sel::Item(i), _) => app.run("appearance.removeItem", json!({"index": i})).is_ok(),
|
||||
(_, Some(fx)) => app.run("effect.remove", fx).is_ok(),
|
||||
_ => false,
|
||||
fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) {
|
||||
let ok = match sel.effect() {
|
||||
Some(fx) => app.run("effect.remove", fx).is_ok(),
|
||||
None if sel.item().is_some() => app.run("appearance.removeItem", json!({"indices": selected_items(ui.ctx(), node, sel)})).is_ok(),
|
||||
None => false,
|
||||
};
|
||||
// Removing the active item clears it in the engine; a removed item effect leaves its item
|
||||
// selected.
|
||||
|
|
@ -530,8 +724,9 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, sel: Sel) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The fx menu: effects grouped by their Effect-menu submenu; opens the effect dialog.
|
||||
fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// The fx menu (Appearance panel, Properties): effects grouped by their Effect-menu submenu;
|
||||
/// opens the effect dialog.
|
||||
pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let mut groups: Vec<(String, Vec<(String, String)>)> = vec![];
|
||||
for (id, label, menu) in catalog() {
|
||||
let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into());
|
||||
|
|
@ -564,10 +759,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) {
|
||||
duplicate_selected(app, sel);
|
||||
duplicate_selected(app, ui, node.as_ref(), sel);
|
||||
}
|
||||
if menu_item(ui, "Remove Item", has && sel != Sel::None, false) {
|
||||
delete_selected(app, ui, sel);
|
||||
delete_selected(app, ui, node.as_ref(), sel);
|
||||
}
|
||||
if menu_item(ui, "Clear Appearance", has, false) {
|
||||
app.run("appearance.clear", json!({})).ok();
|
||||
|
|
@ -583,7 +778,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
ui.separator();
|
||||
menu_item(ui, "Redefine Graphic Style", false, false);
|
||||
menu_item(ui, "Show All Hidden Attributes", false, false);
|
||||
if menu_item(ui, "Show All Hidden Attributes", node.as_ref().is_some_and(|n| n.appearance.has_hidden()), false) {
|
||||
app.run("appearance.showAllHidden", json!({})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -606,11 +803,42 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets() {
|
||||
let r = |y: f32| Rect::from_min_size(pos2(0.0, y), vec2(100.0, ROW));
|
||||
let rows = vec![(2, r(0.0)), (1, r(30.0)), (0, r(60.0))];
|
||||
assert_eq!(target_index(&rows, 45.0), 1);
|
||||
assert_eq!(target_index(&rows, -10.0), 2);
|
||||
assert_eq!(target_index(&rows, 500.0), 0);
|
||||
fn drops_pick_the_command() {
|
||||
use vectorcraft_doc::{Appearance, FillLayer};
|
||||
let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true };
|
||||
let mut n = Node::path(NodeId(1), vectorcraft_geom::PathData::default(), Appearance::default_art());
|
||||
n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); // [F, S, F]
|
||||
n.appearance.items[1].effects_mut().extend([fx("distort.twist"), fx("distort.roughen")]);
|
||||
n.appearance.effects.push(fx("stylize.dropShadow"));
|
||||
let slots = [
|
||||
Slot::Object { top: true },
|
||||
Slot::Item { index: 2, sub: false },
|
||||
Slot::Item { index: 1, sub: false },
|
||||
Slot::Effect(Some(1), 0),
|
||||
Slot::Effect(Some(1), 1),
|
||||
Slot::Item { index: 0, sub: false },
|
||||
Slot::Effect(None, 0),
|
||||
Slot::Object { top: false },
|
||||
];
|
||||
let rows: Vec<(Slot, Rect)> =
|
||||
slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
|
||||
// The upper or lower half of row `i`.
|
||||
let at = |i: usize, lower: bool| pos2(50.0, i as f32 * ROW + if lower { ROW * 0.75 } else { ROW * 0.25 });
|
||||
let drop = |what, p, copy| drop_command(&n, &rows, what, p, copy);
|
||||
// Fill/stroke rows: moves, Alt copies to where it was dropped.
|
||||
assert_eq!(drop(Dragged::Item(0), at(1, false), false), Some(("appearance.moveItem", json!({"from": 0, "to": 2}))));
|
||||
assert_eq!(drop(Dragged::Item(0), at(0, false), true), Some(("appearance.duplicateItem", json!({"index": 0, "to": 3}))));
|
||||
assert_eq!(drop(Dragged::Item(2), at(7, false), true), Some(("appearance.duplicateItem", json!({"index": 2, "to": 0}))));
|
||||
assert_eq!(drop(Dragged::Item(1), at(3, false), false), None, "onto its own effect");
|
||||
// Effects: within the list (below the next one), into a fill, out onto the object.
|
||||
let mv = |from: usize, fi: Value, to: usize, ti: Value, copy: bool| {
|
||||
Some(("effect.move", json!({"from": from, "fromItem": fi, "to": to, "toItem": ti, "copy": copy})))
|
||||
};
|
||||
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(4, true), false), mv(0, json!(1), 1, json!(1), false));
|
||||
assert_eq!(drop(Dragged::Effect(Some(1), 0), at(3, true), false), None, "dropped on itself");
|
||||
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(5, false), true), mv(1, json!(1), 0, json!(0), true));
|
||||
assert_eq!(drop(Dragged::Effect(Some(1), 1), at(7, false), false), mv(1, json!(1), 1, Value::Null, false));
|
||||
assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false));
|
||||
assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -356,3 +356,5 @@ mod tests {
|
|||
}
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
|
|
|
|||
|
|
@ -307,10 +307,9 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let r = widgets::flat_button(ui, "fx", 34.0);
|
||||
if r.on_hover_text("Effects").clicked() {
|
||||
app.ui.open_panel = Some("appearance".into());
|
||||
}
|
||||
// The fx button opens the effect menu, as the Appearance panel's does.
|
||||
let r = widgets::flat_button(ui, "fx", 34.0).on_hover_text("Add New Effect");
|
||||
egui::Popup::menu(&r).show(|ui| super::appearance::fx_menu(app, ui));
|
||||
if widgets::icon_button(ui, "ellipsis", "Appearance panel", false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("appearance".into());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use vectorcraft_engine::Session;
|
|||
|
||||
use super::*;
|
||||
|
||||
fn app_with_rect() -> VectorcraftApp {
|
||||
pub(super) fn app_with_rect() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone();
|
||||
|
|
@ -14,7 +14,7 @@ fn app_with_rect() -> VectorcraftApp {
|
|||
app
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
|
|
@ -29,13 +29,23 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut Ve
|
|||
}
|
||||
|
||||
/// One headless frame with input `events`: every text drawn, with its screen rect.
|
||||
fn frame_events(
|
||||
pub(super) fn frame_events(
|
||||
ctx: &egui::Context,
|
||||
app: &mut VectorcraftApp,
|
||||
events: Vec<egui::Event>,
|
||||
f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) -> Vec<(String, egui::Rect)> {
|
||||
frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, f)
|
||||
}
|
||||
|
||||
/// One headless frame with input `raw`: every text drawn, with its screen rect.
|
||||
pub(super) fn frame_raw(
|
||||
ctx: &egui::Context,
|
||||
app: &mut VectorcraftApp,
|
||||
raw: egui::RawInput,
|
||||
mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) -> Vec<(String, egui::Rect)> {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| f(app, ui));
|
||||
let mut out = ctx.run_ui(raw, |ui| f(app, ui));
|
||||
out.textures_delta.clear();
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
|
|
@ -52,7 +62,7 @@ fn frame_events(
|
|||
}
|
||||
|
||||
/// Click at `pos` over three frames of `f` (hover, press, release).
|
||||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
pub(super) fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
|
||||
frame_events(ctx, app, vec![e], &mut f);
|
||||
|
|
@ -60,7 +70,7 @@ fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f:
|
|||
}
|
||||
|
||||
/// The rect of the first drawn text equal to `s`.
|
||||
fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
|
||||
pub(super) fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
|
||||
texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
//! Headless frames of the Appearance panel's editing depth: effect names open their dialog
|
||||
//! prefilled, rows drag (Alt copies), several fills/strokes select together, stroke notes, the
|
||||
//! chip's Shift-click, the thumbnail drag and the Properties fx button.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_appearance::{app_with_rect, click, frame_events, frame_raw, run, text_rect};
|
||||
use super::*;
|
||||
|
||||
fn pointer(pos: egui::Pos2, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }
|
||||
}
|
||||
|
||||
/// One frame of the Appearance panel with `events` and `modifiers` held.
|
||||
fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>, modifiers: egui::Modifiers) -> Vec<(String, egui::Rect)> {
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
frame_raw(ctx, app, egui::RawInput { events, ..Default::default() }, appearance::show)
|
||||
}
|
||||
|
||||
/// Drag from `from` to `to` in the Appearance panel, `modifiers` held on release.
|
||||
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers) {
|
||||
let none = egui::Modifiers::NONE;
|
||||
panel_frame(ctx, app, vec![egui::Event::PointerMoved(from)], none);
|
||||
panel_frame(ctx, app, vec![pointer(from, true, none)], none);
|
||||
panel_frame(ctx, app, vec![egui::Event::PointerMoved(from + egui::vec2(0.0, 8.0))], none);
|
||||
panel_frame(ctx, app, vec![egui::Event::PointerMoved(to)], modifiers);
|
||||
panel_frame(ctx, app, vec![pointer(to, false, modifiers)], modifiers);
|
||||
panel_frame(ctx, app, vec![], none);
|
||||
}
|
||||
|
||||
/// Click `pos` with `modifiers` held.
|
||||
fn click_with(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, modifiers: egui::Modifiers) {
|
||||
for e in [egui::Event::PointerMoved(pos), pointer(pos, true, modifiers), pointer(pos, false, modifiers)] {
|
||||
panel_frame(ctx, app, vec![e], modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in the row of the label `s` (right of its chip, weight and notes).
|
||||
fn row_of(texts: &[(String, egui::Rect)], s: &str) -> egui::Pos2 {
|
||||
let r = text_rect(texts, s);
|
||||
egui::pos2(r.left() + 200.0, r.center().y)
|
||||
}
|
||||
|
||||
/// The rect of the panel-menu entry `s` (entries are drawn after a check-mark column).
|
||||
fn menu_item(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
|
||||
texts.iter().find(|(t, _)| t.trim_start() == s).unwrap_or_else(|| panic!("`{s}` not in the menu: {texts:?}")).1
|
||||
}
|
||||
|
||||
fn effects(app: &VectorcraftApp, item: Option<usize>) -> Vec<vectorcraft_doc::Effect> {
|
||||
first_selected(app).unwrap().appearance.effects_at(item).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() {
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 9}}));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
click(&ctx, &mut app, text_rect(&texts, "Roughen").center(), appearance::show);
|
||||
let d = app.ui.dialog.clone().expect("the effect's dialog");
|
||||
assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone()), ("effect", 9.0, json!(0)));
|
||||
// The dialog previews; OK keeps the new value on the same effect as one undo step.
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!(20));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let fx = effects(&app, None);
|
||||
assert_eq!((fx.len(), fx[0].params["size"].clone()), (1, json!(20)));
|
||||
assert!(app.ui.dialog.is_none() && app.last_effect.is_none(), "an edit is not a new Last Effect");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(effects(&app, None)[0].params["size"], json!(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() {
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 0, "params": {"size": 9}}));
|
||||
run(&mut app, "appearance.setActiveItem", json!({"index": 0}));
|
||||
// The active fill already has it: the question comes first; OK edits the applied one.
|
||||
assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(), json!({"pending": "effectExists"}));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone(), d.fields["__item"].clone()), ("effect", 9.0, json!(0), json!(0)));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(effects(&app, Some(0)).len(), 1);
|
||||
// "Add New": a fresh dialog with the defaults, which adds a second one.
|
||||
app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert!(d.fields.get("__index").is_none() && d.f64("size", 0.0) == 5.0, "{:?}", d.fields);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(effects(&app, Some(0)).len(), 2);
|
||||
// Another list without it opens the dialog directly.
|
||||
assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen", "item": null})).unwrap(), Value::Null);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "effect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_rows_drag_to_reorder_and_into_an_item() {
|
||||
let mut app = app_with_rect();
|
||||
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
|
||||
run(&mut app, "effect.apply", json!({"effect": "distort.roughen"}));
|
||||
let ctx = egui::Context::default();
|
||||
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
|
||||
// Twist below Roughen.
|
||||
let below = text_rect(&texts, "Roughen").center() + egui::vec2(60.0, 9.0);
|
||||
drag(&ctx, &mut app, row_of(&texts, "Twist"), below, egui::Modifiers::NONE);
|
||||
let ids = |fx: Vec<vectorcraft_doc::Effect>| fx.into_iter().map(|e| e.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]);
|
||||
// Alt-dragging Twist onto the stroke row copies it into the stroke's effects.
|
||||
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
|
||||
drag(&ctx, &mut app, row_of(&texts, "Twist"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT);
|
||||
assert_eq!(ids(effects(&app, None)), ["distort.roughen", "distort.twist"]);
|
||||
assert_eq!(ids(effects(&app, Some(1))), ["distort.twist"]);
|
||||
// Dropped effects become the selected row (their item active).
|
||||
assert_eq!(app.session.appearance_item(), Some(1));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(effects(&app, Some(1)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dragging_an_item_duplicates_it_and_several_items_delete_together() {
|
||||
let mut app = app_with_rect(); // [Fill, Stroke]
|
||||
let ctx = egui::Context::default();
|
||||
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
|
||||
drag(&ctx, &mut app, row_of(&texts, "Fill:"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT);
|
||||
let kinds = |app: &VectorcraftApp| first_selected(app).unwrap().appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
|
||||
assert_eq!(kinds(&app), ["fill", "stroke", "fill"]);
|
||||
// Click the stroke, Cmd-click the top fill: both go with Remove Item.
|
||||
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
|
||||
click_with(&ctx, &mut app, row_of(&texts, "Stroke:"), egui::Modifiers::NONE);
|
||||
let top_fill = texts.iter().filter(|(t, _)| t == "Fill:").map(|(_, r)| *r).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap();
|
||||
click_with(&ctx, &mut app, egui::pos2(top_fill.left() + 200.0, top_fill.center().y), egui::Modifiers::COMMAND);
|
||||
let menu = frame_events(&ctx, &mut app, vec![], appearance::menu);
|
||||
click(&ctx, &mut app, menu_item(&menu, "Remove Item").center(), appearance::menu);
|
||||
assert_eq!(kinds(&app), ["fill"]);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(kinds(&app).len(), 3, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_rows_note_dashes_brushes_and_show_all_hidden_attributes() {
|
||||
let mut app = app_with_rect();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!frame_events(&ctx, &mut app, vec![], appearance::show).iter().any(|(t, _)| t == "Dashed"));
|
||||
run(&mut app, "stroke.set", json!({"dash": [4, 2], "profile": "lens"}));
|
||||
let brush = run(&mut app, "brush.list", json!({}))["brushes"][0]["name"].as_str().unwrap().to_string();
|
||||
run(&mut app, "brush.apply", json!({ "name": brush }));
|
||||
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
|
||||
assert!(texts.iter().any(|(t, _)| *t == format!("{brush}, Dashed")), "{texts:?}");
|
||||
// Show All Hidden Attributes is enabled once something is hidden, and shows it.
|
||||
let enabled = |app: &mut VectorcraftApp| {
|
||||
let menu = frame_events(&ctx, app, vec![], appearance::menu);
|
||||
let r = menu_item(&menu, "Show All Hidden Attributes");
|
||||
click(&ctx, app, r.center(), appearance::menu);
|
||||
};
|
||||
run(&mut app, "appearance.setItem", json!({"index": 0, "visible": false}));
|
||||
enabled(&mut app);
|
||||
assert!(first_selected(&app).unwrap().appearance.items[0].visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_clicking_a_chip_opens_the_mixer_on_that_row() {
|
||||
let mut app = app_with_rect();
|
||||
let ctx = egui::Context::default();
|
||||
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
|
||||
// The chip sits 52 pt right of the label's start (label at EYE_W + 24, chip at EYE_W + 76).
|
||||
let label = text_rect(&texts, "Fill:");
|
||||
click_with(&ctx, &mut app, egui::pos2(label.left() + 52.0 + 9.0, label.center().y), egui::Modifiers::SHIFT);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
|
||||
assert_eq!(app.session.appearance_item(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumbnail_drags_the_appearance_and_the_properties_fx_button_opens_the_effects() {
|
||||
let mut app = app_with_rect();
|
||||
let ctx = egui::Context::default();
|
||||
let texts = panel_frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
|
||||
// The thumbnail is left of the object row's label.
|
||||
let label = text_rect(&texts, "Rectangle");
|
||||
let thumb = egui::pos2(label.left() - 28.0, label.center().y);
|
||||
let none = egui::Modifiers::NONE;
|
||||
for e in [egui::Event::PointerMoved(thumb), pointer(thumb, true, none), egui::Event::PointerMoved(thumb + egui::vec2(30.0, 30.0))] {
|
||||
panel_frame(&ctx, &mut app, vec![e], none);
|
||||
}
|
||||
let id = first_selected(&app).unwrap().id;
|
||||
assert_eq!(egui::DragAndDrop::payload::<appearance::AppearanceDrag>(&ctx).as_deref(), Some(&appearance::AppearanceDrag(id)));
|
||||
// Properties: the fx button lists the effect groups.
|
||||
let props = egui::Context::default();
|
||||
let texts = frame_raw(&props, &mut app, Default::default(), properties::show);
|
||||
click(&props, &mut app, text_rect(&texts, "fx").center(), properties::show);
|
||||
let texts = frame_raw(&props, &mut app, Default::default(), properties::show);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Distort & Transform"), "{texts:?}");
|
||||
}
|
||||
|
|
@ -36,3 +36,10 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern
|
|||
`swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`.
|
||||
|
||||
The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`.
|
||||
|
||||
Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog
|
||||
(fields: the effect's parameters, `preview`). With `index` it edits that applied effect of `item` (null: the object's
|
||||
effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setParams`; otherwise confirm runs
|
||||
`effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the
|
||||
`effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard",
|
||||
value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it.
|
||||
|
|
|
|||
|
|
@ -196,3 +196,12 @@ plain group and keeps the clipping path, unpainted.
|
|||
{"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}}
|
||||
{"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}}
|
||||
```
|
||||
|
||||
## Editing appearance stacks
|
||||
|
||||
`effect.move {from, to, fromItem?, toItem?, copy?}` reorders an effect or moves it between the object's effects
|
||||
(`null`) and a fill's or stroke's (an item index), as dragging its row in the Appearance panel does; `copy: true` copies
|
||||
it (Alt-drag). `appearance.duplicateItem {index, to?}` / `{indices}` and `appearance.removeItem {index | indices}`
|
||||
act on several fills/strokes at once, and `appearance.showAllHidden` makes every hidden fill, stroke and effect
|
||||
visible again. In remote mode, `effect.dialog {effect, index, item}` opens an applied effect's dialog prefilled
|
||||
(`ui.dialog.confirm` runs `effect.setParams`); see the control protocol for the "already applied" question.
|
||||
|
|
|
|||
Loading…
Reference in New Issue