mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:29:44 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]}));
|
||||
|
||||
@@ -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})),
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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".
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
Reference in New Issue
Block a user