Appearance panel: UI parity for row editing

- Drag strokes, fills, characters and effects to move them, or drop them
  on the Duplicate or Delete button
- Select several rows with Shift/Cmd-click and move them as one block
- Delete and Backspace remove the highlighted rows, not the selected art
- Row links open their panel (Stroke, Character…) as a flyout
- Stroke weight spinner follows Units > Stroke
- Transparency panel edits the active fill's or stroke's opacity
This commit is contained in:
FarmerGeorg
2026-11-02 14:36:09 +07:00
committed by bflatastic
parent f4d1b9b748
commit 9c59a4dcb4
45 changed files with 2776 additions and 378 deletions
+21
View File
@@ -510,6 +510,27 @@ impl Appearance {
self.set_contents_at(others_below + usize::from(now_below));
Some(to)
}
/// Move items `from` (ascending paint-order indices, no repeats) as one block, in their order,
/// so the lowest lands at `to` (clamped); returns it. The contents stay between the same unmoved
/// items; a block landing right at them keeps its lowest item's side. None (and nothing changed)
/// when an index is missing.
pub fn move_items(&mut self, from: &[usize], to: usize) -> Option<usize> {
let (&first, _) = from.split_first()?;
if from.iter().any(|i| *i >= self.items.len()) {
return None;
}
let below = first < self.contents_at();
// From the top down, so the lower indices stay valid; then back in order.
let mut block: Vec<AppearanceItem> = from.iter().rev().filter_map(|&i| self.remove_item(i)).collect();
block.reverse();
let others_below = self.contents_at();
let to = to.min(self.items.len());
let m = block.len();
self.items.splice(to..to, block);
let now_below = if below { to <= others_below } else { to < others_below };
self.set_contents_at(others_below + if now_below { m } else { 0 });
Some(to)
}
}
/// The most fills, strokes or effects a stack may have to be matched against another's
+94 -12
View File
@@ -37,6 +37,7 @@ use super::{
};
use crate::EngineError;
/// The Appearance panel's commands: fills and strokes, their order and copies, and the active item.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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; not with aligned rows of a mixed selection), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them",
has_doc,
duplicate_item
),
@@ -116,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -736,7 +737,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
/// 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>> {
pub(super) fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let mut v = match p.get("indices") {
Some(a) => a
.as_array()
@@ -750,6 +751,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
Ok(v)
}
/// `appearance.removeItem`: removes fill/stroke `index`, or every one of `indices`, from the selected objects in one undo step.
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = item_indices(p, "appearance.removeItem")?;
let ids = require_targets(s, p, "appearance.removeItem")?;
@@ -774,16 +776,22 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// `appearance.duplicateItem`: copies fill/stroke `index`, or the rows `indices` as one block at `to` (with the Contents row when `contents` says so), in one undo step.
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
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 contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
if contents.is_some() && to.is_none() {
return Err(bad(C, "`contents` goes with `to`"));
}
let ids = require_targets(s, p, C)?;
// Where the first object's copy landed (the active item lives in the first object's stack).
// Where the first target's copy landed, and where it landed on the active row's object (the
// first selected one, not always the first target): copies placed at `to` land at a different
// index on stacks of other lengths.
let mut first_at = None;
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_at = None;
// Aligned rows of a mixed selection are copied right above themselves in each object.
if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
@@ -793,31 +801,54 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut any = false;
for (id, idx) in ids.iter().zip(&per) {
let Some(n) = d.node_mut(*id) else { continue };
let has_contents = n.contents_label().is_some();
let ap = &mut n.appearance;
if let Some(t) = to {
// The copies land together at `to`, in their order; every item must exist.
if idx.iter().any(|i| *i >= ap.items.len()) {
return Err(bad(C, format!("no item at one of the indices {idx:?}")));
}
let copies: Vec<_> = idx.iter().filter_map(|&i| ap.items.get(i).cloned()).collect();
let at = t.min(ap.items.len());
first_at.get_or_insert(at);
if active == Some(*id) {
active_at = Some(at);
}
for (k, item) in copies.into_iter().enumerate() {
ap.insert_item(at + k, item);
}
if let Some(c) = contents.filter(|_| has_contents) {
ap.set_contents_at(c);
}
any = true;
continue;
}
let mut idx = idx.clone();
idx.sort_unstable();
// From the top down, so the lower indices stay valid.
for &i in idx.iter().rev() {
if let Some(item) = ap.items.get(i).cloned() {
let at = to.map_or(i + 1, |t| t.min(ap.items.len()));
first_at.get_or_insert(at);
ap.insert_item(at, item);
first_at.get_or_insert(i + 1);
ap.insert_item(i + 1, item);
any = true;
}
}
}
if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) }
})?;
// Rows at or above each copy move up by one.
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
let m = idx.len();
let landed = active_at.or(first_at);
s.remap_appearance_item(&ids, |a| {
Some(match to {
Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)),
Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 },
None => a + idx.iter().filter(|i| **i < a).count(),
})
});
ok()
}
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = require_targets(s, p, "appearance.showAllHidden")?;
let ids = s.edit("Show All Hidden Attributes", |d, _| {
@@ -830,6 +861,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// `appearance.moveItem`: moves fill/stroke `from` (or the Contents row) to `to`, or the rows `indices` as one block; the active item stays on its row.
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let to = index_param(p, "to", C)?;
@@ -837,11 +869,17 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("from").and_then(Value::as_str) == Some("contents") {
return move_contents(s, &ids, to);
}
let from = index_param(p, "from", C)?;
// Where an aligned row would go in each of several different stacks isn't one place.
if aligned(p) && appearances_differ(&s.doc()?.doc, &ids) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
}
if p.get("indices").is_some() {
if p.get("from").is_some() {
return Err(bad(C, "give `from` or `indices`, not both"));
}
return move_items(s, p, &ids, to);
}
let from = index_param(p, "from", C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
let mut landed = to;
// When every object's stack comes out as it was, the document and its undo history stay as they
@@ -872,6 +910,50 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {indices}`: move several items as one block (the lowest landing at `to`),
/// placing the Contents row with `contents` when given, as one undo step. The active row moves with
/// its row; the rows the block passes are renumbered.
fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<Value> {
const C: &str = "appearance.moveItem";
let idx = item_indices(p, C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
// Where the block landed on the first target (stacks of other lengths clamp `to` differently):
// the result, as the panel lists the first object's stack.
let mut first_landed = None;
// Where it landed on the active row's object: the active row is in its stack.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_landed = None;
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let landed = n.appearance.move_items(&idx, to).ok_or_else(|| bad(C, format!("no item at one of the indices {idx:?}")))?;
first_landed.get_or_insert(landed);
if active == Some(*id) {
active_landed = Some(landed);
}
if let Some(k) = contents.filter(|_| n.contents_label().is_some()) {
n.appearance.set_contents_at(k);
}
}
Ok(count_changed(d, &before, ids))
})?;
let m = idx.len();
let landed = first_landed.unwrap_or(to);
let at = active_landed.unwrap_or(landed);
s.remap_appearance_item(ids, |a| {
Some(match idx.iter().position(|i| *i == a) {
Some(k) => at + k,
None => {
// Its place among the unmoved rows, then above the block if it is at or above it.
let r = a - idx.iter().filter(|i| **i < a).count();
if r < at { r } else { r + m }
}
})
});
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
/// has one above its bottom `to` items.
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
+100 -20
View File
@@ -12,11 +12,12 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_target, item_target_at, shared_stack};
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at, shared_stack};
use super::expand::has_seeded;
use super::rasterfx;
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param};
/// The effect commands of the Effect menu and the Appearance panel.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -42,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Effect",
[],
None,
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
has_doc,
remove
),
@@ -78,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Effect",
[],
None,
"{index: int, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of the effect right after it → {ids}",
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of each effect right after it, in one undo step → {ids}",
has_doc,
duplicate
),
@@ -87,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect 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?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
has_doc,
move_effect
),
@@ -157,6 +158,50 @@ fn edit_effects(
})
}
/// [`edit_effects`] for several effects of one list (`indices`): `f` gets each target's list and
/// the positions of `indices` there, ascending; with `aligned`, a position a target doesn't share
/// is left out of it, and a target sharing none of them is left out.
#[allow(clippy::too_many_arguments)]
fn edit_effects_at(
s: &mut Session,
p: &Value,
item: ItemTarget,
cmd: &str,
label: &str,
none: &str,
indices: &[usize],
mut f: impl FnMut(&mut Vec<Effect>, &[usize]) -> bool,
) -> Result<Vec<NodeId>> {
// Each target's list and the positions in it, in the order the targets first come up.
let mut targets: Vec<(NodeId, Option<usize>, Vec<usize>)> = vec![];
for &index in indices {
for (id, at, i) in effect_targets(s, p, item, index, cmd)? {
match targets.iter_mut().find(|t| t.0 == id && t.1 == at) {
Some(t) => t.2.push(i),
None => targets.push((id, at, vec![i])),
}
}
}
for t in &mut targets {
t.2.sort_unstable();
t.2.dedup();
}
s.edit(label, |d, _| {
let mut done = vec![];
for (id, at, idx) in &targets {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.effects_mut(*at).is_some_and(|fx| f(fx, idx)) {
check_revolve_count(n, cmd)?;
done.push(*id);
}
}
if done.is_empty() {
return Err(EngineError::Other(none.into()));
}
Ok(done)
})
}
fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
let all = || n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects()));
if all().filter(|e| e.id == effects::REVOLVE).count() > 1 {
@@ -238,6 +283,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<N
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
/// `effect.list`: the effect catalog (built-in and plug-in effects) and the effects applied to the selection, with the active item.
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
@@ -266,35 +312,66 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "catalog": catalog, "applied": applied, "activeItem": s.appearance_item() }))
}
/// The effects `effect.remove` / `effect.duplicate` act on: `indices`, else `index` (ascending, no
/// repeats), and the "no effect" message naming them.
fn effect_indices(p: &Value, cmd: &str) -> Result<(Vec<usize>, String)> {
let idx = item_indices(p, cmd)?;
let list = idx.iter().map(usize::to_string).collect::<Vec<_>>().join(", ");
let none = if idx.len() == 1 { format!("{cmd}: no effect at index {list}") } else { format!("{cmd}: no effects at indices {list}") };
Ok((idx, none))
}
/// `effect.remove`: removes effect `index`, or every one of `indices`, from the object's list or fill/stroke `item`'s, in one undo step.
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.remove";
let index = index_param(p, "index", C)?;
let (idx, none) = effect_indices(p, C)?;
let item = item_target(s, p, C)?;
let ids = edit_effects(s, p, item, C, "Remove Effect", &format!("{C}: no effect at index {index}"), index, |fx, index| {
(index < fx.len()).then(|| fx.remove(index)).is_some()
let label = if idx.len() == 1 { "Remove Effect" } else { "Remove Effects" };
let ids = edit_effects_at(s, p, item, C, label, &none, &idx, |fx, idx| {
// Every effect must exist; from the top down, so the lower positions stay valid.
if idx.iter().any(|i| *i >= fx.len()) {
return false;
}
for &i in idx.iter().rev() {
fx.remove(i);
}
true
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
/// `effect.duplicate`: copies effect `index`, or each of `indices`, right after its original, in one undo step.
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.duplicate";
let index = index_param(p, "index", C)?;
let (idx, none) = effect_indices(p, C)?;
let item = item_target(s, p, C)?;
let ids = edit_effects(s, p, item, C, "Duplicate Effect", &format!("{C}: no effect at index {index}"), index, |fx, index| {
match fx.get(index).cloned() {
Some(e) => {
fx.insert(index + 1, e);
true
}
None => false,
let label = if idx.len() == 1 { "Duplicate Effect" } else { "Duplicate Effects" };
let ids = edit_effects_at(s, p, item, C, label, &none, &idx, |fx, idx| {
if idx.iter().any(|i| *i >= fx.len()) {
return false;
}
// From the top down, so each copy goes right after its original.
for &i in idx.iter().rev() {
if let Some(e) = fx.get(i).cloned() {
fx.insert(i + 1, e);
}
}
true
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
/// `effect.move`: moves (with `copy`: copies) effect `from`, or the effects `from` lists as one block in their order, to `to` in its own list or in `toItem`'s.
fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.move";
let from = index_param(p, "from", C)?;
// One position or several (ascending, no repeats).
let from = match p.get("from") {
Some(Value::Array(_)) => {
let list = json!({ "indices": p["from"] });
item_indices(&list, C)?
}
_ => vec![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 };
@@ -311,17 +388,20 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
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(si).filter(|fx| from.iter().all(|i| *i < fx.len())) else { continue };
// Taken out from the top down, so the lower positions stay valid; put back in order.
let mut taken: Vec<Effect> = from.iter().rev().filter_map(|&i| if copy { fx.get(i).cloned() } else { Some(fx.remove(i)) }).collect();
taken.reverse();
let Some(fx) = n.appearance.effects_mut(di) else { continue };
let at = to.min(fx.len());
fx.insert(at, e);
fx.splice(at..at, taken);
check_revolve_count(n, C)?;
landed.push((at, di));
done.push(*id);
}
if done.is_empty() {
return Err(EngineError::Other(format!("{C}: no effect at index {from}")));
let list = from.iter().map(usize::to_string).collect::<Vec<_>>().join(", ");
return Err(EngineError::Other(format!("{C}: no effect at index {list}")));
}
Ok(done)
})?;
+66
View File
@@ -429,6 +429,7 @@ fn pathfinder_effect_on_a_group_with_type_cuts_the_glyphs_out() {
assert!((left - (10_000.0 - glyph_area)).abs() < 0.5, "{left} left of the square for a glyph area of {glyph_area}");
}
/// A Pathfinder effect on a group renders live and expands to its result.
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
@@ -448,6 +449,7 @@ fn pathfinder_effect_on_a_group_renders_live_and_expands() {
assert_eq!(ch.len(), 1);
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
}
/// Expand Appearance on a Transform effect with copies gives each copy its own gradient.
#[test]
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
@@ -471,3 +473,67 @@ fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
});
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
}
/// The object's own effect ids, in order.
fn object_effects(s: &Session, id: NodeId) -> Vec<String> {
node(s, id).appearance.effects.iter().map(|e| e.id.clone()).collect()
}
/// A rectangle with Drop Shadow, Outer Glow and Twist on the object.
fn three_effects() -> (Session, NodeId) {
let (mut s, id) = session_with_rect();
for e in ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"] {
s.execute("effect.apply", &json!({"effect": e, "ids": [id.0], "item": null})).unwrap();
}
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"]);
(s, id)
}
/// `effect.remove` with `indices` removes several effects in one undo step.
#[test]
fn remove_takes_several_effects_in_one_undo_step() {
let (mut s, id) = three_effects();
s.execute("effect.remove", &json!({"indices": [2, 0], "ids": [id.0], "item": null})).unwrap();
assert_eq!(object_effects(&s, id), ["stylize.outerGlow"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"]);
// An index past the list fails and changes nothing; so does an empty list.
for bad in [json!([0, 3]), json!([]), json!(["x"])] {
assert!(s.execute("effect.remove", &json!({"indices": bad, "ids": [id.0], "item": null})).is_err(), "{bad}");
assert_eq!(object_effects(&s, id).len(), 3, "{bad}");
}
}
/// `effect.duplicate` with `indices` puts each copy right after its original.
#[test]
fn duplicate_takes_several_effects_each_copy_after_its_original() {
let (mut s, id) = three_effects();
s.execute("effect.duplicate", &json!({"indices": [0, 2], "ids": [id.0], "item": null})).unwrap();
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.dropShadow", "stylize.outerGlow", "distort.twist", "distort.twist"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(object_effects(&s, id).len(), 3);
assert!(s.execute("effect.duplicate", &json!({"indices": [1, 5], "ids": [id.0], "item": null})).is_err());
assert_eq!(object_effects(&s, id).len(), 3);
}
/// `effect.move` with several `from` lands them together, in their order.
#[test]
fn move_takes_several_effects_that_land_together_in_order() {
// [Shadow, Glow, Twist]: Shadow and Twist moved to the end of the list land as one block.
let (mut s, id) = three_effects();
let r = s.execute("effect.move", &json!({"from": [0, 2], "to": 1, "ids": [id.0], "fromItem": null})).unwrap();
assert_eq!(object_effects(&s, id), ["stylize.outerGlow", "stylize.dropShadow", "distort.twist"]);
assert_eq!(r["index"], json!(1), "where the first of them landed");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(object_effects(&s, id), ["stylize.dropShadow", "stylize.outerGlow", "distort.twist"]);
// Into the stroke's list (item 1), copying: the object keeps its effects.
s.execute("effect.move", &json!({"from": [1, 2], "to": 0, "fromItem": null, "toItem": 1, "copy": true, "ids": [id.0]})).unwrap();
let n = node(&s, id);
let on_stroke: Vec<_> = n.appearance.items[1].effects().iter().map(|e| e.id.clone()).collect();
assert_eq!(on_stroke, ["stylize.outerGlow", "distort.twist"]);
assert_eq!(object_effects(&s, id).len(), 3);
// A bad index fails and changes nothing.
let before = node(&s, id).appearance;
assert!(s.execute("effect.move", &json!({"from": [0, 9], "to": 0, "fromItem": null, "ids": [id.0]})).is_err());
assert_eq!(node(&s, id).appearance, before);
}
+121
View File
@@ -342,6 +342,7 @@ fn group_appearance_round_trips_and_expands() {
assert!((n.visual_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
}
/// Type in a group with a geometry effect exports and expands with its look.
#[test]
fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() {
let mut s = session();
@@ -367,6 +368,7 @@ fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() {
assert!(!matches!(outlines.kind, NodeKind::Text(_)) && (outlines.opacity - 0.5).abs() < 1e-6 && !outlines.visible, "{outlines:?}");
}
/// Hidden type with a geometry effect (Offset Path) is left out of SVG export.
#[test]
fn hidden_type_with_a_geometry_effect_is_left_out_of_svg_export() {
let mut s = session();
@@ -379,3 +381,122 @@ fn hidden_type_with_a_geometry_effect_is_left_out_of_svg_export() {
let hidden = svg(&s);
assert_eq!((vectorcraft_testkit::svg_shapes(&shown), vectorcraft_testkit::svg_shapes(&hidden)), (1, 0), "{hidden}");
}
/// A selected group whose stack is, in paint order (bottom first), `F0 S1 FA S3` with the Contents
/// row above the bottom two: listed top-down `S3, FA, Contents, S1, F0` (fills red F0 and green FA,
/// strokes 1 and 3 pt).
fn straddling_group(s: &mut Session) -> NodeId {
let (g, _) = group(s);
run(s, "appearance.addFill", json!({}));
run(s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(s, "appearance.addStroke", json!({}));
run(s, "stroke.set", json!({"item": 1, "weight": 1}));
run(s, "appearance.addFill", json!({}));
run(s, "paint.setFill", json!({"color": "#00ff00", "item": 2}));
run(s, "appearance.addStroke", json!({}));
run(s, "stroke.set", json!({"item": 3, "weight": 3}));
run(s, "appearance.moveItem", json!({"from": "contents", "to": 2}));
assert_eq!(stack(s, g), (vec!["F0", "S1", "FA", "S3"], 2));
g
}
/// The group's items in paint order (F0/FA by fill colour, S<weight>) and how many paint below its
/// Contents row.
fn stack(s: &Session, g: NodeId) -> (Vec<&'static str>, usize) {
let ap = node(s, g).appearance;
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
let names = ap
.items
.iter()
.map(|it| match it {
vectorcraft_doc::AppearanceItem::Stroke(st) if st.width == 1.0 => "S1",
vectorcraft_doc::AppearanceItem::Stroke(_) => "S3",
it if *it.paint() == red => "F0",
_ => "FA",
})
.collect();
(names, ap.contents_at())
}
/// Fills/strokes moved together land as one block; the Contents row stays among the rows that did not move.
#[test]
fn several_items_move_as_one_block_and_the_contents_row_keeps_its_unmoved_neighbours() {
let mut s = session();
let g = straddling_group(&mut s);
// FA (active) and S1 dropped on S3: listed top-down FA, S1, S3, Contents, F0.
run(&mut s, "appearance.setActiveItem", json!({"index": 2}));
let r = run(&mut s, "appearance.moveItem", json!({"indices": [2, 1], "to": 2}));
assert_eq!(stack(&s, g), (vec!["F0", "S3", "S1", "FA"], 1));
assert_eq!(r["index"], json!(2), "where the lowest of them landed");
assert_eq!(s.appearance_item(), Some(3), "the active row moved with its row");
run(&mut s, "edit.undo", json!({}));
assert_eq!(stack(&s, g), (vec!["F0", "S1", "FA", "S3"], 2), "one undo step");
// `contents` places the Contents row: under the stack, the block goes to the bottom with
// Contents above it and F0.
run(&mut s, "appearance.moveItem", json!({"indices": [1, 2], "to": 0, "contents": 3}));
assert_eq!(stack(&s, g), (vec!["S1", "FA", "F0", "S3"], 3));
run(&mut s, "edit.undo", json!({}));
// Without it, a block landing right at the Contents row keeps its lowest item's side.
run(&mut s, "appearance.moveItem", json!({"indices": [1, 3], "to": 1}));
assert_eq!(stack(&s, g), (vec!["F0", "S1", "S3", "FA"], 3));
run(&mut s, "edit.undo", json!({}));
// A missing index, or both `from` and `indices`, fail and change nothing.
assert!(s.execute("appearance.moveItem", &json!({"indices": [1, 9], "to": 0})).is_err());
assert!(s.execute("appearance.moveItem", &json!({"from": 1, "indices": [1, 2], "to": 0})).is_err());
assert_eq!(stack(&s, g), (vec!["F0", "S1", "FA", "S3"], 2));
}
/// Fills/strokes copied together land as one block where they are dropped.
#[test]
fn several_items_copy_as_one_block_where_they_are_dropped() {
let mut s = session();
let g = straddling_group(&mut s);
// Alt-dropped on the object row: the copies go on top; Contents stays between the same rows.
run(&mut s, "appearance.duplicateItem", json!({"indices": [1, 2], "to": 4}));
assert_eq!(stack(&s, g), (vec!["F0", "S1", "FA", "S3", "S1", "FA"], 2));
run(&mut s, "edit.undo", json!({}));
// `contents` puts copies landing right at the Contents row above it.
run(&mut s, "appearance.duplicateItem", json!({"indices": [1, 2], "to": 2, "contents": 2}));
assert_eq!(stack(&s, g), (vec!["F0", "S1", "S1", "FA", "FA", "S3"], 2));
run(&mut s, "edit.undo", json!({}));
assert!(s.execute("appearance.duplicateItem", &json!({"indices": [1, 7], "to": 0})).is_err());
assert_eq!(stack(&s, g), (vec!["F0", "S1", "FA", "S3"], 2));
}
/// Several objects with stacks of different lengths: a block moved past the end of the shorter
/// stack lands at a different index on each. The active row follows its row on the first selected
/// object (whose stack it is in), and the result says where the block landed there, not on the
/// last one.
#[test]
fn a_block_move_keeps_the_active_row_on_the_first_objects_stack() {
let mut s = session();
let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let b = id_of(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})));
run(&mut s, "appearance.addFill", json!({"ids": [b.0]}));
run(&mut s, "appearance.addStroke", json!({"ids": [b.0]}));
assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (2, 4));
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
// On A the fill lands at 1 (its top), on B at 3.
let r = run(&mut s, "appearance.moveItem", json!({"indices": [0], "to": 3}));
assert_eq!(s.appearance_item(), Some(1), "the active row is A's moved fill");
assert_eq!(r["index"], 1, "where it landed on the first target, whose stack the panel lists");
}
/// An Alt-drop (`appearance.duplicateItem` with `to`) on several objects with stacks of different
/// lengths places the copies at a different index on each: the active row shifts by where they
/// landed on its own object (the first selected one), not on the first target processed.
#[test]
fn a_block_copy_shifts_the_active_row_by_its_own_objects_landing() {
let mut s = session();
let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let b = id_of(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})));
run(&mut s, "appearance.addFill", json!({"ids": [b.0]}));
run(&mut s, "appearance.addStroke", json!({"ids": [b.0]}));
// B first: the active row is in B's stack [F, S, F, S].
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 2}));
// The copies land at 3 on B (above its active fill) and at 2 on A, its top.
run(&mut s, "appearance.duplicateItem", json!({"indices": [0], "to": 3}));
assert_eq!(s.appearance_item(), Some(2), "the copy landed above B's active fill: it stays at 2");
}
+6 -1
View File
@@ -70,6 +70,7 @@ fn effects_move_and_copy_between_the_object_and_its_items() {
assert!(s.execute("effect.move", &json!({"from": 0, "fromItem": 1, "to": 0, "toItem": 7})).is_err());
}
/// `effect.duplicate` copies a fill's effect right after it.
#[test]
fn duplicate_selected_works_for_effects() {
let (mut s, id) = session_with_rect();
@@ -78,6 +79,7 @@ fn duplicate_selected_works_for_effects() {
assert_eq!(fx(&s, id, Some(0)), ["distort.roughen", "distort.roughen"]);
}
/// `appearance.duplicateItem` copies a fill/stroke to a given position, and `appearance.removeItem` with `indices` removes several at once.
#[test]
fn items_duplicate_to_a_position_and_several_remove_at_once() {
let (mut s, id) = session_with_rect();
@@ -91,7 +93,10 @@ fn items_duplicate_to_a_position_and_several_remove_at_once() {
// 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());
// Several with `to` are copied as one block there (Alt-dragging highlighted rows).
run(&mut s, "appearance.duplicateItem", json!({"indices": [0, 1], "to": 3}));
assert_eq!(kinds(&s), ["fill", "fill", "fill", "fill", "fill", "stroke", "stroke", "fill"]);
s.execute("edit.undo", &json!({})).unwrap();
// 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]}));
+9
View File
@@ -455,6 +455,15 @@ fn structured_junk() {
("addon.color.applyNearestSwatch", json!({"library": "nope", "ids": "x", "stroke": 3})),
("effect.apply", json!({"effect": "no.such.effect"})),
("effect.remove", json!({"index": 99})),
("effect.remove", json!({"indices": [0, 99, u64::MAX]})),
("effect.remove", json!({"indices": []})),
("effect.duplicate", json!({"indices": [u64::MAX, 0, 0]})),
("effect.move", json!({"from": [0, u64::MAX], "to": u64::MAX, "toItem": 99})),
("effect.move", json!({"from": [], "to": 0})),
("appearance.moveItem", json!({"indices": [0, 1, u64::MAX], "to": u64::MAX, "contents": u64::MAX})),
("appearance.moveItem", json!({"indices": [1, 0], "to": 0, "from": 0})),
("appearance.duplicateItem", json!({"indices": [0, 1], "to": u64::MAX, "contents": u64::MAX})),
("appearance.duplicateItem", json!({"indices": [0], "contents": 1})),
("effect.setParams", json!({"index": 0, "params": null})),
("object.path.offsetPath", json!({"offset": 1e308})),
("object.path.offsetPath", json!({"offset": -1e308})),
+2
View File
@@ -836,6 +836,8 @@ font-technology Variable Променливи
Double Right Quotation Marks Двойни дясна кавичка
Double-click Двойно щракване
Double-click to edit the characters' paint Двойно щракване за редактиране на оцветяването на знаците
Click to edit Щракнете за редактиране
Double-click to edit Двойно щракване за редактиране
Double-click to insert Двойно щракване за вмъкване
Double-click to rename Двойно щракване за преименуване
Double-click to target the contents Двойно щракване за насочване към съдържанието
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1375,6 +1375,8 @@ lighting Light Světlo
Dashed Čárkovaný
Delete Selected Item Odstranit vybranou položku
Double-click to edit the characters' paint Dvojklikem upravíte barvu znaků
Click to edit Kliknutím upravíte
Double-click to edit Dvojklikem upravíte
Double-click to target the contents Dvojklikem zacílíte obsah
Drag onto art to apply this appearance Přetažením na kresbu použijete tento vzhled
Duplicate Selected Item Duplikovat vybranou položku
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1286,6 +1286,8 @@ lighting Light Lys
Dashed Stiplet
Delete Selected Item Slet markeret element
Double-click to edit the characters' paint Dobbeltklik for at redigere tegnenes maling
Click to edit Klik for at redigere
Double-click to edit Dobbeltklik for at redigere
Double-click to target the contents Dobbeltklik for at målrette indholdet
Drag onto art to apply this appearance Træk hen over grafikken for at anvende dette udseende
Duplicate Selected Item Duplikér markeret element
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -830,6 +830,8 @@ font-technology Variable Variabel
Double Right Quotation Marks Doppelte schließende Anführungszeichen
Double-click Doppelklicken
Double-click to edit the characters' paint Doppelklicken, um die Farbe der Zeichen zu bearbeiten
Click to edit Klicken, um zu bearbeiten
Double-click to edit Doppelklicken, um zu bearbeiten
Double-click to insert Doppelklicken zum Einfügen
Double-click to rename Doppelklicken zum Umbenennen
Double-click to target the contents Doppelklicken, um den Inhalt als Ziel auszuwählen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -809,6 +809,8 @@ font-technology Variable Μεταβλητές
Double Right Quotation Marks Δεξιά διπλά εισαγωγικά
Double-click Διπλό κλικ
Double-click to edit the characters' paint Διπλό κλικ για επεξεργασία του χρωματισμού χαρακτήρων
Click to edit Κλικ για επεξεργασία
Double-click to edit Διπλό κλικ για επεξεργασία
Double-click to insert Διπλό κλικ για εισαγωγή
Double-click to rename Διπλό κλικ για μετονομασία
Double-click to target the contents Διπλό κλικ για επιλογή του περιεχομένου ως στόχου
Can't render this file because it contains an unexpected character in line 163 and column 5.
+2
View File
@@ -851,6 +851,8 @@ font-technology Variable Variables
Double Right Quotation Marks Comillas dobles de cierre
Double-click Doble clic
Double-click to edit the characters' paint Doble clic para editar la pintura de los caracteres
Click to edit Clic para editar
Double-click to edit Doble clic para editar
Double-click to insert Doble clic para insertar
Double-click to rename Doble clic para cambiar el nombre
Double-click to target the contents Doble clic para seleccionar el contenido como destino
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1349,6 +1349,8 @@ lighting Light Valo
Dashed Katkoviiva
Delete Selected Item Poista valittu kohde
Double-click to edit the characters' paint Muokkaa merkkien väritystä kaksoisnapsauttamalla
Click to edit Muokkaa napsauttamalla
Double-click to edit Muokkaa kaksoisnapsauttamalla
Double-click to target the contents Kohdista sisältöön kaksoisnapsauttamalla
Drag onto art to apply this appearance Käytä tätä ulkoasua vetämällä se kuvituksen päälle
Duplicate Selected Item Monista valittu kohde
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -854,6 +854,8 @@ font-technology Variable Variables
Double Right Quotation Marks Guillemets doubles fermants
Double-click Double-clic
Double-click to edit the characters' paint Double-cliquez pour modifier la peinture des caractères
Click to edit Cliquez pour modifier
Double-click to edit Double-cliquez pour modifier
Double-click to insert Double-cliquez pour insérer
Double-click to rename Double-cliquez pour renommer
Double-click to target the contents Double-cliquez pour cibler le contenu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1101,6 +1101,8 @@ font-technology Variable Változó
Double Slash Kettős ferde vonal
Double-click Dupla kattintás
Double-click to edit the characters' paint Dupla kattintással a karakterek festése szerkeszthető
Click to edit Kattintással szerkeszthető
Double-click to edit Dupla kattintással szerkeszthető
Double-click to insert Beszúrás dupla kattintással
Double-click to rename Dupla kattintással átnevezhető
Double-click to target the contents Dupla kattintással a tartalom lesz a cél
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1286,6 +1286,8 @@ lighting Light Cahaya
Dashed Putus-putus
Delete Selected Item Hapus item terpilih
Double-click to edit the characters' paint Klik ganda untuk mengedit pewarnaan karakter
Click to edit Klik untuk mengedit
Double-click to edit Klik ganda untuk mengedit
Double-click to target the contents Klik ganda untuk menargetkan konten
Drag onto art to apply this appearance Seret ke karya seni untuk menerapkan penampilan ini
Duplicate Selected Item Duplikatkan item terpilih
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1286
1287
1288
1289
1290
1291
1292
1293
+2
View File
@@ -851,6 +851,8 @@ font-technology Variable Variabili
Double Right Quotation Marks Virgolette doppie di chiusura
Double-click Fai doppio clic
Double-click to edit the characters' paint Fai doppio clic per modificare il colore dei caratteri
Click to edit Fai clic per modificare
Double-click to edit Fai doppio clic per modificare
Double-click to insert Fai doppio clic per inserire
Double-click to rename Fai doppio clic per rinominare
Double-click to target the contents Fai doppio clic per impostare il contenuto come destinazione
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1403,6 +1403,8 @@ digits Malayalam マラヤーラム数字
Double Quotes: 二重引用符:
Double-click ダブルクリック
Double-click to edit the characters' paint ダブルクリックで文字のペイントを編集
Click to edit クリックで編集
Double-click to edit ダブルクリックで編集
Double-click to insert ダブルクリックで挿入
Double-click to rename ダブルクリックで名前を変更
Double-click to target the contents ダブルクリックで内容をターゲット
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1205,6 +1205,8 @@ lighting Light სინათლე
Dashed ტირეებიანი
Delete Selected Item მონიშნული ელემენტის წაშლა
Double-click to edit the characters' paint ორჯერ დაწკაპეთ სიმბოლოების შეღებვის რედაქტირებისთვის
Click to edit დააწკაპეთ რედაქტირებისთვის
Double-click to edit ორჯერ დაწკაპეთ რედაქტირებისთვის
Double-click to target the contents ორჯერ დაწკაპეთ შიგთავსის ასარჩევად
Drag onto art to apply this appearance ჩაავლეთ ნახატზე ამ იერსახის გამოსაყენებლად
Duplicate Selected Item მონიშნული ელემენტის დუბლირება
Can't render this file because it is too large.
+2
View File
@@ -1393,6 +1393,8 @@ digits Malayalam 말라얄람어
Double Quotes: 큰따옴표:
Double-click 더블클릭
Double-click to edit the characters' paint 더블클릭하여 문자의 색 편집
Click to edit 클릭하여 편집
Double-click to edit 더블클릭하여 편집
Double-click to insert 더블클릭하여 삽입
Double-click to rename 더블클릭하여 이름 변경
Double-click to target the contents 더블클릭하여 내용 선택
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1300,6 +1300,8 @@ lighting Light Licht
Dashed Gestreept
Delete Selected Item Geselecteerd item verwijderen
Double-click to edit the characters' paint Dubbelklik om de verf van de tekens te bewerken
Click to edit Klik om te bewerken
Double-click to edit Dubbelklik om te bewerken
Double-click to target the contents Dubbelklik om de inhoud als doel te selecteren
Drag onto art to apply this appearance Sleep naar de illustratie om deze vormgeving toe te passen
Duplicate Selected Item Geselecteerd item dupliceren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1289,6 +1289,8 @@ lighting Light Światło
Dashed Kreskowany
Delete Selected Item Usuń zaznaczony element
Double-click to edit the characters' paint Kliknij dwukrotnie, aby edytować farbę znaków
Click to edit Kliknij, aby edytować
Double-click to edit Kliknij dwukrotnie, aby edytować
Double-click to target the contents Kliknij dwukrotnie, aby wskazać zawartość
Drag onto art to apply this appearance Przeciągnij na kompozycję, aby zastosować ten wygląd
Duplicate Selected Item Powiel zaznaczony element
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -761,6 +761,8 @@
Double Right Quotation Marks Aspas Direitas Duplas
Double-click Clique duplo
Double-click to edit the characters' paint Clique duplo para editar a pintura dos caracteres
Click to edit Clique para editar
Double-click to edit Clique duplo para editar
Double-click to insert Clique duplo para inserir
Double-click to rename Clique duplo para renomear
Double-click to target the contents Clique duplo para direcionar o conteúdo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -849,6 +849,8 @@ font-technology Variable Вариативные
Double Right Quotation Marks Двойные закрывающие кавычки
Double-click Двойной щелчок
Double-click to edit the characters' paint Дважды щёлкните, чтобы редактировать заливку символов
Click to edit Щёлкните, чтобы редактировать
Double-click to edit Дважды щёлкните, чтобы редактировать
Double-click to insert Дважды щёлкните для вставки
Double-click to rename Дважды щёлкните для переименования
Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -838,6 +838,8 @@ font-technology Variable Variabla
Double Right Quotation Marks Dubbla högercitattecken
Double-click Dubbelklick
Double-click to edit the characters' paint Dubbelklicka för att redigera tecknens färg
Click to edit Klicka för att redigera
Double-click to edit Dubbelklicka för att redigera
Double-click to insert Dubbelklicka för att infoga
Double-click to rename Dubbelklicka för att byta namn
Double-click to target the contents Dubbelklicka för att välja innehållet som mål
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -830,6 +830,8 @@ font-technology Variable Değişken
Double Right Quotation Marks Çift Sağ Tırnak İşaretleri
Double-click Çift tıklama
Double-click to edit the characters' paint Karakterlerin boyasını düzenlemek için çift tıklayın
Click to edit Düzenlemek için tıklayın
Double-click to edit Düzenlemek için çift tıklayın
Double-click to insert Eklemek için çift tıklayın
Double-click to rename Yeniden adlandırmak için çift tıklayın
Double-click to target the contents İçeriği hedeflemek için çift tıklayın
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -845,6 +845,8 @@ font-technology Variable Варіативні
Double Right Quotation Marks Подвійні лапки, що закривають
Double-click Подвійне клацання
Double-click to edit the characters' paint Двічі клацніть, щоб редагувати заливку символів
Click to edit Клацніть, щоб редагувати
Double-click to edit Двічі клацніть, щоб редагувати
Double-click to insert Двічі клацніть для вставки
Double-click to rename Двічі клацніть для перейменування
Double-click to target the contents Двічі клацніть, щоб вибрати вміст як об’єкт
Can't render this file because it contains an unexpected character in line 177 and column 5.
+2
View File
@@ -986,6 +986,8 @@ font-technology Variable Biến thiên
Double Right Quotation Marks Dấu ngoặc kép phải đôi
Double-click Bấm đúp
Double-click to edit the characters' paint Bấm đúp để chỉnh sửa màu ký tự
Click to edit Bấm để chỉnh sửa
Double-click to edit Bấm đúp để chỉnh sửa
Double-click to insert Bấm đúp để chèn
Double-click to rename Bấm đúp để đổi tên
Double-click to target the contents Bấm đúp để chọn nội dung
Can't render this file because it contains an unexpected character in line 199 and column 5.
+2
View File
@@ -793,6 +793,8 @@
Double Right Quotation Marks 右双引号
Double-click 双击
Double-click to edit the characters' paint 双击以编辑字符的上色
Click to edit 单击以编辑
Double-click to edit 双击以编辑
Double-click to insert 双击以插入
Double-click to rename 双击以重命名
Double-click to target the contents 双击以指定内容
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -813,6 +813,8 @@ font-technology Variable 可變
Double Right Quotation Marks 右雙引號
Double-click 按兩下
Double-click to edit the characters' paint 按兩下以編輯字元的上色
Click to edit 按一下以編輯
Double-click to edit 按兩下以編輯
Double-click to insert 按兩下以插入
Double-click to rename 按兩下以重新命名
Double-click to target the contents 按兩下以指定內容
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -492,6 +492,26 @@ pub(crate) fn set_pstate<T: Clone + Send + Sync + 'static>(ctx: &egui::Context,
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("panel-state", key)), v));
}
/// Whether the panel whose state is `key` has the keyboard for its rows (Delete and Backspace
/// remove its highlighted rows): it took it with a click on a row ([`set_row_keys`]) and kept it in
/// every frame since, so it lapses when the panel isn't drawn.
pub(crate) fn has_row_keys(ctx: &egui::Context, key: &str) -> bool {
pstate::<Option<u64>>(ctx, key).is_some_and(|pass| pass + 1 >= ctx.cumulative_pass_nr())
}
/// Take (`on`) or give up the keyboard for the rows of the panel whose state is `key`, for this
/// frame and the next ([`has_row_keys`]).
pub(crate) fn set_row_keys(ctx: &egui::Context, key: &str, on: bool) {
set_pstate(ctx, key, on.then(|| ctx.cumulative_pass_nr()));
}
/// Whether a plain Delete or Backspace belongs to a panel's rows this frame, the Layers or the
/// Appearance panel's after a click on one of them, so [`crate::shortcuts::handle`] leaves it to the
/// panel instead of running Edit › Clear on the selected art.
pub(crate) fn rows_take_delete(ctx: &egui::Context) -> bool {
has_row_keys(ctx, layers::ROW_KEYS) || has_row_keys(ctx, appearance::ROW_KEYS)
}
/// [`crate::i18n::label_or_name`] in the UI language.
pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
crate::i18n::label_or_name(crate::i18n::current(), s, built_in)
File diff suppressed because it is too large Load Diff
+162 -4
View File
@@ -1,5 +1,60 @@
use super::*;
/// Duplicating rows highlights the copies, each right above its original, as the Layers panel
/// does: the active row's copy becomes active, the anchor's copy the anchor.
#[test]
fn a_duplicate_highlights_the_copies_above_the_originals() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({ "ids": [id] })).unwrap();
for _ in 0..3 {
app.run("effect.apply", json!({"effect": "distort.roughen"})).unwrap();
}
let owner = Some(NodeId(id));
let ctx = egui::Context::default();
let duplicate = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let node = first_subject(app);
let sel = current_sel(app, ui.ctx(), node.as_ref());
duplicate_selected(app, ui, node.as_ref(), sel);
});
out.textures_delta.clear();
};
// Effects 0 and 2 highlighted, 2 selected: the list reads copy, 0, 1, copy, 2 (top down), so
// the copies are rows 0 and 3.
app.run("appearance.setActiveItem", json!({ "index": null })).unwrap();
set_pstate(&ctx, "ap-sel", (owner, Sel::Effect(2)));
set_pstate(&ctx, "ap-multi", Highlight { owner, rows: Rows::Effects(None), order: vec![2, 0], anchor: Some(0), active: Some(2) });
duplicate(&mut app);
let h: Highlight = pstate(&ctx, "ap-multi");
assert_eq!((h.order, h.anchor), (vec![3, 0], Some(0)));
assert_eq!(pstate::<(Option<NodeId>, Sel)>(&ctx, "ap-sel").1, Sel::Effect(3));
// [Fill, Stroke] both highlighted, the stroke active: paint order fill, copy, stroke, copy,
// so the copies are 1 and 3 and the stroke's copy is active.
app.run("appearance.setActiveItem", json!({ "index": 1 })).unwrap();
set_pstate(&ctx, "ap-sel", (owner, Sel::Item(1)));
set_pstate(&ctx, "ap-multi", Highlight { owner, rows: Rows::Items, order: vec![1, 0], anchor: Some(1), active: Some(1) });
duplicate(&mut app);
let h: Highlight = pstate(&ctx, "ap-multi");
assert_eq!((h.order, h.anchor), (vec![3, 1], Some(3)));
assert_eq!(app.session.appearance_item(), Some(3));
// A row outside the highlight (dropped on Duplicate): only its copy is highlighted and active.
app.run("appearance.setActiveItem", json!({ "index": null })).unwrap();
set_pstate(&ctx, "ap-sel", (owner, Sel::Effect(0)));
set_pstate(&ctx, "ap-multi", Highlight { owner, rows: Rows::Effects(None), order: vec![0], anchor: Some(0), active: Some(0) });
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let n = first_subject(&app).unwrap();
let sel = current_sel(&app, ui.ctx(), Some(&n));
app.run("effect.duplicate", json!({"item": null, "indices": [4]})).unwrap();
highlight_copies(&mut app, ui.ctx(), &n, sel, Rows::Effects(None), &[4]);
});
out.textures_delta.clear();
let h: Highlight = pstate(&ctx, "ap-multi");
assert_eq!((h.order, h.anchor), (vec![4], Some(4)));
assert_eq!(pstate::<(Option<NodeId>, Sel)>(&ctx, "ap-sel").1, Sel::Effect(4));
}
#[test]
fn opacity_labels() {
assert_eq!(opacity_text(1.0, BlendMode::Normal), "Default");
@@ -311,7 +366,7 @@ fn the_effect_editor_keeps_text_typed_across_frames() {
out.textures_delta.clear();
out
};
// Every text drawn, with its center.
/// Every text drawn, with its center.
fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())),
@@ -358,6 +413,7 @@ fn the_effect_editor_keeps_text_typed_across_frames() {
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
}
/// Each place a dragged row can be dropped picks the command it runs and its parameters.
#[test]
fn drops_pick_the_command() {
use vectorcraft_doc::{Appearance, FillLayer};
@@ -398,6 +454,72 @@ fn drops_pick_the_command() {
assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack");
}
/// Several highlighted rows dropped together (the owner's straddling example): a group listed
/// top-down `S3, FA*, Contents, S1*, F0` (paint order F0 S1 FA S3, Contents above two).
#[test]
fn several_rows_drop_as_one_block() {
use vectorcraft_doc::FillLayer;
let mut n = Node::group(NodeId(1), vec![]);
n.appearance.items = (0..4).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect();
n.appearance.set_contents_at(2);
let slots = [
Slot::Object { top: true },
Slot::Item { index: 3, sub: false },
Slot::Item { index: 2, sub: false },
Slot::Contents,
Slot::Item { index: 1, sub: false },
Slot::Item { index: 0, sub: false },
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();
let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0);
let set = [1, 2];
let drop = |grabbed, i, copy| block_drop_command(&n, &rows, Dragged::Item(grabbed), &set, at(i), copy);
let mv = |to: usize, contents: usize| Some(("appearance.moveItem", json!({"indices": [1, 2], "to": to, "contents": contents})));
// On S3 (FA grabbed, so dropped upwards: above it): FA, S1, S3, Contents, F0.
assert_eq!(drop(2, 1, false), mv(2, 1));
// On the Contents row: grabbed above it, just below; grabbed below it, just above.
assert_eq!(drop(2, 3, false), mv(1, 3));
assert_eq!(drop(1, 3, false), mv(1, 1));
// On the object row: on top, Contents below; under the stack: at the bottom, Contents above.
assert_eq!(drop(2, 0, false), mv(2, 1));
assert_eq!(drop(2, 6, false), mv(0, 3));
// On F0 (below it, on its side of Contents).
assert_eq!(drop(2, 5, false), mv(0, 3));
// On one of its own rows: nothing.
assert_eq!(drop(2, 2, false), None);
assert_eq!(drop(2, 4, true), None);
// Alt: copies as a block, placed the same way; Contents stays between the same rows.
let cp = |to: usize, contents: usize| Some(("appearance.duplicateItem", json!({"indices": [1, 2], "to": to, "contents": contents})));
assert_eq!(drop(2, 0, true), cp(4, 2));
assert_eq!(drop(2, 5, true), cp(0, 4));
assert_eq!(drop(2, 3, true), cp(2, 4));
// Effects of one list: they land together; on one of their own rows nothing happens.
let fx = |id: &str| Effect { id: id.into(), params: Value::Null, visible: true };
let mut p = Node::path(NodeId(2), vectorcraft_geom::PathData::default(), vectorcraft_doc::Appearance::default_art());
p.appearance.effects.extend([fx("a"), fx("b"), fx("c")]);
let fslots = [
Slot::Object { top: true },
Slot::Item { index: 1, sub: false },
Slot::Item { index: 0, sub: false },
Slot::Effect(None, 0),
Slot::Effect(None, 1),
Slot::Effect(None, 2),
];
let frows: Vec<(Slot, Rect)> =
fslots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect();
let lower = |i: usize| pos2(50.0, i as f32 * ROW + ROW * 0.75);
let fdrop = |i, copy| block_drop_command(&p, &frows, Dragged::Effect(None, 0), &[0, 2], lower(i), copy);
let fmv = |to: usize, to_item: Value, copy: bool| {
Some(("effect.move", json!({"from": [0, 2], "fromItem": null, "to": to, "toItem": to_item, "copy": copy})))
};
assert_eq!(fdrop(4, false), fmv(1, Value::Null, false), "below b: a, c land after it");
assert_eq!(fdrop(1, true), fmv(0, json!(1), true), "into the stroke's list");
assert_eq!(fdrop(3, false), None);
assert_eq!(fdrop(5, false), None);
}
/// One headless frame of the panel: the texts drawn, top to bottom.
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) {
@@ -448,9 +570,9 @@ fn add_new_fill_selects_the_new_fill_alone() {
app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill]
let ctx = egui::Context::default();
let n = app.session.doc().unwrap().doc.node(id).unwrap().clone();
// Rows 0 and 1 (the stroke) Cmd-clicked together, row 0 active.
click_item(&mut app, &ctx, &n, Sel::None, 0, egui::Modifiers::NONE);
click_item(&mut app, &ctx, &n, Sel::Item(0), 1, egui::Modifiers::COMMAND);
// Rows 1 (the stroke) and 0 Cmd-clicked together, row 0 (clicked last) active.
click_row(&mut app, &ctx, &n, Sel::None, Rows::Items, 1, egui::Modifiers::NONE);
click_row(&mut app, &ctx, &n, Sel::Item(1), Rows::Items, 0, egui::Modifiers::COMMAND);
assert_eq!(selected_items(&ctx, Some(&n), Sel::Item(0)), [0, 1]);
app.run("appearance.addFill", json!({})).unwrap();
let n = app.session.doc().unwrap().doc.node(id).unwrap().clone();
@@ -557,6 +679,42 @@ fn inline_stroke_weight_and_effect_fields_are_named() {
let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color");
}
/// A style that gives the next object two fills and two strokes: each row's swatch and weight
/// field has an id of its own (egui flags a clash with a 🔥 warning drawn over the widgets).
#[test]
fn new_art_rows_of_the_same_kind_have_their_own_widget_ids() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
run(&mut app, "appearance.addFill", json!({}));
run(&mut app, "appearance.addStroke", json!({}));
run(&mut app, "graphicStyle.new", json!({"name": "Twice"}));
run(&mut app, "select.none", json!({}));
run(&mut app, "graphicStyle.apply", json!({"name": "Twice"}));
let ctx = egui::Context::default();
ctx.options_mut(|o| o.warn_on_id_clash = true);
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut shown = vec![];
// The second frame: ids from the first are known by then.
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
out.textures_delta.clear();
shown.clear();
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
}
let rows = shown.iter().filter(|t| ["Fill:", "Stroke:"].contains(&t.as_str())).count();
assert_eq!(rows, 4, "two fills and two strokes: {shown:?}");
let clashes: Vec<&String> = shown.iter().filter(|t| t.contains('🔥')).collect();
assert!(clashes.is_empty(), "{clashes:?}");
}
/// #4174: two circles with different fills and the same stroke, effect and opacity list the
/// object row with their kind, the "Mixed Appearances" note and what they share: the stroke, the
/// Feather and the opacity, not the fills. A path with type names them "Mixed Objects".
+10 -6
View File
@@ -178,6 +178,10 @@ struct Out {
toggle: Vec<(NodeId, bool)>,
}
/// The panel state that says the Layers panel's rows have the keyboard ([`super::has_row_keys`]).
pub(crate) const ROW_KEYS: &str = "ly-keys";
/// The Layers panel: the layer tree with its click, drag and rename rules and the bottom buttons; Delete/Backspace after a row click delete the highlighted rows.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
@@ -346,18 +350,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if !released
&& let Some(drag) = egui::DragAndDrop::payload::<LayersDrag>(ui.ctx())
&& let LayersDrag::Rows(ids) = &*drag
&& let Some(src) = ids.first()
&& let Some(&grabbed) = ui.data(|d| d.get_temp::<NodeId>(key("grabbed"))).as_ref().or(ids.first())
&& let Some(pos) = ui.input(|i| i.pointer.latest_pos())
{
drag_ghost(ui, &view, *src, pos);
drag_ghost(ui, &view, widgets::ghost_row(ids, grabbed), pos);
}
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
// clicked last, not the canvas).
// The panel keeps it while it is drawn ([`super::has_row_keys`]); the app's shortcuts leave
// Delete and Backspace to it meanwhile ([`super::rows_take_delete`]).
let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p)));
if pressed_outside {
ui.data_mut(|d| d.insert_temp(key("focus"), false));
}
let focus: bool = ui.data(|d| d.get_temp(key("focus"))).unwrap_or(false);
let focus = super::has_row_keys(ui.ctx(), ROW_KEYS) && !pressed_outside;
super::set_row_keys(ui.ctx(), ROW_KEYS, focus);
if focus
&& !view.rows.is_empty()
&& ui.ctx().memory(|m| m.focused().is_none())
+3 -3
View File
@@ -1,7 +1,7 @@
use super::model::{ObjectKind, RowState, names, parse_names, toggled};
use super::{
Document, HashSet, LayersDrag, Node, NodeId, NodeKind, PanelDrag, Sense, Stroke, StrokeKind, Ui, Value, VectorcraftApp, View, json,
last_selected, see_selection, vec2, widgets,
Document, HashSet, LayersDrag, Node, NodeId, NodeKind, PanelDrag, Sense, Stroke, Ui, Value, VectorcraftApp, View, json, last_selected,
see_selection, vec2, widgets,
};
/// The Filter button, lit while a filter is on, and its popup: Clear All, the object kinds under
@@ -90,7 +90,7 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|| (cmd == "layer.delete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
if over {
child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
widgets::drop_target_outline(&child, button.rect);
}
}
// Create New Sublayer: Alt asks for its options first, and Ctrl/Cmd+Alt puts it under the
+29 -37
View File
@@ -2,8 +2,8 @@ use super::controls::collect_for_export_menu;
use super::model::{Place, Row, opens, painted_name};
use super::thumbs::{node_arc, real_thumb, thumb};
use super::{
Arc, COLUMN, Color32, ColumnDrag, Document, INDENT, LayersDrag, Node, NodeId, NodeKind, Out, PanelDrag, SQUARE_X, Sense, Stroke, StrokeKind,
TARGET_X, Tokens, Ui, Value, View, icons, json, vec2, widgets,
Arc, COLUMN, Color32, ColumnDrag, Document, INDENT, LayersDrag, Node, NodeId, NodeKind, Out, PanelDrag, ROW_KEYS, SQUARE_X, Sense, Stroke,
StrokeKind, TARGET_X, Tokens, Ui, Value, View, icons, json, vec2, widgets,
};
/// Stable id for the layers panel's per-name UI state.
@@ -16,9 +16,6 @@ pub(super) fn rename_id() -> egui::Id {
egui::Id::unique("layers-rename")
}
/// Ghost rows are drawn at this opacity.
const GHOST_OPACITY: f32 = 0.5;
/// The colour of the layer holding `id`.
fn layer_colour(doc: &Document, id: NodeId) -> Color32 {
let [r, g, b] = doc.layer_color(id);
@@ -50,23 +47,18 @@ pub(super) fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
let Some(n) = node_arc(doc, src) else { return };
let n = &n;
let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0));
// A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in.
let r = egui::Rect::from_min_size(pos - grab, vec2(width, h));
let id = key("ghost");
let mut ui = Ui::new(ui.ctx().clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(r));
ui.set_opacity(GHOST_OPACITY);
let p = ui.painter();
p.rect_filled(r, 0.0, t.row_selected);
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(&ui, n, r, x, layer_colour(doc, src));
x += 22.0 + depth as f32 * INDENT;
if view.opts.thumb(n) {
x = row_thumb(&ui, view, n, r, x).right() + 4.0;
}
let label = n.identify_label(view.identify_xml_id);
let name = if view.identify_xml_id { label } else { painted_name(n, &label, crate::i18n::current()) };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
widgets::drag_ghost(ui.ctx(), key("ghost"), r, |ui, r| {
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(ui, n, r, x, layer_colour(doc, src));
x += 22.0 + depth as f32 * INDENT;
if view.opts.thumb(n) {
x = row_thumb(ui, view, n, r, x).right() + 4.0;
}
let label = n.identify_label(view.identify_xml_id);
let name = if view.identify_xml_id { label } else { painted_name(n, &label, crate::i18n::current()) };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
});
}
/// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift
@@ -74,22 +66,19 @@ pub(super) fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
pub(super) fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) {
let Some((id, m)) = out.click.take() else { return };
let anchor: Option<NodeId> = ui.data(|d| d.get_temp(key("anchor")));
ui.data_mut(|d| d.insert_temp(key("focus"), true));
let at = |id: NodeId| list.iter().position(|r| r.node.id == id);
if m.shift
&& let Some(a) = anchor.or(view.rows.last().copied())
&& let (Some(i), Some(j)) = (at(a), at(id))
{
let range: Vec<u64> = list.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.node.id.0).collect();
out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }})));
return;
}
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
if m.command {
out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"})));
} else {
out.actions.push(("layer.setCurrent".into(), json!({"id": id.0})));
crate::panels::set_row_keys(ui.ctx(), ROW_KEYS, true);
let order: Vec<NodeId> = list.iter().map(|r| r.node.id).collect();
let click = widgets::row_click(&order, &view.rows, anchor, id, m);
if click.moves_anchor() {
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
}
let ids = |rows: &[NodeId]| rows.iter().map(|r| r.0).collect::<Vec<_>>();
out.actions.push(match click {
widgets::RowClick::Only(id) => ("layer.setCurrent".into(), json!({"id": id.0})),
widgets::RowClick::Toggle(id) => ("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"})),
widgets::RowClick::Range(rows) => ("layer.highlight".into(), json!({"ids": ids(&rows), "mode": "set"})),
widgets::RowClick::AddRange(rows) => ("layer.highlight".into(), json!({"ids": ids(&rows), "mode": "add"})),
});
}
/// Whether row `n` can take `drag` inside it.
@@ -390,7 +379,10 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids));
let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min);
ui.data_mut(|d| d.insert_temp(key("grab"), (grab, r.width(), depth)));
ui.data_mut(|d| {
d.insert_temp(key("grab"), (grab, r.width(), depth));
d.insert_temp(key("grabbed"), n.id);
});
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
File diff suppressed because it is too large Load Diff
@@ -203,7 +203,8 @@ fn shift_clicking_a_chip_opens_the_mixer_on_that_row() {
// 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"));
// The Color panel opens as the row's popup.
assert_eq!(app.ui.transient_panel.as_deref(), Some("color"));
assert_eq!(app.session.appearance_item(), Some(0));
}
+97 -86
View File
@@ -39,6 +39,7 @@ fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option
v
}
/// The Transparency panel: blend mode and opacity of the selection, or of the Appearance panel's active fill/stroke (whose object-only controls are greyed out), the opacity mask and the knockout options.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// While editing a mask the panel shows the masked object (the selection is its mask art).
@@ -50,6 +51,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// panel then shows the masked object), else of the targets, blank where they differ; with no
// target, the defaults for new art.
let item = editing.is_none().then(|| app.session.appearance_item()).flatten().and_then(|_| current_transparency(app));
// A fill/stroke's transparency is only its opacity and blend: what applies to whole objects
// (the opacity mask, isolation, knockout) is greyed out meanwhile.
let object = editing.is_some() || app.session.appearance_item().is_none();
let (op, blend) = match item {
Some((o, b)) => (Some(o), Some(b)),
None if has => (info.opacity.map(|o| (o / 100.0) as f32), info.blend),
@@ -67,100 +71,104 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");
let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref());
let (new_clip, new_invert) = app.session.new_mask_defaults();
ui.horizontal(|ui| {
if !hide_thumbs {
let (r, oresp) = ui.allocate_exact_size(vec2(60.0, 50.0), Sense::click());
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
// The thumbnail being edited is outlined (object normally, the mask while editing it).
let (obj_w, mask_w) = if editing.is_some() { (0.5, 1.5) } else { (1.5, 1.0) };
ui.painter().rect_stroke(r, 0.0, Stroke::new(obj_w, t.border), StrokeKind::Outside);
if editing.is_some() && oresp.on_hover_text(tl!("Stop editing the opacity mask")).clicked() {
app.run("transparency.stopEditingOpacityMask", json!({})).ok();
}
if let Some(n) = &n {
let mut bare = n.clone();
bare.extras_mut().mask = None;
if !node_thumb(app, ui, "tr-obj", &bare, r.shrink(3.0), None) {
let (f, s) = current_paints(app);
let inner = r.shrink2(vec2(6.0, 10.0));
widgets::paint_chip(ui, inner, &f);
if let Some(c) = s.color()
&& !matches!(s, Paint::None)
{
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, super::c32(&c)), StrokeKind::Middle);
}
ui.add_enabled_ui(object, |ui| {
ui.horizontal(|ui| {
if !hide_thumbs {
let (r, oresp) = ui.allocate_exact_size(vec2(60.0, 50.0), Sense::click());
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
// The thumbnail being edited is outlined (object normally, the mask while editing it).
let (obj_w, mask_w) = if editing.is_some() { (0.5, 1.5) } else { (1.5, 1.0) };
ui.painter().rect_stroke(r, 0.0, Stroke::new(obj_w, t.border), StrokeKind::Outside);
if editing.is_some() && oresp.on_hover_text(tl!("Stop editing the opacity mask")).clicked() {
app.run("transparency.stopEditingOpacityMask", json!({})).ok();
}
}
match (mask, n.as_ref().map(|n| n.id)) {
(Some(m), Some(id)) => {
// Link toggle between the object and its mask.
let (lr, lresp) = ui.allocate_exact_size(vec2(14.0, 50.0), Sense::click());
icons::paint(
ui,
if m.linked { "link" } else { "link-2-off" },
egui::Rect::from_center_size(lr.center(), vec2(12.0, 12.0)),
t.icon,
);
if lresp.on_hover_text(if m.linked { tl!("Unlink the mask") } else { tl!("Link the mask") }).clicked() {
app.run("transparency.setOpacityMask", json!({"linked": !m.linked})).ok();
}
let (mr, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
let mresp = ui.interact(mr, egui::Id::unique(MASK_THUMB), Sense::click());
let bg = if m.clip != m.invert { [0, 0, 0, 255] } else { [255, 255, 255, 255] };
ui.painter().rect_filled(mr, 0.0, egui::Color32::from_rgb(bg[0], bg[1], bg[2]));
node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg));
// Viewing the mask alone (Alt-click) highlights its thumbnail.
let viewing = app.session.active().is_some_and(|d| d.shown_mask().is_some());
let border = match (viewing, editing.is_some()) {
(true, _) => Stroke::new(2.0, t.accent),
(false, true) => Stroke::new(mask_w, t.border),
(false, false) => Stroke::new(mask_w, t.input_border),
};
ui.painter().rect_stroke(mr, 0.0, border, StrokeKind::Inside);
if m.disabled {
let red = Stroke::new(2.0, egui::Color32::from_rgb(220, 40, 40));
ui.painter().line_segment([mr.left_top(), mr.right_bottom()], red);
ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red);
}
let (shift, alt) = ui.input(|i| (i.modifiers.shift, i.modifiers.alt));
let tip = tl!("Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it");
if mresp.on_hover_text(tip).clicked() {
if shift {
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok();
} else if alt {
app.run("transparency.viewOpacityMask", json!({"id": id.0})).ok();
} else if editing.is_none() {
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
if let Some(n) = &n {
let mut bare = n.clone();
bare.extras_mut().mask = None;
if !node_thumb(app, ui, "tr-obj", &bare, r.shrink(3.0), None) {
let (f, s) = current_paints(app);
let inner = r.shrink2(vec2(6.0, 10.0));
widgets::paint_chip(ui, inner, &f);
if let Some(c) = s.color()
&& !matches!(s, Paint::None)
{
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, super::c32(&c)), StrokeKind::Middle);
}
}
}
_ => {
let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled);
match (mask, n.as_ref().map(|n| n.id)) {
(Some(m), Some(id)) => {
// Link toggle between the object and its mask.
let (lr, lresp) = ui.allocate_exact_size(vec2(14.0, 50.0), Sense::click());
icons::paint(
ui,
if m.linked { "link" } else { "link-2-off" },
egui::Rect::from_center_size(lr.center(), vec2(12.0, 12.0)),
t.icon,
);
if lresp.on_hover_text(if m.linked { tl!("Unlink the mask") } else { tl!("Link the mask") }).clicked() {
app.run("transparency.setOpacityMask", json!({"linked": !m.linked})).ok();
}
let (mr, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
let mresp = ui.interact(mr, egui::Id::unique(MASK_THUMB), Sense::click());
let bg = if m.clip != m.invert { [0, 0, 0, 255] } else { [255, 255, 255, 255] };
ui.painter().rect_filled(mr, 0.0, egui::Color32::from_rgb(bg[0], bg[1], bg[2]));
node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg));
// Viewing the mask alone (Alt-click) highlights its thumbnail.
let viewing = app.session.active().is_some_and(|d| d.shown_mask().is_some());
let border = match (viewing, editing.is_some()) {
(true, _) => Stroke::new(2.0, t.accent),
(false, true) => Stroke::new(mask_w, t.border),
(false, false) => Stroke::new(mask_w, t.input_border),
};
ui.painter().rect_stroke(mr, 0.0, border, StrokeKind::Inside);
if m.disabled {
let red = Stroke::new(2.0, egui::Color32::from_rgb(220, 40, 40));
ui.painter().line_segment([mr.left_top(), mr.right_bottom()], red);
ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red);
}
let (shift, alt) = ui.input(|i| (i.modifiers.shift, i.modifiers.alt));
let tip = tl!("Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it");
if mresp.on_hover_text(tip).clicked() {
if shift {
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({}))
.ok();
} else if alt {
app.run("transparency.viewOpacityMask", json!({"id": id.0})).ok();
} else if editing.is_none() {
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
}
}
}
_ => {
let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled);
}
}
}
}
ui.vertical(|ui| {
let label = if mask.is_some() { tl!("Release") } else { tl!("Make Mask") };
// Make Mask is off in isolation mode and pattern editing mode for one object, which it would take into mask editing.
let enabled = mask.is_some() || (has && editing.is_none() && crate::menus::enabled(app, "transparency.makeOpacityMask"));
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
if r.on_disabled_hover_text(tl!("Select the art (and, on top of it, the mask object)")).clicked() {
let id = if mask.is_some() { "transparency.releaseOpacityMask" } else { "transparency.makeOpacityMask" };
app.run(id, json!({})).ok();
}
let (clip, invert) = mask.map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
if widgets::check(ui, tl!("Clip"), clip, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"clip": !clip})).ok();
}
if widgets::check(ui, tl!("Invert Mask"), invert, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"invert": !invert})).ok();
}
});
ui.vertical(|ui| {
let label = if mask.is_some() { tl!("Release") } else { tl!("Make Mask") };
// Make Mask is off in isolation mode and pattern editing mode for one object, which it would take into mask editing.
let enabled = mask.is_some() || (has && editing.is_none() && crate::menus::enabled(app, "transparency.makeOpacityMask"));
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
if r.on_disabled_hover_text(tl!("Select the art (and, on top of it, the mask object)")).clicked() {
let id = if mask.is_some() { "transparency.releaseOpacityMask" } else { "transparency.makeOpacityMask" };
app.run(id, json!({})).ok();
}
let (clip, invert) = mask.map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
if widgets::check(ui, tl!("Clip"), clip, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"clip": !clip})).ok();
}
if widgets::check(ui, tl!("Invert Mask"), invert, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"invert": !invert})).ok();
}
});
})
});
if !pstate::<bool>(ui.ctx(), "tr-hide-options") {
widgets::divider(ui);
let has = has && object;
if widgets::check(ui, tl!("Isolate Blending"), isolate, has) {
app.run("transparency.set", json!({"isolate": !isolate})).ok();
}
@@ -183,6 +191,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The Transparency panel's menu: showing thumbnails and options, and the opacity mask items (whole objects only).
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");
let hide_opts: bool = pstate(ui.ctx(), "tr-hide-options");
@@ -197,9 +206,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some());
let (_, n) = state(app, ui.ctx());
let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref());
// Not for a fill/stroke (the Appearance panel's active row): masks belong to whole objects.
let object = editing || app.session.appearance_item().is_none();
let can_make = n.is_some() && !editing && crate::menus::enabled(app, "transparency.makeOpacityMask");
for (label, id, enabled) in mask_items(mask, can_make) {
if menu_item(ui, tl!(label), enabled, false) {
if menu_item(ui, tl!(label), enabled && object, false) {
app.run(id, json!({})).ok();
}
}
+19 -8
View File
@@ -158,7 +158,9 @@ pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool {
}
/// Process one frame's events against the keymap at most once per context, viewport and frame, so
/// reruns cannot fire a shortcut or tool key twice.
/// reruns cannot fire a shortcut or tool key twice: keymap commands, tools and panels, nudges,
/// Delete/Backspace (left to a Layers or Appearance row that has the keys) and single-key tool
/// shortcuts.
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// One UI pass takes a frame's events. A discarded rerun and a test helper that also calls
// `logic` see the same events again; they must not fire a shortcut or tool key twice. The
@@ -327,11 +329,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
// Command, panel and tool shortcuts. A bound shifted punctuation chord uses its key press;
// other text keys use the typed character below.
// other text keys use the typed character below. A plain Delete or Backspace after a click on
// a Layers or Appearance row is that panel's (it removes the highlighted rows), not
// Edit › Clear's.
let rows_keys = crate::panels::rows_take_delete(ctx);
let row_key = |chord: &craft_keymap::Chord| {
rows_keys && chord.is_plain() && !chord.mods.shift && matches!(chord.key, craft_keymap::Key::Delete | craft_keymap::Key::Backspace)
};
let hits = dispatch(app, ctx, |hit| {
let chord = hit.trigger.chord;
let chord = &hit.trigger.chord;
let shifted_punctuation = chord.mods.shift && chord.key.name().chars().all(|c| !c.is_alphanumeric());
!(chord.is_plain() && types_text(chord.key) && !shifted_punctuation)
!(chord.is_plain() && types_text(chord.key) && !shifted_punctuation || row_key(chord))
});
let ran = !hits.is_empty();
for c in hits {
@@ -343,7 +351,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
if alt_type_arrows(app, ctx) {
return;
}
nudge_and_clear(app, ctx);
nudge_and_clear(app, ctx, rows_keys);
single_key_shortcuts(app, ctx);
}
@@ -483,8 +491,9 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo
settings_on_a_mac(app, ctx, mac(ctx));
}
/// Apply arrow-key movement and clear selected art when no text editor owns input.
fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context) {
/// Apply arrow-key movement and clear selected art when no text editor owns input, unless a Layers
/// or Appearance row has Delete/Backspace (`rows_keys`).
fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context, rows_keys: bool) {
// Arrow nudges (Shift = ×10, Alt = copy).
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (k, dx, dy) in arrows {
@@ -494,7 +503,9 @@ fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
// Delete / Backspace clear the selection.
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides())
if !rows_keys
&& ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace))
&& app.session.active().is_some_and(|d| d.selection.has_objects_or_guides())
{
let _ = app.run("edit.clear", json!({}));
}
+14
View File
@@ -311,6 +311,7 @@ impl Dialog {
_ => d,
}
}
/// Field `k` as text: a string as it is, another value as JSON, empty when missing.
pub fn str(&self, k: &str) -> String {
match self.fields.get(k) {
Some(Value::String(s)) => s.clone(),
@@ -318,6 +319,7 @@ impl Dialog {
None => String::new(),
}
}
/// Field `k` as a bool; `false` when it is missing or not a bool.
pub fn bool(&self, k: &str) -> bool {
self.fields.get(k).and_then(Value::as_bool).unwrap_or(false)
}
@@ -343,6 +345,15 @@ pub struct UiState {
/// Icon panel currently popped out of the collapsed column (also `properties`, `layers` or
/// `libraries` while the dock is collapsed).
pub open_panel: Option<String>,
/// The panel a row's link opened as a popup (the Appearance panel's Stroke:, Character:,
/// Opacity:…): it shows beside the Appearance panel at the clicked row until a press outside
/// it, Escape, or the panel showing elsewhere puts it away
/// ([`crate::panels::appearance::row_popup`]).
#[serde(skip)]
pub transient_panel: Option<String>,
/// That popup's first text field is still to take the keyboard.
#[serde(skip)]
pub transient_focus: bool,
pub control_bar: bool,
pub toolbar: bool,
pub toolbar_double: bool,
@@ -884,6 +895,7 @@ impl UiState {
}
impl Default for UiState {
/// The interface of a first start.
fn default() -> Self {
Self {
legacy_language: None,
@@ -892,6 +904,8 @@ impl Default for UiState {
dock_tab_order: Vec::new(),
dock_collapsed: false,
open_panel: None,
transient_panel: None,
transient_focus: false,
control_bar: false,
toolbar: true,
toolbar_double: false,
+3 -1
View File
@@ -37,7 +37,9 @@ pub use color::{
Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider,
color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, stepped_area,
};
pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source};
pub use drag::{
PanelDrag, RowClick, SwatchRows, apply_row_click, art_drop, doc_preview, drag_ghost, drag_source, drop_target_outline, ghost_row, row_click,
};
#[cfg(test)]
use dropdowns::combo_button_id;
pub(crate) use dropdowns::{combo, combo_menu, combo_named};
+145 -1
View File
@@ -1,4 +1,4 @@
use super::{Paint, Response, Stroke, StrokeKind, Tokens, Ui, Vec2};
use super::{Paint, Rect, Response, Stroke, StrokeKind, Tokens, Ui, Vec2};
/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop
/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer.
@@ -94,6 +94,95 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
}
}
/// What a click on a row of a list whose rows highlight (the Layers and Appearance panels) means.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RowClick<K> {
/// Highlight only this row (a plain click).
Only(K),
/// Highlight this row, or take its highlight off (Ctrl/Cmd).
Toggle(K),
/// Highlight these rows instead, listed top-down (Shift).
Range(Vec<K>),
/// Highlight these rows too, listed top-down (Shift+Ctrl/Cmd).
AddRange(Vec<K>),
}
impl<K> RowClick<K> {
/// Does the clicked row become the anchor Shift ranges start from? Every click but a Shift range.
pub fn moves_anchor(&self) -> bool {
matches!(self, Self::Only(_) | Self::Toggle(_))
}
}
/// What a click on row `clicked` with modifiers `m` means, the rows listed top-down in `order`:
/// Shift ranges from `anchor` (else from the last of the `highlighted` rows) and Shift+Ctrl/Cmd adds
/// the range; Ctrl/Cmd toggles the row; a plain click, or Shift with no start in `order`, highlights
/// only it (Ctrl/Cmd: toggles it).
pub fn row_click<K: Copy + PartialEq>(order: &[K], highlighted: &[K], anchor: Option<K>, clicked: K, m: egui::Modifiers) -> RowClick<K> {
let at = |k: K| order.iter().position(|r| *r == k);
if m.shift
&& let Some(a) = anchor.or(highlighted.last().copied())
&& let (Some(i), Some(j)) = (at(a), at(clicked))
{
let range = order.get(i.min(j)..=i.max(j)).unwrap_or_default().to_vec();
return if m.command { RowClick::AddRange(range) } else { RowClick::Range(range) };
}
if m.command { RowClick::Toggle(clicked) } else { RowClick::Only(clicked) }
}
/// Apply `click` to the `highlighted` rows, kept in highlight order as `layer.highlight` keeps the
/// Layers panel's: a toggled row is appended (or taken off, even the last one), a range replaces the
/// rows or adds those not there yet, top-down.
pub fn apply_row_click<K: Copy + PartialEq>(highlighted: &mut Vec<K>, click: &RowClick<K>) {
let add = |rows: &mut Vec<K>, add: &[K]| {
for k in add {
if !rows.contains(k) {
rows.push(*k);
}
}
};
match click {
RowClick::Only(k) => *highlighted = vec![*k],
RowClick::Toggle(k) if highlighted.contains(k) => highlighted.retain(|r| r != k),
RowClick::Toggle(k) => highlighted.push(*k),
RowClick::Range(rows) => {
highlighted.clear();
add(highlighted, rows);
}
RowClick::AddRange(rows) => add(highlighted, rows),
}
}
/// Dimmed copies of dragged rows ([`drag_ghost`]) are drawn at this opacity.
pub const DRAG_GHOST_OPACITY: f32 = 0.5;
/// Which of the dragged rows the dimmed copy shows: the first of them in highlight order (`dragged`),
/// as the Layers panel has always done, else the grabbed one. The one place to change it (to show the
/// grabbed row, say) for both panels.
pub fn ghost_row<K: Copy>(dragged: &[K], grabbed: K) -> K {
dragged.first().copied().unwrap_or(grabbed)
}
/// A dimmed copy of a dragged row at `rect`, above everything else, with the grabbing cursor (the
/// Layers and Appearance panels): the highlighted row's fill and an accent edge; `contents` draws
/// the row's own contents on it.
pub fn drag_ghost(ctx: &egui::Context, id: egui::Id, rect: Rect, contents: impl FnOnce(&Ui, Rect)) {
let t = Tokens::get(ctx);
// A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in.
let mut ui = Ui::new(ctx.clone(), id, egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, id)).max_rect(rect));
ui.set_opacity(DRAG_GHOST_OPACITY);
ui.painter().rect_filled(rect, 0.0, t.row_selected);
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
contents(&ui, rect);
}
/// The outline a panel button shows while dragged rows hang over it and it would take them (the
/// Layers panel's trash and New Layer, the Appearance panel's Duplicate and Delete).
pub fn drop_target_outline(ui: &Ui, rect: Rect) {
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
}
/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes,
/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when
/// the texture for `key` at this size isn't cached yet.
@@ -123,3 +212,58 @@ pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64)
});
Some(tex)
}
#[cfg(test)]
mod row_click_tests {
use egui::Modifiers;
use super::{RowClick, apply_row_click, row_click};
const ROWS: [u32; 5] = [10, 20, 30, 40, 50];
const SHIFT_CMD: Modifiers = Modifiers { shift: true, command: true, ..Modifiers::NONE };
/// A plain click highlights only the clicked row and Ctrl/Cmd toggles it; both move the anchor.
#[test]
fn a_plain_click_highlights_only_the_row_and_ctrl_toggles_it() {
assert_eq!(row_click(&ROWS, &[20, 40], Some(20), 30, Modifiers::NONE), RowClick::Only(30));
assert_eq!(row_click(&ROWS, &[20, 40], Some(20), 30, Modifiers::COMMAND), RowClick::Toggle(30));
// Neither moves past the anchor rules: both make the clicked row the new anchor.
assert!(RowClick::Only(30).moves_anchor() && RowClick::Toggle(30).moves_anchor());
}
/// Shift highlights the range from the anchor and Shift+Ctrl/Cmd adds it; neither moves the anchor.
#[test]
fn shift_ranges_from_the_anchor_and_shift_ctrl_adds_the_range() {
// Top-down order whichever way the range runs.
assert_eq!(row_click(&ROWS, &[40], Some(40), 20, Modifiers::SHIFT), RowClick::Range(vec![20, 30, 40]));
assert_eq!(row_click(&ROWS, &[20], Some(20), 40, SHIFT_CMD), RowClick::AddRange(vec![20, 30, 40]));
assert!(!RowClick::Range(vec![20]).moves_anchor() && !RowClick::AddRange(vec![20]).moves_anchor());
}
/// Without an anchor, Shift starts at the last highlighted row, or acts as a plain click when none is.
#[test]
fn without_an_anchor_shift_starts_at_the_last_highlighted_row_else_acts_as_a_plain_click() {
assert_eq!(row_click(&ROWS, &[10, 50], None, 30, Modifiers::SHIFT), RowClick::Range(vec![30, 40, 50]));
assert_eq!(row_click(&ROWS, &[], None, 30, Modifiers::SHIFT), RowClick::Only(30));
assert_eq!(row_click(&ROWS, &[], None, 30, SHIFT_CMD), RowClick::Toggle(30));
// An anchor (or last row) that isn't in the list: as without one.
assert_eq!(row_click(&ROWS, &[99], Some(99), 30, Modifiers::SHIFT), RowClick::Only(30));
}
/// Applied clicks keep the rows in highlight order, as `layer.highlight` does.
#[test]
fn applying_a_click_keeps_highlight_order_as_layer_highlight_does() {
let applied = |mut rows: Vec<u32>, c: RowClick<u32>| {
apply_row_click(&mut rows, &c);
rows
};
assert_eq!(applied(vec![40, 20], RowClick::Only(30)), [30]);
// Toggling appends in click order; a highlighted row (even the last) goes off.
assert_eq!(applied(vec![40, 20], RowClick::Toggle(10)), [40, 20, 10]);
assert_eq!(applied(vec![40, 20], RowClick::Toggle(40)), [20]);
assert_eq!(applied(vec![40], RowClick::Toggle(40)), Vec::<u32>::new());
// A range replaces the rows, or adds the ones not there yet, top-down.
assert_eq!(applied(vec![50], RowClick::Range(vec![20, 30])), [20, 30]);
assert_eq!(applied(vec![30, 50], RowClick::AddRange(vec![20, 30, 40])), [30, 50, 20, 40]);
}
}
+5 -5
View File
@@ -266,8 +266,8 @@ fn a_slider_focused_by_a_click_shows_its_outline_only_after_a_key() {
assert!(outlined(&shapes), "an arrow key shows the outline");
}
// Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as
// the menu bar's items do.
/// Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as
/// the menu bar's items do.
#[test]
fn a_panel_menu_row_lights_up_under_the_pointer() {
let ctx = themed(Default::default());
@@ -390,9 +390,9 @@ fn a_combos_keyboard_guard_reads_the_combos_own_button_id() {
assert!(same.get(), "the guard's id is the ComboBox button's id");
}
// Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a
// drag between its proxies. It stays open for a click on its own anchor and while a panel
// drag is on; a click elsewhere still closes it.
/// Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a
/// drag between its proxies. It stays open for a click on its own anchor and while a panel
/// drag is on; a click elsewhere still closes it.
#[test]
fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() {
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };