From 9c59a4dcb47c1135b1db3511efae9ac0408823f3 Mon Sep 17 00:00:00 2001 From: FarmerGeorg Date: Sun, 25 Oct 2026 17:46:45 +0100 Subject: [PATCH] Appearance panel: UI parity for row editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/doc/src/appearance.rs | 21 + crates/engine/src/cmd/appearance.rs | 106 +- crates/engine/src/cmd/effectcmd.rs | 120 +- crates/engine/src/cmd/effectcmd/tests.rs | 66 + crates/engine/src/tests/containers.rs | 121 ++ crates/engine/src/tests/effectedit.rs | 7 +- crates/engine/tests/command_sweep.rs | 9 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels.rs | 20 + crates/ui-egui/src/panels/appearance.rs | 833 +++++++++--- crates/ui-egui/src/panels/appearance/tests.rs | 166 ++- crates/ui-egui/src/panels/layers.rs | 16 +- crates/ui-egui/src/panels/layers/controls.rs | 6 +- crates/ui-egui/src/panels/layers/rows.rs | 66 +- crates/ui-egui/src/panels/tests_appearance.rs | 1162 ++++++++++++++++- crates/ui-egui/src/panels/tests_effectedit.rs | 3 +- crates/ui-egui/src/panels/transparency.rs | 183 +-- crates/ui-egui/src/shortcuts.rs | 27 +- crates/ui-egui/src/state.rs | 14 + crates/ui-egui/src/widgets.rs | 4 +- crates/ui-egui/src/widgets/drag.rs | 146 ++- crates/ui-egui/src/widgets/look_tests.rs | 10 +- 45 files changed, 2776 insertions(+), 378 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 92c28067a..81b9aaaf5 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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 { + 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 = 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 diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 33379e752..dc83d24bd 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -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 { vec![ cmd!( @@ -107,7 +108,7 @@ pub fn specs() -> Vec { "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 { "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 { /// The paint-order item indices a command acts on (`indices`, else `index`), ascending and /// without repeats. -fn item_indices(p: &Value, cmd: &str) -> Result> { +pub(super) fn item_indices(p: &Value, cmd: &str) -> Result> { let mut v = match p.get("indices") { Some(a) => a .as_array() @@ -750,6 +751,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result> { 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 { 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 { 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 { 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 { 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 { 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 { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// `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 { 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 { 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 { 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 { + 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 { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 9f1e20121..54d0176a3 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -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 { vec![ cmd!( @@ -42,7 +43,7 @@ pub fn specs() -> Vec { "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 { "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 { "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, &[usize]) -> bool, +) -> Result> { + // Each target's list and the positions in it, in the order the targets first come up. + let mut targets: Vec<(NodeId, Option, Vec)> = 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= 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 { let catalog: Vec = effects::effect_catalog() .into_iter() @@ -266,35 +312,66 @@ fn list(s: &mut Session, p: &Value) -> Result { 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, String)> { + let idx = item_indices(p, cmd)?; + let list = idx.iter().map(usize::to_string).collect::>().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 { 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 { 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 { 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 { 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 = 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::>().join(", "); + return Err(EngineError::Other(format!("{C}: no effect at index {list}"))); } Ok(done) })?; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index d85b3358c..7e44e0772 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -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 { + 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); +} diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs index 1b147ecc1..dd32135c1 100644 --- a/crates/engine/src/tests/containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -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) 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"); +} diff --git a/crates/engine/src/tests/effectedit.rs b/crates/engine/src/tests/effectedit.rs index b64005b6d..f660928ea 100644 --- a/crates/engine/src/tests/effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -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]})); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 41a77a00e..884d9f21b 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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})), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index eafff75ba..180c65dd3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 Двойно щракване за насочване към съдържанието diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index af2ce2934..66db77578 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f152a80b1..e3123798e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7ebd71879..73e449299 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 54d7f09ae..e452c1deb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Διπλό κλικ για επιλογή του περιεχομένου ως στόχου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c9310cdc8..5d5656e06 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 70d9511b3..ccfe1b354 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b68954582..37c75f40c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 81bd63033..639882637 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dd36c0406..033f5fbcc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ff0bfb667..dc71df96a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c6208ecbf..f9332e14e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 ダブルクリックで内容をターゲット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8a4e83f8e..f39f10da3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -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 მონიშნული ელემენტის დუბლირება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 699cf9f4e..00f6807c9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 더블클릭하여 내용 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a5045b2f9..d15de7a87 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e16b37ea7..24d441a9e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3b1d39b51..879d45239 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c11f4b094..01e135647 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Дважды щёлкните, чтобы выбрать содержимое как объект diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1d429be9b..9e1782d29 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bc6b3fe50..ad6ea39b6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c39cd761a..1e9a28590 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Двічі клацніть, щоб вибрати вміст як об’єкт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e044b61d0..d39a33d34 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b7bc2f6aa..dd17b3e73 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 双击以指定内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cbbbe20e9..604ec3091 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 按兩下以指定內容 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 494360e74..b91c563f2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -492,6 +492,26 @@ pub(crate) fn set_pstate(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::>(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) diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 09df1544a..346449c9d 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -4,12 +4,16 @@ //! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar. //! Groups and layers show a Contents row and type a Characters row where their members //! (characters) paint among the object's own fills and strokes: double-clicking Contents targets -//! the members, double-clicking Characters edits the characters' paint. +//! the members, double-clicking Characters edits the characters' paint and its name opens the +//! Character panel. The panels a row's link opens (Color, Stroke, Transparency, Character) are +//! popups: a press outside closes them, and their first text field takes the keyboard. //! //! Rows drag: a fill/stroke row reorders the stack (across the Contents row too), the Contents row //! moves between the fills and strokes, an effect row reorders its list or moves into another item -//! (dropped on its row) or onto the object; Alt copies instead. Cmd-click and Shift-click select -//! several fills/strokes for Duplicate and Delete. +//! (dropped on its row) or onto the object; Alt copies instead. Dropped on Duplicate Selected Item +//! or Delete Selected Item, a fill/stroke row (with the other selected rows, when it is one of +//! them) or an effect row is copied or removed. Cmd-click and Shift-click select several +//! fills/strokes for Duplicate and Delete. use std::sync::OnceLock; @@ -18,10 +22,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId}; -use super::{live_run, pstate, set_pstate}; +use super::{pstate, set_pstate}; use crate::panels::stroke::paint_profile; use crate::theme::Tokens; -use crate::widgets::{self, PanelDrag, TransparencyEdit, menu_item}; +use crate::widgets::{self, PanelDrag, menu_item}; use crate::{VectorcraftApp, icons}; const ROW: f32 = 30.0; @@ -41,27 +45,24 @@ pub enum Sel { ItemEffect(usize, usize), /// The Contents (type: Characters) row: paint edits go to the members (characters). Contents, + /// The Opacity row of fill/stroke item `i` (the Transparency panel edits that item). + ItemOpacity(usize), + /// The object's own Opacity row. + Opacity, } impl Sel { /// The fill/stroke row this selection makes the active item. fn item(self) -> Option { match self { - Sel::Item(i) | Sel::ItemEffect(i, _) => Some(i), - Sel::None | Sel::Effect(_) | Sel::Contents => None, + Sel::Item(i) | Sel::ItemEffect(i, _) | Sel::ItemOpacity(i) => Some(i), + Sel::None | Sel::Effect(_) | Sel::Contents | Sel::Opacity => None, } } - /// `{item, index}` of a selected effect row (`item: null` for the object's own effects). - fn effect(self) -> Option { - match self { - Sel::Effect(k) => Some(json!({"item": null, "index": k})), - Sel::ItemEffect(i, k) => Some(json!({"item": i, "index": k})), - Sel::None | Sel::Item(_) | Sel::Contents => None, - } - } - /// Is a fill/stroke or effect row selected (what Duplicate and Delete act on)? + /// Is a fill/stroke or effect row selected (what Duplicate and Delete act on)? An Opacity row + /// is not an item: it can't be copied or removed. fn editable(self) -> bool { - self.item().is_some() || self.effect().is_some() + matches!(self, Sel::Item(_) | Sel::Effect(_) | Sel::ItemEffect(..)) } } @@ -73,65 +74,139 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) - let exists = |item: Option, k: usize| owner == Some(n.id) && n.appearance.effects_at(item).is_some_and(|fx| k < fx.len()); match (app.session.appearance_item(), sel) { (Some(i), Sel::ItemEffect(j, k)) if i == j && exists(Some(i), k) => sel, + (Some(i), Sel::ItemOpacity(j)) if i == j && owner == Some(n.id) => sel, (Some(i), _) => Sel::Item(i), (None, Sel::Effect(k)) if exists(None, k) => sel, (None, Sel::Contents) if owner == Some(n.id) && n.contents_label().is_some() => sel, + (None, Sel::Opacity) if owner == Some(n.id) => sel, (None, _) => Sel::None, } } -/// Select a row; its fill/stroke becomes the active item that paint and effect edits target. -/// Other selected fills/strokes are deselected. -pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { +/// Which rows a highlight is made of: fills/strokes, or the effects of one list (`None`: the +/// object's own). Rows of different kinds or lists are never highlighted together. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Rows { + #[default] + Items, + Effects(Option), +} + +impl Rows { + /// The row `index` of this kind as a selection. + fn sel(self, index: usize) -> Sel { + match self { + Rows::Items => Sel::Item(index), + Rows::Effects(None) => Sel::Effect(index), + Rows::Effects(Some(i)) => Sel::ItemEffect(i, index), + } + } + /// The rows of this kind on `n`, listed top-down as the panel shows them (fills/strokes from the + /// top of the paint order, effects in list order). + fn order(self, n: &Node) -> Vec { + match self { + Rows::Items => (0..n.appearance.items.len()).rev().collect(), + Rows::Effects(item) => (0..n.appearance.effects_at(item).map_or(0, |fx| fx.len())).collect(), + } + } +} + +impl Sel { + /// The kind of row selected and its index. + fn rows(self) -> Option<(Rows, usize)> { + match self { + Sel::Item(i) => Some((Rows::Items, i)), + Sel::Effect(k) => Some((Rows::Effects(None), k)), + Sel::ItemEffect(i, k) => Some((Rows::Effects(Some(i)), k)), + Sel::None | Sel::Contents | Sel::ItemOpacity(_) | Sel::Opacity => None, + } + } +} + +/// The highlighted rows of an object (panel state `"ap-multi"`): their kind and list, the rows in +/// the order they were highlighted (as `layer.highlight` keeps the Layers panel's), and the anchor +/// Shift ranges start from (the last row clicked without Shift), and the row that was active when +/// they were highlighted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Highlight { + owner: Option, + rows: Rows, + order: Vec, + anchor: Option, + active: Option, +} + +/// Make `sel` the active row; its fill/stroke becomes the active item that paint and effect edits +/// target. The highlighted rows stay as they are. +fn set_active(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { if app.session.appearance_item() != sel.item() { app.run("appearance.setActiveItem", json!({ "index": sel.item() })).ok(); } let owner = app.session.active().and_then(|d| d.selection.subjects().first().copied()); set_pstate(ctx, "ap-sel", (owner, sel)); - set_pstate(ctx, "ap-multi", (owner, sel.item().into_iter().collect::>(), sel.item())); } -/// The selected fill/stroke rows of `n` (paint order, ascending): the active row plus those -/// Cmd/Shift-clicked with it. When the active row has changed since (Add New Fill makes the new -/// fill active), only the active row. -fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec { - let (Some(n), Sel::Item(active)) = (n, sel) else { return vec![] }; - let (owner, mut set, at): (Option, Vec, Option) = pstate(ctx, "ap-multi"); - if owner != Some(n.id) || at != Some(active) || !set.contains(&active) { - return vec![active]; +/// Select a row; its fill/stroke becomes the active item that paint and effect edits target. +/// It is the only highlighted row and the anchor. +pub(crate) fn select_row(app: &mut VectorcraftApp, ctx: &egui::Context, sel: Sel) { + set_active(app, ctx, sel); + let owner = app.session.active().and_then(|d| d.selection.subjects().first().copied()); + let (rows, order) = sel.rows().map_or((Rows::Items, vec![]), |(r, k)| (r, vec![k])); + let anchor = order.first().copied(); + set_pstate(ctx, "ap-multi", Highlight { owner, rows, order, anchor, active: anchor }); +} + +/// The highlighted rows of `n` with `sel` active: the stored ones while they belong to `n`, are of +/// the active row's kind and list and hold it (rows that no longer exist dropped), else just the +/// active row. When the active row has changed outside the panel since (Add New Fill makes the new +/// fill active), also just the active row. With no row active, none (the anchor stays). +fn highlight(ctx: &egui::Context, n: &Node, sel: Sel) -> Highlight { + let mut h: Highlight = pstate(ctx, "ap-multi"); + let ours = h.owner == Some(n.id); + let Some((rows, active)) = sel.rows() else { + return Highlight { owner: Some(n.id), order: vec![], anchor: h.anchor.filter(|_| ours), rows: h.rows, active: None }; + }; + if !ours || h.rows != rows || h.active != Some(active) || !h.order.contains(&active) { + return Highlight { owner: Some(n.id), rows, order: vec![active], anchor: Some(active), active: Some(active) }; } - set.retain(|i| *i < n.appearance.items.len()); + let len = rows.order(n).len(); + h.order.retain(|k| *k < len); + h +} + +/// The highlighted fill/stroke rows of `n` (paint order, ascending): the active row plus those +/// Cmd/Shift-clicked with it. +fn selected_items(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Vec { + let (Some(n), Sel::Item(_)) = (n, sel) else { return vec![] }; + let mut set = highlight(ctx, n, sel).order; set.sort_unstable(); - set.dedup(); set } -/// Click on fill/stroke row `i`: Shift extends the selection from the active row, Cmd toggles -/// the row, a plain click selects only it. -fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, i: usize, m: egui::Modifiers) { - let mut set = selected_items(ctx, Some(n), sel); - let active = match sel { - Sel::Item(a) if m.shift => { - set = (a.min(i)..=a.max(i)).collect(); - a - } - Sel::Item(a) if m.command => { - match set.iter().position(|x| *x == i) { - Some(p) if set.len() > 1 => { - set.remove(p); - } - Some(_) => {} - None => set.push(i), - } - if set.contains(&a) { a } else { set[0] } - } - _ => { - set = vec![i]; - i - } - }; - select_row(app, ctx, Sel::Item(active)); - set_pstate(ctx, "ap-multi", (Some(n.id), set, Some(active))); +/// The highlighted effect rows of `n`: their list (`None`: the object's) and positions, ascending. +fn selected_effects(ctx: &egui::Context, n: Option<&Node>, sel: Sel) -> Option<(Option, Vec)> { + let (n, (Rows::Effects(list), _)) = (n?, sel.rows()?) else { return None }; + let mut set = highlight(ctx, n, sel).order; + set.sort_unstable(); + Some((list, set)) +} + +/// Click on row `k` of kind `rows` with modifiers `m`, by the Layers panel's rules +/// ([`widgets::row_click`]): Shift highlights the range from the anchor, Shift+Ctrl/Cmd adds it, +/// Ctrl/Cmd toggles the row, a plain click highlights only it. A row of another kind or list starts +/// a new highlight. The clicked row becomes active, or the first highlighted one when its +/// highlight went off. +fn click_row(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, rows: Rows, k: usize, m: egui::Modifiers) { + let h = highlight(ctx, n, sel); + let (mut order, anchor) = if h.rows == rows { (h.order, h.anchor) } else { (vec![], None) }; + let click = widgets::row_click(&rows.order(n), &order, anchor, k, m); + widgets::apply_row_click(&mut order, &click); + let anchor = if click.moves_anchor() { Some(k) } else { anchor }; + let active = if order.contains(&k) { Some(k) } else { order.first().copied() }; + set_active(app, ctx, active.map_or(Sel::None, |a| rows.sel(a))); + set_pstate(ctx, "ap-multi", Highlight { owner: Some(n.id), rows, order, anchor, active }); + // The panel has the keyboard now (Delete removes the highlighted rows). + super::set_row_keys(ctx, ROW_KEYS, true); } /// An effect as the fx menu lists it: (id, label without the ellipsis, Effect-menu path). @@ -215,11 +290,14 @@ fn chevron(ui: &mut Ui, r: Rect, id: impl std::hash::Hash + std::fmt::Debug, ope resp.clicked() } +/// Text `s` at `pos` (left, vertically centred), strong or plain. fn text(ui: &Ui, pos: egui::Pos2, s: &str, strong: bool) { let t = Tokens::get(ui.ctx()); ui.painter().text(pos, egui::Align2::LEFT_CENTER, s, egui::FontId::proportional(12.5), if strong { t.text_strong } else { t.text }); } +/// Paint `p` as a swatch in `r`, with a black edge and a white frame; `mixed` (objects whose +/// fills differ) shows "?". fn chip(ui: &Ui, r: Rect, p: &Paint, mixed: bool) { ui.painter().rect_filled(r.expand(1.0), 0.0, egui::Color32::BLACK); widgets::proxy_chip(ui, r, p, mixed, None); @@ -227,7 +305,8 @@ fn chip(ui: &Ui, r: Rect, p: &Paint, mixed: bool) { } /// A dotted-underline link drawn at `pos`; returns clicked. -fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug, s: &str) -> bool { +/// A row's dotted-underlined label at `pos`, clickable whatever modifiers are held. +fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug, s: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(s.to_string(), egui::FontId::proportional(12.5), t.text_strong); let r = Rect::from_min_size(pos2(pos.x, pos.y - galley.size().y / 2.0), galley.size()); @@ -238,9 +317,97 @@ fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug ui.painter().line_segment([pos2(x, r.bottom()), pos2((x + 1.0).min(r.right()), r.bottom())], Stroke::new(1.0, t.text_dim)); x += 2.5; } - resp.clicked() + resp } +/// Where a row's popup opens: the height of the click that opened it (panel state). +const POPUP_AT: &str = "ap-popup-at"; +/// The frame the row's popup was last drawn in (panel state). +const POPUP_FRAME: &str = "ap-popup-frame"; +/// The row's popup's width, a floating panel's. +const POPUP_W: f32 = 260.0; +/// The room a row's popup keeps from the Appearance panel and the window's edges. +const POPUP_GAP: f32 = 6.0; + +/// Open panel `id` as a row's popup ([`row_popup`]) at the height of the click that opened it; its +/// first text field takes the keyboard. A panel showing already (in the dock, a floating window or +/// an icon column's flyout) opens no popup. +pub(crate) fn open_popup(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str) { + if crate::panel_docking::showing(&app.ui, id) { + app.ui.transient_panel = None; + return; + } + app.ui.transient_panel = Some(id.into()); + app.ui.transient_focus = true; + set_pstate(ctx, POPUP_AT, ctx.pointer_interact_pos().map(|p| p.y)); + // Drawn from the next frame on: this one has gone by already for it. + set_pstate(ctx, POPUP_FRAME, Some(ctx.cumulative_frame_nr())); +} + +/// A row's popup ([`open_popup`]): its panel beside the Appearance panel `panel` (left of it, else +/// right of it when there is no room), level with the row that opened it, so it shows which row it +/// belongs to. A press outside it, Escape, the Appearance panel going out of sight, or the panel +/// showing elsewhere puts it away. +pub(crate) fn row_popup(app: &mut VectorcraftApp, ctx: &egui::Context, panel: Rect) { + let Some(id) = app.ui.transient_panel.clone() else { return }; + let close = |app: &mut VectorcraftApp| { + app.ui.transient_panel = None; + app.ui.transient_focus = false; + }; + // Drawn in every frame since it opened: the Appearance panel stayed in sight. + let frame = ctx.cumulative_frame_nr(); + let last: Option = pstate(ctx, POPUP_FRAME); + if last.is_none_or(|f| f + 1 < frame) || crate::panel_docking::showing(&app.ui, &id) { + return close(app); + } + set_pstate(ctx, POPUP_FRAME, Some(frame)); + let area_id = egui::Id::unique("ap-row-popup"); + let shown = ctx.memory(|m| m.area_rect(area_id)); + // A press outside it (not one that closes a dropdown the popup opened) or Escape puts it away. + let pressed_outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| shown.is_some_and(|r| !r.contains(p)))); + if pressed_outside && !egui::Popup::is_any_open(ctx) || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + return close(app); + } + let screen = ctx.content_rect(); + let left = panel.left() - POPUP_GAP - POPUP_W; + let x = if left >= screen.left() + POPUP_GAP { left } else { (panel.right() + POPUP_GAP).min(screen.right() - POPUP_W - POPUP_GAP) }; + let at: Option = pstate(ctx, POPUP_AT); + let y = at.unwrap_or(panel.top()) - 14.0; + let label = crate::state::all_panels().find(|(p, _)| *p == id).map_or(id.as_str(), |(_, l)| l).to_string(); + let tall = (screen.height() * 0.6).max(200.0); + egui::Area::new(area_id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).constrain(true).show(ctx, |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::ZERO).show(ui, |ui| { + ui.set_width(POPUP_W); + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.label(egui::RichText::new(tl!(&label)).strong().color(t.text_strong)); + }); + // Just opened: its first enabled text field takes the keyboard, as a dialog's does. + let focus = app.ui.transient_focus; + if focus { + ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true)); + } + crate::dock::panel_body(app, ui, &id, POPUP_W, tall); + // Taken (the flag is gone): done. Still there (nothing could take it yet): next frame. + if focus && ui.data_mut(|m| m.remove_temp::(widgets::dialog_focus_flag())).is_none() { + app.ui.transient_focus = false; + } + }); + }); +} + +/// A row's name that edits the row when clicked ([`link`]), with the tooltip "Click to edit". +fn name_link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug, s: &str) -> egui::Response { + link(ui, pos, id, s).on_hover_text(tl!("Click to edit")) +} + +/// The tooltip of an effect row's empty space, "Double-click to edit", unless the pointer is on its +/// name (`name`), which has its own. +fn edit_tip(resp: egui::Response, name: &egui::Response) -> egui::Response { + if name.hovered() { resp } else { resp.on_hover_text(tl!("Double-click to edit")) } +} + +/// The Appearance panel: the object row, the stack of the selected object (or of new art with nothing selected) and the bottom bar; Delete/Backspace after a row click remove the highlighted rows. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let node = first_subject(app); @@ -301,6 +468,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.allocate_exact_size(vec2(ui.available_width(), 10.0), Sense::hover()); }); bottom(app, ui, node.as_ref(), sel); + delete_key(app, ui); + // A row's popup, beside the panel (in the same window when the panel floats). + let panel = ui.max_rect(); + row_popup(app, ui.ctx(), panel); +} + +/// The panel state that says the Appearance panel's rows have the keyboard ([`super::has_row_keys`]). +pub(crate) const ROW_KEYS: &str = "ap-keys"; + +/// Delete or Backspace removes the highlighted rows while the panel has the keyboard: a row was +/// clicked last, not somewhere outside the panel, the panel was drawn in every frame since, and no +/// text field has the focus (the Layers panel's rule). The app's shortcuts leave the keys to the +/// panel meanwhile ([`super::rows_take_delete`]); otherwise they delete the selected art. +fn delete_key(app: &mut VectorcraftApp, ui: &mut Ui) { + let panel = ui.min_rect(); + let ctx = ui.ctx().clone(); + let pressed_outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !panel.contains(p))); + let keys = super::has_row_keys(&ctx, ROW_KEYS) && !pressed_outside; + super::set_row_keys(&ctx, ROW_KEYS, keys); + let node = first_subject(app); + let sel = current_sel(app, &ctx, node.as_ref()); + if keys + && sel.editable() + && ctx.memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) + { + delete_selected(app, ui, node.as_ref(), sel); + } } /// The object row's thumbnail drags this object's appearance onto art ([`PanelDrag::Appearance`]; @@ -371,8 +566,9 @@ fn aligned(app: &VectorcraftApp, mut params: Value) -> Value { } /// What the next object drawn gets when nothing is selected (`Session::new_art`): its fills and -/// strokes, effects and transparency as read-only rows; "Stroke:" opens the Stroke panel, which -/// sets up its stroke. +/// strokes, effects and transparency as rows. "Stroke:" opens the Stroke panel, which sets up its +/// stroke, and "Fill:" the Color panel on its fill; the swatches and the weight field work as an +/// object's ([`swatch_button`], [`weight_field`]), on its top fill and stroke. fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let ap = app.session.new_art(); @@ -387,16 +583,19 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { let (r, _) = row(ui, false); eye(ui, r, ("ap-def-eye", i), it.visible(), false); let lx = r.left() + EYE_W + 24.0; - if it.is_fill() { - text(ui, pos2(lx, r.center().y), tl!("Fill:"), false); - } else if link(ui, pos2(lx, r.center().y), ("ap-def-link", i), tl!("Stroke:")) { - app.ui.open_panel = Some("stroke".into()); + let label = if it.is_fill() { tl!("Fill:") } else { tl!("Stroke:") }; + if name_link(ui, pos2(lx, r.center().y), ("ap-def-link", i), label).clicked() { + // The Color panel then edits this one of the next object's paints. + app.run("paint.toggleActive", json!({ "fill": it.is_fill() })).ok(); + open_popup(app, ui.ctx(), if it.is_fill() { "color" } else { "stroke" }); } - chip(ui, Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)), it.paint(), false); + // A graphic style can give the next object two fills or strokes with the same target: a + // child Ui salted by the row keeps their widget ids apart (and, unlike `push_id`, takes no + // layout space). + let mut cells = ui.new_child(egui::UiBuilder::new().id_salt(("ap-def-row", i)).max_rect(r)); + swatch_button(app, &mut cells, r, it.paint(), Painted::NewArt { fill: it.is_fill() }); if let AppearanceItem::Stroke(st) = it { - let w = app.session.stroke_unit().format(st.width); - text(ui, pos2(r.left() + EYE_W + 106.0, r.center().y), &w, false); - stroke_notes(ui, Rect::from_min_max(pos2(r.left() + EYE_W + 166.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); + weight_field(app, &mut cells, r, st, Painted::NewArt { fill: false }); } for (k, e) in it.effects().iter().enumerate() { fx(ui, e, (Some(i), k)); @@ -438,6 +637,7 @@ enum Slot { Contents, } +/// The rows of object `n` (fills/strokes with their Opacity and effect rows, the Contents row, the object's effects and Opacity) with the click rules, row drags and drop markers; `sel` is the active row. /// `only`: a mixed selection (`n` its first object): only the rows its objects share are listed, /// and rows aren't dragged (where one would go in each of the different stacks isn't one place). fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect, only: Option<&Mixed>) { @@ -445,6 +645,8 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: let ctx = ui.ctx().clone(); let dragging: Option = pstate::>(&ctx, "ap-drag").filter(|_| only.is_none()); let items = selected_items(&ctx, Some(n), sel); + let effects = selected_effects(&ctx, Some(n), sel); + let lit = |item: Option, k: usize| effects.as_ref().is_some_and(|(list, ks)| *list == item && ks.contains(&k)); let mut rows = vec![(Slot::Object { top: true }, object_row)]; let mut stopped = false; // Starts or ends a row drag. @@ -455,6 +657,10 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: } if resp.drag_started() { set_pstate(&ctx, "ap-drag", Some(what)); + // Where the row was grabbed, for its dimmed copy under the pointer. + // (From where the button went down: the drag starts once the pointer has moved on.) + let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(EYE_W, ROW / 2.0), |p| p - resp.rect.min); + set_pstate(&ctx, "ap-grab", (grab, resp.rect.width())); } stopped |= resp.drag_stopped(); }; @@ -483,51 +689,33 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: set_pstate(ui.ctx(), &format!("ap-open-{i}"), !open); } let lx = r.left() + EYE_W + 24.0; - if is_stroke { - if link(ui, pos2(lx, r.center().y), ("ap-link", i), tl!("Stroke:")) { - select_row(app, ui.ctx(), Sel::Item(i)); - app.ui.open_panel = Some("stroke".into()); - } - } else { - text(ui, pos2(lx, r.center().y), tl!("Fill:"), false); - } - // Swatch with a swatches popup that sets this item's paint; Shift-click opens the Color - // panel's mixer on this row instead. - let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); - chip(ui, cr, paint, false); - let cresp = ui - .interact(cr.expand(2.0), ui.scope_id().with(("ap-chip", i)), Sense::click()) - .on_hover_text(tl!("Click to choose a swatch, Shift-click to mix a colour")); - let mixer = cresp.clicked() && ui.input(|inp| inp.modifiers.shift); - if mixer { + // The label edits the row (the Stroke or Color panel); the empty space only takes the click + // rules. + let label = if is_stroke { tl!("Stroke:") } else { tl!("Fill:") }; + if name_link(ui, pos2(lx, r.center().y), ("ap-link", i), label).clicked() { select_row(app, ui.ctx(), Sel::Item(i)); - app.ui.open_panel = Some("color".into()); + // The panel has the keyboard (Delete removes this row, not the art). + super::set_row_keys(ui.ctx(), ROW_KEYS, true); + open_popup(app, ui.ctx(), if is_stroke { "stroke" } else { "color" }); } - egui::Popup::menu(&cresp).open_memory((cresp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { - swatch_picker(app, ui, i); - }); + swatch_button(app, ui, r, paint, Painted::Item(i)); if let AppearanceItem::Stroke(st) = it { - let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); - if let Some(w) = widgets::num_field_named(&mut child, ("ap-w", i), Some(tl!("Weight:")), Some(st.width), app.session.stroke_unit(), 56.0) - { - app.run("appearance.setItem", aligned(app, json!({"index": i, "weight": w}))).ok(); - } - stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); + weight_field(app, ui, r, st, Painted::Item(i)); } if resp.clicked() { let m = ui.input(|inp| inp.modifiers); - click_item(app, ui.ctx(), n, sel, i, m); - } - if resp.double_clicked() { - app.ui.open_panel = Some(if is_stroke { "stroke" } else { "color" }.into()); + click_row(app, ui.ctx(), n, sel, Rows::Items, i, m); } track(&resp, Dragged::Item(i)); if open { - let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend(), false); + let r = opacity_row(app, ui, Some(i), it.opacity(), it.blend(), sel, false); rows.push((Slot::Item { index: i, sub: true }, r)); for (k, e) in it.effects().iter().enumerate() { - let (r, resp) = effect_row(app, ui, Some(i), k, e, sel == Sel::ItemEffect(i, k)); + let (r, resp) = effect_row(app, ui, Some(i), k, e, lit(Some(i), k)); + if resp.clicked() { + let m = ui.input(|inp| inp.modifiers); + click_row(app, ui.ctx(), n, sel, Rows::Effects(Some(i)), k, m); + } rows.push((Slot::Effect(Some(i), k), r)); track(&resp, Dragged::Effect(Some(i), k)); } @@ -543,11 +731,15 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: if only.is_some_and(|m| !m.shared.has_effect(k)) { continue; } - let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); + let (r, resp) = effect_row(app, ui, None, k, e, lit(None, k)); + if resp.clicked() { + let m = ui.input(|inp| inp.modifiers); + click_row(app, ui.ctx(), n, sel, Rows::Effects(None), k, m); + } rows.push((Slot::Effect(None, k), r)); track(&resp, Dragged::Effect(None, k)); } - let r = opacity_row(app, ui, None, n.opacity, n.blend, only.is_some_and(|m| m.transparency)); + let r = opacity_row(app, ui, None, n.opacity, n.blend, sel, only.is_some_and(|m| m.transparency)); rows.push((Slot::Object { top: false }, r)); let Some(what) = dragging else { return }; let pointer = ui.ctx().pointer_interact_pos(); @@ -570,8 +762,25 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: (Dragged::Contents, _) => line(if contents_slot(n, slot).is_some_and(|k| k > below) { r.top() } else { r.bottom() }), } } - if !ui.ctx().input(|i| i.pointer.any_down()) { - set_pstate::>(ui.ctx(), "ap-drag", None); + // The drag ends in `bottom`, which takes drops on its Duplicate and Delete buttons. + // Several highlighted rows, one of them grabbed, drop together. + let set = match what { + Dragged::Item(i) if items.contains(&i) => items.clone(), + Dragged::Effect(item, k) => effects.as_ref().filter(|(list, ks)| *list == item && ks.contains(&k)).map_or(vec![], |(_, ks)| ks.clone()), + _ => vec![], + }; + if ui.ctx().input(|i| i.pointer.any_down()) + && let Some(pos) = ui.ctx().input(|i| i.pointer.latest_pos()) + { + let order = if set.len() > 1 { highlight(&ctx, n, sel).order } else { vec![] }; + drag_ghost(app, ui, n, what, &order, pos, object_row.width()); + } + if stopped + && set.len() > 1 + && let Some(p) = pointer + { + block_drop(app, ui.ctx(), n, sel, &rows, what, &set, p); + return; } if stopped && let Some(p) = pointer @@ -585,6 +794,73 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: } } +/// Drop the highlighted rows `set` (ascending; `what` grabbed) at `p`: they move (Alt: are copied) +/// as one block ([`block_drop_command`]) and the moved rows (the copies) stay highlighted, the +/// active row with its row. +#[allow(clippy::too_many_arguments)] +fn block_drop(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, rows: &[(Slot, Rect)], what: Dragged, set: &[usize], p: egui::Pos2) { + let copy = ctx.input(|i| i.modifiers.alt); + let Some((cmd, params)) = block_drop_command(n, rows, what, set, p, copy) else { return }; + let Ok(r) = app.run(cmd, params.clone()) else { return }; + let h = highlight(ctx, n, sel); + // Where the lowest of them landed, and in which kind of rows. + let (landed, kind) = match what { + Dragged::Effect(..) => (r["index"].as_u64().map(|k| k as usize), Rows::Effects(r["item"].as_u64().map(|i| i as usize))), + _ if copy => (params["to"].as_u64().map(|t| (t as usize).min(n.appearance.items.len())), Rows::Items), + _ => (r["index"].as_u64().map(|k| k as usize), Rows::Items), + }; + let Some(landed) = landed else { return }; + let moved = |i: usize| set.iter().position(|s| *s == i).map(|k| landed + k); + let order: Vec = h.order.iter().filter_map(|i| moved(*i)).collect(); + let active = sel.rows().and_then(|(_, a)| moved(a)).or(order.first().copied()); + set_active(app, ctx, active.map_or(Sel::None, |a| kind.sel(a))); + let anchor = h.anchor.and_then(moved).or(active); + set_pstate(ctx, "ap-multi", Highlight { owner: Some(n.id), rows: kind, order, anchor, active }); +} + +/// The dimmed copy of the dragged rows under the pointer at `pos` ([`widgets::drag_ghost`]): the +/// row [`widgets::ghost_row`] picks from the dragged rows `order` (highlight order; empty: only the +/// grabbed one), with its label, colour swatch and stroke weight, or its effect's name. +fn drag_ghost(app: &VectorcraftApp, ui: &Ui, n: &Node, what: Dragged, order: &[usize], pos: egui::Pos2, width: f32) { + let (grab, w): (egui::Vec2, f32) = pstate(ui.ctx(), "ap-grab"); + let r = Rect::from_min_size(pos - grab, vec2(if w > 0.0 { w } else { width }, ROW)); + let shown = match what { + Dragged::Item(i) => Dragged::Item(widgets::ghost_row(order, i)), + Dragged::Effect(item, k) => Dragged::Effect(item, widgets::ghost_row(order, k)), + Dragged::Contents => Dragged::Contents, + }; + let weight = match shown { + Dragged::Item(i) => match n.appearance.items.get(i) { + Some(AppearanceItem::Stroke(st)) => Some(app.session.stroke_unit().format(st.width)), + _ => None, + }, + _ => None, + }; + widgets::drag_ghost(ui.ctx(), ui.scope_id().with("ap-ghost"), r, |ui, r| { + let y = r.center().y; + match shown { + Dragged::Item(i) => { + let Some(it) = n.appearance.items.get(i) else { return }; + text(ui, pos2(r.left() + EYE_W + 24.0, y), if it.is_fill() { tl!("Fill:") } else { tl!("Stroke:") }, false); + chip(ui, Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, y - 9.0), vec2(18.0, 18.0)), it.paint(), false); + if let Some(w) = &weight { + text(ui, pos2(r.left() + EYE_W + 104.0, y), w, false); + } + } + Dragged::Effect(item, k) => { + let Some(e) = n.appearance.effects_at(item).and_then(|fx| fx.get(k)) else { return }; + let indent = if item.is_some() { 12.0 } else { 0.0 }; + text(ui, pos2(r.left() + indent + EYE_W + 24.0, y), &shown_effect_label(&e.id), false); + } + Dragged::Contents => { + if let Some(label) = n.contents_label() { + text(ui, pos2(r.left() + EYE_W + 24.0, y), tl!(label), false); + } + } + } + }); +} + /// The row under `p`. fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> { rows.iter().find(|(_, r)| r.contains(p)).copied() @@ -670,14 +946,86 @@ fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, c } } +/// The command a drop of several highlighted rows `set` (ascending; `what` is the row grabbed, one +/// of them) at `p` runs (`copy`: Alt held), or None on one of their own rows or outside the stack. +/// Fill/strokes land as one block in their order: next to the row dropped on and on its side of the +/// Contents row; on the Contents row on the far side from the grabbed row; on the object row on +/// top, under the stack at the bottom. Effects land together in the list dropped on. +fn block_drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, set: &[usize], p: egui::Pos2, copy: bool) -> Option<(&'static str, Value)> { + let (slot, r) = slot_at(rows, p)?; + let m = set.len(); + match what { + Dragged::Item(grabbed) => { + let len = n.appearance.items.len(); + let k = n.appearance.contents_at(); + let unmoved_below = (0..k).filter(|i| !set.contains(i)).count(); + // (lowest copy's index among all items, items painting below Contents afterwards) + let place = |t: usize, above: bool| { + if copy { + (if above { t + 1 } else { t }, if t < k { k + m } else { k }) + } else { + let rank = (0..t).filter(|i| !set.contains(i)).count(); + (if above { rank + 1 } else { rank }, if t < k { unmoved_below + m } else { unmoved_below }) + } + }; + let (to, contents) = match slot { + Slot::Item { index, .. } | Slot::Effect(Some(index), _) if set.contains(&index) => return None, + Slot::Item { index, .. } | Slot::Effect(Some(index), _) => place(index, index > grabbed), + // From above the Contents row the block lands just below it, from below just above. + Slot::Contents => { + let below = if copy { k } else { unmoved_below }; + (below, if grabbed >= k { below + m } else { below }) + } + Slot::Object { top: true } => (if copy { len } else { len.saturating_sub(m) }, if copy { k } else { unmoved_below }), + Slot::Object { top: false } | Slot::Effect(None, _) => (0, if copy { k + m } else { unmoved_below + m }), + }; + let mut params = json!({"indices": set, "to": to}); + if n.contents_label().is_some() { + params["contents"] = json!(contents); + } + Some((if copy { "appearance.duplicateItem" } else { "appearance.moveItem" }, params)) + } + Dragged::Effect(from_item, _) => { + let (item, pos) = match slot { + Slot::Effect(item, k2) if item == from_item && set.contains(&k2) => return None, + Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)), + Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()), + Slot::Object { .. } | Slot::Contents => (None, n.appearance.effects.len()), + }; + // Within their own list the effects leave their places first. + let same = item == from_item && !copy; + let pos = if same { pos - set.iter().filter(|s| **s < pos).count() } else { pos }; + let contiguous = set.windows(2).all(|w| w.get(1).copied() == w.first().map(|a| a + 1)); + if same && contiguous && set.first() == Some(&pos) { + return None; + } + Some(("effect.move", json!({"from": set, "fromItem": from_item, "to": pos, "toItem": item, "copy": copy}))) + } + Dragged::Contents => None, + } +} + /// The Contents (type: Characters) row of `n`: clicking it sends paint edits to the members /// (characters); double-clicking Contents targets the members, Characters opens the Color panel -/// on the characters' paint (whose chips it shows). Returns its rect and response (for dragging). +/// on the characters' paint (whose chips it shows), and its name the Character panel. Returns its +/// rect and response (for dragging). fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, sel: Sel) -> (Rect, egui::Response) { let (r, resp) = row(ui, sel == Sel::Contents); eye(ui, r, "ap-contents-eye", true, false); - text(ui, pos2(r.left() + EYE_W + 24.0, r.center().y), tl!(label), false); let characters = matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)); + let at = pos2(r.left() + EYE_W + 24.0, r.center().y); + let name = if characters { + let name = name_link(ui, at, "ap-characters-link", tl!(label)); + if name.clicked() { + select_row(app, ui.ctx(), Sel::Contents); + super::set_row_keys(ui.ctx(), ROW_KEYS, true); + open_popup(app, ui.ctx(), "character"); + } + Some(name) + } else { + text(ui, at, tl!(label), false); + None + }; if characters { for (k, stroke) in [false, true].into_iter().enumerate() { let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 100.0 + 24.0 * k as f32, r.center().y - 9.0), vec2(18.0, 18.0)); @@ -686,17 +1034,19 @@ fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, se } // An image's row has no members to target. let image = matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)); - let resp = if image { + let resp = if image || name.is_some_and(|n| n.hovered()) { resp } else { resp.on_hover_text(if characters { tl!("Double-click to edit the characters' paint") } else { tl!("Double-click to target the contents") }) }; if resp.clicked() || resp.double_clicked() { select_row(app, ui.ctx(), Sel::Contents); + // The panel has the keyboard (Delete then removes nothing, rather than the object). + super::set_row_keys(ui.ctx(), ROW_KEYS, true); } if resp.double_clicked() { if characters { - app.ui.open_panel = Some("color".into()); + open_popup(app, ui.ctx(), "color"); } else if !image { app.run("appearance.targetContents", json!({})).ok(); } @@ -724,29 +1074,28 @@ fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) { } } -/// The Opacity row of the object (`item: None`) or, indented, of fill/stroke `item`. Clicking it -/// opens a popup with the shared opacity and blend controls (`transparency.set` on that item). -/// Returns the row's rect. +/// An Opacity row: of the object (`item: None`) or, indented, of fill/stroke `item`. A click +/// highlights the row itself (any modifiers: it is never one of several); only "Opacity:" edits +/// it: it opens the Transparency panel on it (the engine's active item is the row's fill/stroke, +/// whose opacity and blend the panel edits). Returns the row's rect. /// -/// `mixed`: the selected objects' opacity or blend mode differ: it reads "Mixed", and an edit -/// sets them all. -fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode, mixed: bool) -> Rect { - let (r, resp) = row(ui, false); +/// `mixed`: the selected objects' opacity or blend mode differ: it reads "Mixed". +fn opacity_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, opacity: f32, blend: BlendMode, sel: Sel, mixed: bool) -> Rect { + let this = item.map_or(Sel::Opacity, Sel::ItemOpacity); + let (r, resp) = row(ui, sel == this); eye(ui, r, ("ap-op-eye", item), true, false); let lx = r.left() + EYE_W + 24.0 + if item.is_some() { 12.0 } else { 0.0 }; - let clicked = link(ui, pos2(lx, r.center().y), ("ap-op-link", item), tl!("Opacity:")) || resp.clicked(); + let link = name_link(ui, pos2(lx, r.center().y), ("ap-op-link", item), tl!("Opacity:")).clicked(); + if link || resp.clicked() { + select_row(app, ui.ctx(), this); + // The panel has the keyboard (Delete then removes nothing, rather than the object). + super::set_row_keys(ui.ctx(), ROW_KEYS, true); + } + if link { + open_popup(app, ui.ctx(), "transparency"); + } let shown = if mixed { tl!("Mixed").to_string() } else { opacity_text(opacity, blend) }; text(ui, pos2(lx + 56.0, r.center().y), &shown, false); - widgets::popover(&resp, clicked, |ui| match widgets::opacity_blend(ui, ("ap-op", item), Some(opacity), Some(blend), true) { - Some(TransparencyEdit::Blend(b)) => { - app.run("transparency.set", aligned(app, json!({"item": item, "blend": b.label()}))).ok(); - } - Some(TransparencyEdit::Opacity(o, phase)) => { - let params = aligned(app, json!({"item": item, "opacity": o})); - live_run(app, "Opacity", "transparency.set", params, phase) - } - None => {} - }); r } @@ -769,8 +1118,15 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi set_pstate(ui.ctx(), &open_key, !open); } let lx = r.left() + EYE_W + 24.0; - if link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &shown_effect_label(&e.id)) { + // The name edits the effect on a click; the empty space takes the click rules (in `stack`) + // and edits it on a double-click: its settings dialog, or the inline editor of an effect + // without settings. + let name = name_link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &shown_effect_label(&e.id)); + let resp = edit_tip(resp, &name); + let edit = name.clicked() || resp.double_clicked(); + if edit { select_row(app, ui.ctx(), this); + super::set_row_keys(ui.ctx(), ROW_KEYS, true); if has_options(&e.id) { app.run("effect.dialog", aligned(app, json!({"effect": e.id, "index": k, "item": item}))).ok(); } else { @@ -778,12 +1134,6 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usi } } icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon); - if resp.clicked() { - select_row(app, ui.ctx(), this); - } - if resp.double_clicked() { - set_pstate(ui.ctx(), &open_key, !open); - } if open { effect_editor(app, ui, item, k, e); } @@ -936,8 +1286,62 @@ fn param_label(key: &str, built_in: bool) -> String { super::label_or_name(label.trim_end_matches(':'), built_in).to_string() } -/// Small swatch grid inside the appearance chip popup. -fn swatch_picker(app: &mut VectorcraftApp, ui: &mut Ui, index: usize) { +/// The paint a fill/stroke row's swatch sets: of the selection's fill/stroke row `index`, or, with +/// nothing selected, the next object drawn's top fill (`fill`) or stroke. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Painted { + Item(usize), + NewArt { fill: bool }, +} + +/// The swatch of fill/stroke row `r` showing `paint`: a click opens a popup of the document's +/// swatches that sets the paint `target`; Shift-click opens the Color panel on it instead. +fn swatch_button(app: &mut VectorcraftApp, ui: &mut Ui, r: Rect, paint: &Paint, target: Painted) { + let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); + chip(ui, cr, paint, false); + let resp = ui + .interact(cr.expand(2.0), ui.scope_id().with(("ap-chip", target)), Sense::click()) + .on_hover_text(tl!("Click to choose a swatch, Shift-click to mix a colour")); + let mixer = resp.clicked() && ui.input(|inp| inp.modifiers.shift); + if mixer { + match target { + Painted::Item(i) => { + select_row(app, ui.ctx(), Sel::Item(i)); + super::set_row_keys(ui.ctx(), ROW_KEYS, true); + } + Painted::NewArt { fill } => { + app.run("paint.toggleActive", json!({ "fill": fill })).ok(); + } + } + open_popup(app, ui.ctx(), "color"); + } + egui::Popup::menu(&resp).open_memory((resp.clicked() && !mixer).then_some(egui::SetOpenCommand::Toggle)).show(|ui| { + swatch_picker(app, ui, target); + }); +} + +/// The weight of stroke row `r` (stroke `st`) and its notes after it: the Stroke panel's spinner +/// (arrows, Units > Stroke, preset dropdown); a weight typed, stepped or picked goes to the stroke +/// `target`, with nothing selected the next object drawn's (`stroke.set`). +fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer, target: Painted) { + const W: f32 = 100.0; + let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 13.0), vec2(W, 26.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); + let unit = app.session.stroke_unit(); + let presets = super::stroke::weight_presets(unit); + if let Some(w) = widgets::spin_field_stops_named(&mut child, ("ap-w", target), Some(tl!("Weight:")), Some(st.width), unit, W, 1.0, 0.0, &presets) + { + match target { + Painted::Item(index) => app.run("appearance.setItem", aligned(app, json!({"index": index, "weight": w}))), + Painted::NewArt { .. } => app.run("stroke.set", json!({ "weight": w })), + } + .ok(); + } + stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); +} + +/// Small swatch grid inside the appearance chip popup; a pick paints `target`. +fn swatch_picker(app: &mut VectorcraftApp, ui: &mut Ui, target: Painted) { let Some(st) = app.session.active() else { return }; let mut all: Vec<(String, Paint)> = st.doc.swatches.iter().map(|s| (s.name.clone(), s.paint.clone())).collect(); for g in &st.doc.swatch_groups { @@ -950,18 +1354,32 @@ fn swatch_picker(app: &mut VectorcraftApp, ui: &mut Ui, index: usize) { for (name, p) in &all { let (r, resp) = ui.allocate_exact_size(vec2(15.5, 15.5), Sense::click()); widgets::swatch_tile(ui, r, p, false, resp.hovered()); - if resp.on_hover_text(name).clicked() { + let resp = resp.on_hover_text(name); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + if resp.clicked() { chosen = Some((name.clone(), p.is_none())); } } }); if let Some((name, none)) = chosen { - let params = if none { json!({"index": index, "none": true}) } else { json!({"index": index, "swatch": name}) }; - app.run("appearance.setItem", aligned(app, params)).ok(); + let paint = if none { json!({"none": true}) } else { json!({ "swatch": name }) }; + let (cmd, mut params) = match target { + Painted::Item(index) => ("appearance.setItem", json!({ "index": index })), + // With nothing selected these set up the next object drawn. + Painted::NewArt { fill: true } => ("paint.setFill", json!({})), + Painted::NewArt { fill: false } => ("paint.setStroke", json!({})), + }; + if let (Some(p), Some(extra)) = (params.as_object_mut(), paint.as_object()) { + p.extend(extra.clone()); + } + // On a mixed selection, the row is the first object's: each other one paints its equal one. + let params = if matches!(target, Painted::Item(_)) { aligned(app, params) } else { params }; + app.run(cmd, params).ok(); ui.close(); } } +/// The bottom bar: Add New Stroke, Add New Fill, Add New Effect, Clear Appearance, and Duplicate and Delete Selected Item, which also take dropped rows. fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) { let has = node.is_some(); widgets::bottom_bar(ui, |ui| { @@ -980,32 +1398,137 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) if widgets::icon_button_enabled(ui, "dc-clear", tl!("Clear Appearance"), false, has, 24.0).clicked() { app.run("appearance.clear", json!({})).ok(); } - let can = has && sel.editable(); - if widgets::icon_button_enabled(ui, "dc-new-item", tl!("Duplicate Selected Item"), false, can, 24.0).clicked() { + // A dragged fill/stroke or effect row can be dropped on them (not the Contents row), also + // with nothing selected: they light up while it is held. + let dragged = matches!(pstate::>(ui.ctx(), "ap-drag"), Some(Dragged::Item(_) | Dragged::Effect(..))); + let can = has && (sel.editable() || dragged); + let duplicate = widgets::icon_button_enabled(ui, "dc-new-item", tl!("Duplicate Selected Item"), false, can, 24.0); + if duplicate.clicked() { duplicate_selected(app, ui, node, sel); } - if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Selected Item"), false, can, 24.0).clicked() { + let delete = widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Selected Item"), false, can, 24.0); + if delete.clicked() { delete_selected(app, ui, node, sel); } + drop_on_buttons(app, ui, node, sel, &duplicate, &delete); let _ = basic; }); } -/// Duplicate the selected fills/strokes (each copy right above its row) or the selected effect. -fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { - match sel.effect() { - Some(fx) => app.run("effect.duplicate", aligned(app, fx)).ok(), - None if sel.item().is_some() => { - app.run("appearance.duplicateItem", aligned(app, json!({"indices": selected_items(ui.ctx(), node, sel)}))).ok() +/// A stack row dragged onto Duplicate Selected Item or Delete Selected Item: the button is outlined +/// while the row hangs over it, and on release the row is copied or removed, as rows dropped on +/// the Layers panel's buttons are. The buttons take the row even when nothing is selected (the row +/// names itself). Ends the row drag once the pointer is up. +fn drop_on_buttons(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel, duplicate: &egui::Response, delete: &egui::Response) { + let ctx = ui.ctx(); + let dragging: Option = pstate(ctx, "ap-drag"); + if !ctx.input(|i| i.pointer.any_down()) { + set_pstate::>(ctx, "ap-drag", None); + } + let (Some(what), Some(n), Some(p)) = (dragging, node, ctx.pointer_interact_pos()) else { return }; + let items = selected_items(ctx, Some(n), sel); + let effects = selected_effects(ctx, Some(n), sel); + let released = ctx.input(|i| i.pointer.any_released()); + for (button, copy) in [(duplicate, true), (delete, false)] { + let Some((cmd, params)) = button_drop_command(&items, effects.as_ref(), what, copy).filter(|_| button.rect.contains(p)) else { continue }; + if !released { + widgets::drop_target_outline(ui, button.rect); + continue; } - None => None, - }; + // The rows copied or removed with the dropped one (its highlighted list, when it is in it). + let gone: Vec = params["indices"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + let ran = app.run(cmd, aligned(app, params)).is_ok(); + let removed = ran && !copy; + if ran && copy { + let rows = if let Dragged::Effect(item, _) = what { Rows::Effects(item) } else { Rows::Items }; + let copied: Vec = gone.iter().map(|k| *k as usize).collect(); + highlight_copies(app, ctx, n, sel, rows, &copied); + } + // When the active effect was among them, its fill/stroke (or nothing) is selected; the + // engine clears or renumbers the active fill/stroke itself. + let active_gone = match (what, sel) { + (Dragged::Effect(None, _), Sel::Effect(a)) => gone.contains(&(a as u64)), + (Dragged::Effect(Some(i), _), Sel::ItemEffect(j, a)) => i == j && gone.contains(&(a as u64)), + _ => false, + }; + if removed + && active_gone + && let Dragged::Effect(item, _) = what + { + select_row(app, ctx, item.map_or(Sel::None, Sel::Item)); + } + } } +/// The command a stack row `what` dropped on Duplicate Selected Item (`copy`) or Delete Selected +/// Item runs: a fill/stroke row acts on the highlighted rows `items` when it is one of them, an +/// effect row on the highlighted `effects` (list, positions) when it is one of them, else either on +/// itself alone. The Contents row is neither copied nor removed (None). +fn button_drop_command(items: &[usize], effects: Option<&(Option, Vec)>, what: Dragged, copy: bool) -> Option<(&'static str, Value)> { + match what { + Dragged::Item(i) => { + let indices = if items.contains(&i) { items.to_vec() } else { vec![i] }; + Some((if copy { "appearance.duplicateItem" } else { "appearance.removeItem" }, json!({ "indices": indices }))) + } + Dragged::Effect(item, k) => { + let indices = match effects { + Some((list, ks)) if *list == item && ks.contains(&k) => ks.clone(), + _ => vec![k], + }; + Some((if copy { "effect.duplicate" } else { "effect.remove" }, json!({"item": item, "indices": indices}))) + } + Dragged::Contents => None, + } +} + +/// Duplicate the highlighted fills/strokes or effects: each copy shows right above its row and the +/// copies are highlighted ([`highlight_copies`]). +fn duplicate_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { + let (rows, indices) = match selected_effects(ui.ctx(), node, sel) { + Some((item, ks)) => (Rows::Effects(item), ks), + None if matches!(sel, Sel::Item(_)) => (Rows::Items, selected_items(ui.ctx(), node, sel)), + None => return, + }; + let (cmd, params) = match rows { + Rows::Effects(item) => ("effect.duplicate", json!({"item": item, "indices": indices})), + Rows::Items => ("appearance.duplicateItem", json!({ "indices": indices })), + }; + if let (Ok(_), Some(n)) = (app.run(cmd, aligned(app, params)), node) { + highlight_copies(app, ui.ctx(), n, sel, rows, &indices); + } +} + +/// After the rows `dup` of kind `rows` were copied in place, the copies are highlighted, as the +/// Layers panel highlights its duplicates: each shows right above its original, a fill/stroke's at +/// the next paint index, an effect's where the original was (the original one below it). The copy of +/// the active row becomes active (else the first copy), the copy of the anchor the anchor. `n` is +/// the object as it was before the copy. +fn highlight_copies(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, rows: Rows, dup: &[usize]) { + let below = |k: usize| dup.iter().filter(|d| **d < k).count(); + let copy = |k: usize| match rows { + Rows::Items => k + below(k) + 1, + Rows::Effects(_) => k + below(k), + }; + let h = highlight(ctx, n, sel); + let ours = |k: &usize| h.rows == rows && dup.contains(k); + // The copies of the highlighted rows in their highlight order, then any other copied row. + let mut order: Vec = h.order.iter().filter(|k| ours(k)).map(|k| copy(*k)).collect(); + for c in dup.iter().map(|k| copy(*k)) { + if !order.contains(&c) { + order.push(c); + } + } + let active = sel.rows().filter(|(r, a)| *r == rows && dup.contains(a)).map(|(_, a)| copy(a)).or(order.first().copied()); + let anchor = h.anchor.filter(|a| ours(a)).map(copy).or(active); + set_active(app, ctx, active.map_or(Sel::None, |a| rows.sel(a))); + set_pstate(ctx, "ap-multi", Highlight { owner: Some(n.id), rows, order, anchor, active }); +} + +/// Remove the highlighted fills/strokes or effects; a removed item effect leaves its fill/stroke selected. fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: Sel) { - let ok = match sel.effect() { - Some(fx) => app.run("effect.remove", aligned(app, fx)).is_ok(), - None if sel.item().is_some() => { + let ok = match selected_effects(ui.ctx(), node, sel) { + Some((item, ks)) => app.run("effect.remove", aligned(app, json!({"item": item, "indices": ks}))).is_ok(), + None if matches!(sel, Sel::Item(_)) => { app.run("appearance.removeItem", aligned(app, json!({"indices": selected_items(ui.ctx(), node, sel)}))).is_ok() } None => false, diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs index dfd7ccb63..9454b4893 100644 --- a/crates/ui-egui/src/panels/appearance/tests.rs +++ b/crates/ui-egui/src/panels/appearance/tests.rs @@ -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, 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, 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 { 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) { + 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". diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index f4282ea65..8f8778689 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -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::(ui.ctx()) && let LayersDrag::Rows(ids) = &*drag - && let Some(src) = ids.first() + && let Some(&grabbed) = ui.data(|d| d.get_temp::(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()) diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index b6b43c3a0..ab1ca44a2 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -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::().is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) || (cmd == "layer.delete" && button.dnd_hover_payload::().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 diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 2c43a363a..f7abe7aa3 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -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 = 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 = 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 = 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::>(); + 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); diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index 82c2b9f20..a9415a591 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -261,10 +261,12 @@ fn item_effect_rows_toggle_their_eye() { assert_eq!(app.session.appearance_item(), Some(1)); } +/// The effects of the selected object's stroke (item 1). fn current_stroke_effects(app: &VectorcraftApp) -> Vec { first_selected(app).unwrap().appearance.items[1].effects().clone() } +/// The blend mode list's separators come before the first mode of each group. #[test] fn blend_separators_follow_the_groups() { use vectorcraft_color::BlendMode; @@ -274,51 +276,24 @@ fn blend_separators_follow_the_groups() { assert!(starts.iter().all(|m| m.group() > 0) && !crate::widgets::blend_separator_before(BlendMode::ALL.len())); } +/// An Opacity row's empty space (its value, "Default") opens nothing: neither a popup nor a panel. +/// Only its "Opacity:" link edits it (the Transparency panel). #[test] -fn item_opacity_popup_sets_the_items_opacity() { +fn an_opacity_rows_empty_space_opens_nothing() { let mut app = app_with_rect(); let ctx = egui::Context::default(); super::set_pstate(&ctx, "ap-open-1", true); let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - // The stroke's Opacity sub-row (listed first; the object's own Opacity row is last). - let link = texts.iter().find(|(t, _)| t == "Opacity:").expect("item opacity row").1; - click(&ctx, &mut app, link.center(), appearance::show); - let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - assert!(texts.iter().any(|(t, _)| t == "Normal"), "the popup shows the blend mode: {texts:?}"); - // Type 50 into the popup's opacity field. - let field = text_rect(&texts, "100%"); - click(&ctx, &mut app, field.center(), appearance::show); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("50".into())], appearance::show); - frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, Default::default())], appearance::show); - let n = first_selected(&app).unwrap(); - assert_eq!(n.appearance.items[1].opacity(), 0.5); - assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0)); -} - -#[test] -fn opacity_popup_blend_dropdown_sets_the_blend_mode() { - let mut app = app_with_rect(); - let ctx = egui::Context::default(); - let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - // The object's own Opacity row (the last one). - let link = texts.iter().rev().find(|(t, _)| t == "Opacity:").expect("object opacity row").1; - click(&ctx, &mut app, link.center(), appearance::show); - let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - // Opening the dropdown inside the popup keeps the popup open: the list shows. - click(&ctx, &mut app, text_rect(&texts, "Normal").center(), appearance::show); - let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - click(&ctx, &mut app, text_rect(&texts, "Multiply").center(), appearance::show); - assert_eq!(first_selected(&app).unwrap().blend, vectorcraft_color::BlendMode::Multiply); - // The popup is still open after choosing. - let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - assert!(texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}"); - // A frame without the panel closes it, as egui closes the popups it remembers. - frame_events(&ctx, &mut app, vec![], |_, _| {}); - let texts = frame_events(&ctx, &mut app, vec![], appearance::show); - assert!(!texts.iter().any(|(t, _)| t == "Multiply"), "{texts:?}"); + for value in [texts.iter().find(|(t, _)| t == "Default"), texts.iter().rev().find(|(t, _)| t == "Default")] { + let value = value.expect("an Opacity row").1; + click(&ctx, &mut app, value.center(), appearance::show); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + assert!(!texts.iter().any(|(t, _)| t == "Normal"), "no popup with the blend mode: {texts:?}"); + assert_eq!(app.ui.transient_panel, None); + } } +/// The object row names the selection as the Control bar does. #[test] fn object_row_names_the_selection_like_the_control_bar() { let mut app = app_with_rect(); @@ -347,3 +322,1114 @@ fn object_row_names_the_selection_like_the_control_bar() { crate::theme::install_fonts(&fonts); assert!(frame_in(&fonts, &mut app, crate::chrome::control_bar).contains(&"Type".to_string())); } + +/// The Appearance panel's button named `label`: the center of its rect (`ctx` needs accesskit on). +fn panel_button(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 { + panel_button_in(ctx, app, label, appearance::show) +} + +/// [`panel_button`] in a frame of `f`. +fn panel_button_in(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> egui::Pos2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (_, n) = nodes + .iter() + .find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button) + .unwrap_or_else(|| panic!("no button named {label:?}")); + let b = n.bounds().expect("bounds"); + egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5) +} + +/// Drag from `a` to `b` in the Appearance panel: press, move past the drag threshold, move, release. +fn drag_in_panel(ctx: &egui::Context, app: &mut VectorcraftApp, a: egui::Pos2, b: egui::Pos2) { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [ + vec![egui::Event::PointerMoved(a)], + vec![button(a, true)], + vec![egui::Event::PointerMoved(a + egui::vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(b)], + vec![button(b, false)], + vec![], + ] { + frame_events(ctx, app, e, appearance::show); + } +} + +/// A point on the row whose label is the `nth` drawn `label`, clear of its eye, swatch and fields. +fn row_point(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, nth: usize) -> egui::Pos2 { + let texts = frame_events(ctx, app, vec![], appearance::show); + let r = texts.iter().filter(|(t, _)| t == label).nth(nth).unwrap_or_else(|| panic!("no {label:?} #{nth}: {texts:?}")).1; + egui::pos2(r.left() + 400.0, r.center().y) +} + +/// The selected object's fills/strokes as (is fill, stroke weight). +fn stack_items(app: &VectorcraftApp) -> Vec<(bool, Option)> { + first_selected(app) + .unwrap() + .appearance + .items + .iter() + .map(|it| match it { + vectorcraft_doc::AppearanceItem::Stroke(s) => (false, Some(s.width as f32)), + _ => (true, None), + }) + .collect() +} + +/// A context with accessibility on, so tests find buttons by their labels. +fn drop_ctx() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + ctx +} + +/// Fill and stroke rows dropped on Delete Selected Item are removed, on Duplicate Selected Item +/// copied, as rows dropped on the Layers panel's buttons are. +#[test] +fn rows_dropped_on_delete_are_removed_and_on_duplicate_copied() { + // [Fill, Stroke]: dropping the stroke on Delete leaves the fill. + let mut app = app_with_rect(); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let stroke = row_point(&ctx, &mut app, "Stroke:", 0); + drag_in_panel(&ctx, &mut app, stroke, delete); + assert_eq!(stack_items(&app), [(true, None)]); + // Dropping the fill on Duplicate adds one copy of it. + let mut app = app_with_rect(); + let ctx = drop_ctx(); + let duplicate = panel_button(&ctx, &mut app, "Duplicate Selected Item"); + let fill = row_point(&ctx, &mut app, "Fill:", 0); + drag_in_panel(&ctx, &mut app, fill, duplicate); + let items = stack_items(&app); + assert_eq!(items.iter().filter(|(f, _)| *f).count(), 2, "{items:?}"); + assert_eq!(items.len(), 3, "{items:?}"); +} + +/// Dragging one of the selected rows onto Delete removes every selected row; dragging a row outside +/// the selection removes only that row. +#[test] +fn dropping_a_selected_row_acts_on_the_selection() { + let setup = || { + // [Fill, Stroke 1 pt, Stroke 6 pt] + let mut app = app_with_rect(); + run(&mut app, "appearance.addStroke", json!({})); + run(&mut app, "stroke.set", json!({"item": 2, "weight": 6})); + let ctx = drop_ctx(); + frame_events(&ctx, &mut app, vec![], appearance::show); + // Both strokes highlighted (the top one, then Ctrl-click the lower one). + let (top, lower) = (row_point(&ctx, &mut app, "Stroke:", 0), row_point(&ctx, &mut app, "Stroke:", 1)); + click_mod(&ctx, &mut app, top, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, lower, egui::Modifiers::COMMAND); + (app, ctx) + }; + // The top stroke row (drawn first) is in the selection: both strokes go. + let (mut app, ctx) = setup(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let top = row_point(&ctx, &mut app, "Stroke:", 0); + drag_in_panel(&ctx, &mut app, top, delete); + assert_eq!(stack_items(&app), [(true, None)]); + // The fill is not: only it goes. + let (mut app, ctx) = setup(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let fill = row_point(&ctx, &mut app, "Fill:", 0); + drag_in_panel(&ctx, &mut app, fill, delete); + assert_eq!(stack_items(&app), [(false, Some(1.0)), (false, Some(6.0))]); + // Onto Duplicate, the selected strokes are both copied. + let (mut app, ctx) = setup(); + let duplicate = panel_button(&ctx, &mut app, "Duplicate Selected Item"); + let top = row_point(&ctx, &mut app, "Stroke:", 0); + drag_in_panel(&ctx, &mut app, top, duplicate); + let items = stack_items(&app); + assert_eq!(items.len(), 5, "{items:?}"); + assert_eq!(items.iter().filter(|(_, w)| *w == Some(6.0)).count(), 2, "{items:?}"); +} + +/// An effect row dropped on Delete removes that effect, on Duplicate copies it; the stroke it +/// belongs to stays. +#[test] +fn effect_rows_dropped_on_delete_or_duplicate() { + for (button, left) in [("Delete Selected Item", 0), ("Duplicate Selected Item", 2)] { + let mut app = app_with_rect(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + let ctx = drop_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let at = panel_button(&ctx, &mut app, button); + let fx = row_point(&ctx, &mut app, "Roughen", 0); + drag_in_panel(&ctx, &mut app, fx, at); + assert_eq!(current_stroke_effects(&app).len(), left, "{button}"); + assert_eq!(stack_items(&app).len(), 2, "{button}: the fill and stroke stay"); + } +} + +/// The Contents row can be neither copied nor removed: dropped on either button it changes nothing. +/// A row released outside the panel changes nothing either, and the next drop still works. +#[test] +fn contents_rows_and_drops_elsewhere_change_nothing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "object.group", json!({})); + run(&mut app, "appearance.addFill", json!({})); + let ctx = drop_ctx(); + let before = first_selected(&app).unwrap().appearance.clone(); + for button in ["Delete Selected Item", "Duplicate Selected Item"] { + let at = panel_button_in(&ctx, &mut app, button, boxed_panel); + let contents = row_point(&ctx, &mut app, "Contents", 0); + drag_in_panel(&ctx, &mut app, contents, at); + assert_eq!(first_selected(&app).unwrap().appearance, before, "{button}"); + } + // Released far below the panel: nothing happens. + let fill = row_point(&ctx, &mut app, "Fill:", 0); + drag_in_panel(&ctx, &mut app, fill, egui::pos2(fill.x, 9000.0)); + assert_eq!(first_selected(&app).unwrap().appearance, before); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let fill = row_point(&ctx, &mut app, "Fill:", 0); + drag_in_panel(&ctx, &mut app, fill, delete); + assert!(first_selected(&app).unwrap().appearance.items.is_empty()); +} + +/// A row held over Delete Selected Item outlines the button as the Layers panel outlines its trash +/// (1.5 px accent, corner radius 3). +#[test] +fn a_row_held_over_delete_outlines_it_as_layers_does() { + let mut app = app_with_rect(); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let stroke = row_point(&ctx, &mut app, "Stroke:", 0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [vec![egui::Event::PointerMoved(stroke)], vec![button(stroke, true)], vec![egui::Event::PointerMoved(stroke + egui::vec2(0.0, 6.0))]] { + frame_events(&ctx, &mut app, e, appearance::show); + } + let mut out = + ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(delete)], ..Default::default() }, |ui| appearance::show(&mut app, ui)); + out.textures_delta.clear(); + let accent = crate::theme::Tokens::get(&ctx).accent; + let outlines: Vec<_> = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Rect(r) if r.stroke.color == accent && r.rect.contains(delete) => Some((r.stroke.width, r.corner_radius)), + _ => None, + }) + .collect(); + assert_eq!(outlines, [(1.5, egui::CornerRadius::same(3))]); +} + +/// Click at `pos` with modifiers `m` held (hover, press, release). +fn click_mod(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, m: egui::Modifiers) { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: m }; + for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_events(ctx, app, vec![egui::Event::ModifiersChanged(m), e], appearance::show); + } + frame_events(ctx, app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)], appearance::show); +} + +/// The selected object's own effect ids, in order. +fn object_effects(app: &VectorcraftApp) -> Vec { + first_selected(app).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect() +} + +/// A rectangle with Outer Glow, Drop Shadow and Twist on the object. +fn rect_with_three_effects() -> VectorcraftApp { + let mut app = app_with_rect(); + for e in ["stylize.outerGlow", "stylize.dropShadow", "distort.twist"] { + run(&mut app, "effect.apply", json!({"effect": e, "item": null})); + } + app +} + +/// The owner's case: the stack Stroke, Fill, Outer Glow, Gaussian Blur-like effects; Ctrl-clicking +/// two of the object's effects highlights both, and Delete Selected Item removes both in one undo +/// step, leaving the fill and stroke. +#[test] +fn ctrl_clicked_effects_are_deleted_together() { + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let glow = row_point(&ctx, &mut app, "Outer Glow", 0); + let shadow = row_point(&ctx, &mut app, "Drop Shadow", 0); + click_mod(&ctx, &mut app, glow, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, shadow, egui::Modifiers::COMMAND); + click(&ctx, &mut app, delete, appearance::show); + assert_eq!(object_effects(&app), ["distort.twist"]); + assert_eq!(stack_items(&app).len(), 2, "the fill and stroke stay"); + run(&mut app, "edit.undo", json!({})); + assert_eq!(object_effects(&app).len(), 3, "one undo step"); +} + +/// Shift ranges from the anchor (the last row clicked without Shift), Shift+Ctrl adds a range, and +/// Ctrl can take the highlight off the last row, as in the Layers panel. +#[test] +fn effect_rows_follow_the_layers_click_rules() { + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let row = |ctx: &egui::Context, app: &mut VectorcraftApp, name: &str| row_point(ctx, app, name, 0); + // Shift from Outer Glow to Twist: all three. + let (glow, twist) = (row(&ctx, &mut app, "Outer Glow"), row(&ctx, &mut app, "Twist")); + click_mod(&ctx, &mut app, glow, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, twist, egui::Modifiers::SHIFT); + click(&ctx, &mut app, delete, appearance::show); + assert!(object_effects(&app).is_empty()); + run(&mut app, "edit.undo", json!({})); + // Twist, then Shift+Ctrl from it to Drop Shadow adds that range: Shadow and Twist; Glow stays. + let (shadow, twist) = (row(&ctx, &mut app, "Drop Shadow"), row(&ctx, &mut app, "Twist")); + click_mod(&ctx, &mut app, twist, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, shadow, egui::Modifiers { shift: true, command: true, ..Default::default() }); + click(&ctx, &mut app, delete, appearance::show); + assert_eq!(object_effects(&app), ["stylize.outerGlow"]); + run(&mut app, "edit.undo", json!({})); + // Ctrl-clicking the only highlighted effect takes its highlight off: Delete removes nothing. + let glow = row(&ctx, &mut app, "Outer Glow"); + click_mod(&ctx, &mut app, glow, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, glow, egui::Modifiers::COMMAND); + click(&ctx, &mut app, delete, appearance::show); + assert_eq!(object_effects(&app).len(), 3); +} + +/// Ctrl-clicking an effect of another list (the stroke's) starts a new selection there: Delete then +/// removes only that effect. +#[test] +fn ctrl_clicking_an_effect_of_another_list_starts_a_new_selection() { + let mut app = rect_with_three_effects(); + run(&mut app, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + let ctx = drop_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let glow = row_point(&ctx, &mut app, "Outer Glow", 0); + click_mod(&ctx, &mut app, glow, egui::Modifiers::NONE); + let roughen = row_point(&ctx, &mut app, "Roughen", 0); + click_mod(&ctx, &mut app, roughen, egui::Modifiers::COMMAND); + click(&ctx, &mut app, delete, appearance::show); + assert!(current_stroke_effects(&app).is_empty()); + assert_eq!(object_effects(&app).len(), 3); +} + +/// Fill/stroke rows use the same rules: Shift ranges from the anchor, not from the active row. +#[test] +fn fill_and_stroke_rows_range_from_the_anchor() { + // [Fill, Stroke 1 pt, Stroke 6 pt, Stroke 3 pt], listed top-down 3, 6, 1, Fill. + let mut app = app_with_rect(); + run(&mut app, "appearance.addStroke", json!({})); + run(&mut app, "stroke.set", json!({"item": 2, "weight": 6})); + run(&mut app, "appearance.addStroke", json!({})); + run(&mut app, "stroke.set", json!({"item": 3, "weight": 3})); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let stroke = |ctx: &egui::Context, app: &mut VectorcraftApp, nth| row_point(ctx, app, "Stroke:", nth); + let (s1, s3) = (stroke(&ctx, &mut app, 2), stroke(&ctx, &mut app, 0)); + click_mod(&ctx, &mut app, s1, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, s3, egui::Modifiers::COMMAND); + // Ctrl-click S3 again: off, but it stays the anchor; S1 is the active row. + click_mod(&ctx, &mut app, s3, egui::Modifiers::COMMAND); + let fill = row_point(&ctx, &mut app, "Fill:", 0); + click_mod(&ctx, &mut app, fill, egui::Modifiers::SHIFT); + click(&ctx, &mut app, delete, appearance::show); + assert!(stack_items(&app).is_empty(), "the range from S3 down to the fill: all four"); +} + +/// The panel in a 300×500 pt area at the top left, so clicks can land outside it. +fn boxed_panel(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(300.0, 500.0), |ui| appearance::show(app, ui)); +} + +/// One frame of the boxed panel with `key` pressed. +fn key_frame(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key) { + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(ctx, app, vec![ev], boxed_panel); +} + +/// After a click on a row, Delete or Backspace removes the highlighted rows, as in the Layers panel. +/// A click outside the panel, or a focused text field, takes the key away. +#[test] +fn delete_and_backspace_remove_the_highlighted_rows_after_a_click_in_the_panel() { + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + let at = |name: &str| text_rect(&texts, name).right_center() + egui::vec2(40.0, 0.0); + let (glow, stroke) = (at("Outer Glow"), at("Stroke:")); + click(&ctx, &mut app, glow, boxed_panel); + key_frame(&ctx, &mut app, egui::Key::Delete); + assert_eq!(object_effects(&app), ["stylize.dropShadow", "distort.twist"]); + // Backspace too (rows moved up: Drop Shadow is where Outer Glow was). + click(&ctx, &mut app, glow, boxed_panel); + key_frame(&ctx, &mut app, egui::Key::Backspace); + assert_eq!(object_effects(&app), ["distort.twist"]); + // A press outside the panel: the key no longer reaches the rows. + click(&ctx, &mut app, stroke, boxed_panel); + click(&ctx, &mut app, egui::pos2(900.0, 900.0), boxed_panel); + key_frame(&ctx, &mut app, egui::Key::Delete); + assert_eq!(stack_items(&app).len(), 2); + // A focused text field keeps the key. + click(&ctx, &mut app, stroke, boxed_panel); + ctx.memory_mut(|m| m.request_focus(egui::Id::unique("some-field"))); + key_frame(&ctx, &mut app, egui::Key::Delete); + assert_eq!(stack_items(&app).len(), 2); +} + +/// A selected group listed top-down `S3, FA, Contents, S1, F0` (fills red F0 and green FA, strokes +/// 1 and 3 pt; paint order F0 S1 FA S3). +fn straddling_group() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "object.group", json!({})); + run(&mut app, "appearance.addFill", json!({})); + run(&mut app, "paint.setFill", json!({"color": "#ff0000", "item": 0})); + run(&mut app, "appearance.addStroke", json!({})); + run(&mut app, "stroke.set", json!({"item": 1, "weight": 1})); + run(&mut app, "appearance.addFill", json!({})); + run(&mut app, "paint.setFill", json!({"color": "#00ff00", "item": 2})); + run(&mut app, "appearance.addStroke", json!({})); + run(&mut app, "stroke.set", json!({"item": 3, "weight": 3})); + run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 2})); + app +} + +/// The group's items, paint order (F0/FA by colour, S), and how many paint below Contents. +fn group_stack(app: &VectorcraftApp) -> (Vec<&'static str>, usize) { + let ap = first_selected(app).unwrap().appearance.clone(); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::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()) +} + +/// The owner's example: FA and S1 highlighted, FA dragged onto S3, lands as one block above it: +/// listed FA, S1, S3, Contents, F0. The moved rows stay highlighted: Delete then removes both. +#[test] +fn highlighted_rows_drag_as_one_block_and_stay_highlighted() { + let mut app = straddling_group(); + assert_eq!(group_stack(&app), (vec!["F0", "S1", "FA", "S3"], 2)); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let (fa, s1) = (row_point(&ctx, &mut app, "Fill:", 0), row_point(&ctx, &mut app, "Stroke:", 1)); + click_mod(&ctx, &mut app, fa, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, s1, egui::Modifiers::COMMAND); + let s3 = row_point(&ctx, &mut app, "Stroke:", 0); + drag_in_panel(&ctx, &mut app, fa, s3 - egui::vec2(0.0, 5.0)); + assert_eq!(group_stack(&app), (vec!["F0", "S3", "S1", "FA"], 1)); + click(&ctx, &mut app, delete, appearance::show); + // Listed S3, Contents, F0. + assert_eq!(group_stack(&app), (vec!["F0", "S3"], 1)); +} + +/// Alt-dragging two highlighted effects onto the stroke copies both into its list; the copies are +/// then the highlighted rows. +#[test] +fn alt_dragging_highlighted_effects_copies_them_together() { + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let (glow, twist) = (row_point(&ctx, &mut app, "Outer Glow", 0), row_point(&ctx, &mut app, "Twist", 0)); + click_mod(&ctx, &mut app, glow, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, twist, egui::Modifiers::COMMAND); + let stroke = row_point(&ctx, &mut app, "Stroke:", 0); + let alt = egui::Modifiers::ALT; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: alt }; + for e in [ + vec![egui::Event::ModifiersChanged(alt), egui::Event::PointerMoved(glow)], + vec![button(glow, true)], + vec![egui::Event::PointerMoved(glow + egui::vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(stroke)], + vec![button(stroke, false)], + vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)], + ] { + frame_events(&ctx, &mut app, e, appearance::show); + } + let ids: Vec<_> = current_stroke_effects(&app).iter().map(|e| e.id.clone()).collect(); + assert_eq!(ids, ["stylize.outerGlow", "distort.twist"]); + assert_eq!(object_effects(&app).len(), 3, "the originals stay"); +} + +/// While highlighted rows are dragged, a dimmed copy of the first highlighted row follows the +/// pointer (as in the Layers panel, whichever row was grabbed) and the cursor is the grabbing hand. +#[test] +fn dragged_rows_show_a_dimmed_copy_of_the_first_highlighted_row() { + let mut app = straddling_group(); + let ctx = drop_ctx(); + let (fa, s1) = (row_point(&ctx, &mut app, "Fill:", 0), row_point(&ctx, &mut app, "Stroke:", 1)); + click_mod(&ctx, &mut app, fa, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, s1, egui::Modifiers::COMMAND); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [vec![egui::Event::PointerMoved(s1)], vec![button(s1, true)], vec![egui::Event::PointerMoved(s1 + egui::vec2(0.0, 6.0))]] { + frame_events(&ctx, &mut app, e, appearance::show); + } + let held = egui::pos2(s1.x, 2000.0); + // The copy shows from the frame after the drag starts. + frame_events(&ctx, &mut app, vec![egui::Event::PointerMoved(held - egui::vec2(0.0, 10.0))], appearance::show); + let mut out = + ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(held)], ..Default::default() }, |ui| appearance::show(&mut app, ui)); + out.textures_delta.clear(); + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Grabbing); + let mut near = vec![]; + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape + && (t.pos.y - held.y).abs() < 40.0 + { + near.push(t.galley.text().to_string()); + } + } + assert!(near.contains(&"Fill:".to_string()), "the copy shows the first highlighted row: {near:?}"); + assert!(!near.contains(&"Stroke:".to_string()), "not the grabbed one: {near:?}"); +} + +/// One frame at time `t` (seconds) with `events`: the texts drawn. +fn timed_frame(ctx: &egui::Context, app: &mut VectorcraftApp, t: f64, events: Vec) -> Vec { + frame_raw(ctx, app, egui::RawInput { events, time: Some(t), ..Default::default() }, appearance::show).into_iter().map(|(s, _)| s).collect() +} + +/// The tooltip shown after resting the pointer on `pos`. +fn tooltip_at(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2) -> Vec { + let t = ctx.input(|i| i.time) + 1.0; + timed_frame(ctx, app, t, vec![egui::Event::PointerMoved(pos)]); + timed_frame(ctx, app, t + 2.0, vec![]); + timed_frame(ctx, app, t + 2.1, vec![]) +} + +/// Double-click at `pos` (two quick clicks). +fn double_click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2) { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let t = ctx.input(|i| i.time) + 1.0; + for (i, e) in [egui::Event::PointerMoved(pos), button(true), button(false), button(true), button(false)].into_iter().enumerate() { + timed_frame(ctx, app, t + i as f64 * 0.05, vec![e]); + } +} + +/// The text of an effect row (its name) opens the effect's settings dialog on a click, with or +/// without Ctrl/Cmd or Shift held, and says "Click to edit"; the empty space right of it takes the +/// click rules, opens the dialog on a double-click and says "Double-click to edit". +#[test] +fn an_effects_name_edits_it_and_its_empty_space_highlights_it() { + for m in [egui::Modifiers::NONE, egui::Modifiers::COMMAND, egui::Modifiers::SHIFT] { + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click_mod(&ctx, &mut app, text_rect(&texts, "Outer Glow").center(), m); + assert!(app.ui.dialog.is_some(), "{m:?}: a click on the name opens the dialog"); + } + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + // Over the name only its own tooltip shows, not the row's. + let tip = tooltip_at(&ctx, &mut app, text_rect(&texts, "Outer Glow").center()); + assert!(tip.contains(&"Click to edit".to_string()) && !tip.contains(&"Double-click to edit".to_string()), "{tip:?}"); + let space = row_point(&ctx, &mut app, "Outer Glow", 0); + assert!(tooltip_at(&ctx, &mut app, space).contains(&"Double-click to edit".to_string())); + // A single Ctrl-click on the empty space opens nothing; a double-click there does. + click_mod(&ctx, &mut app, space, egui::Modifiers::COMMAND); + assert!(app.ui.dialog.is_none()); + double_click(&ctx, &mut app, space); + assert!(app.ui.dialog.is_some(), "a double-click on the empty space opens the dialog"); +} + +/// A fill or stroke row's label ("Fill:", "Stroke:") is a link: a click opens the Color or Stroke +/// panel with or without modifiers, with the tooltip "Click to edit". Its empty space only takes the +/// click rules: no tooltip, and neither a Ctrl-click nor a double-click there opens a panel. +#[test] +fn fill_and_stroke_labels_are_links_and_their_empty_space_only_highlights() { + for (label, panel) in [("Stroke:", "stroke"), ("Fill:", "color")] { + for m in [egui::Modifiers::NONE, egui::Modifiers::COMMAND, egui::Modifiers::SHIFT] { + let mut app = app_with_rect(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click_mod(&ctx, &mut app, text_rect(&texts, label).center(), m); + assert_eq!(app.ui.transient_panel.as_deref(), Some(panel), "{label} {m:?}"); + } + let mut app = app_with_rect(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let tip = tooltip_at(&ctx, &mut app, text_rect(&texts, label).center()); + assert!(tip.contains(&"Click to edit".to_string()) && !tip.contains(&"Double-click to edit".to_string()), "{label}: {tip:?}"); + let space = row_point(&ctx, &mut app, label, 0); + let tip = tooltip_at(&ctx, &mut app, space); + assert!(!tip.iter().any(|t| t.ends_with("to edit")), "{label}: no tooltip on the empty space: {tip:?}"); + click_mod(&ctx, &mut app, space, egui::Modifiers::COMMAND); + double_click(&ctx, &mut app, space); + assert_eq!(app.ui.transient_panel, None, "{label}"); + } +} + +/// One app-like frame: the global shortcuts first, then the panel `f` in a 300×500 area (as the app +/// runs `shortcuts::handle` in `logic` before the panels draw). +fn app_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, f: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + ui.allocate_ui(egui::vec2(300.0, 500.0), |ui| f(app, ui)); + }); + out.textures_delta.clear(); +} + +/// A click at `pos` in app-like frames of `f` ([`app_frame`]): hover, press, release, then a frame with the pointer still there. +fn app_click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, f: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(pos), button(true), button(false), egui::Event::PointerMoved(pos)] { + app_frame(ctx, app, vec![e], f); + } +} + +/// `key` pressed and released in an app-like frame of `f`, then one more frame. +fn app_key(ctx: &egui::Context, app: &mut VectorcraftApp, key: egui::Key, f: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + app_frame(ctx, app, vec![ev(true), ev(false)], f); + app_frame(ctx, app, vec![], f); +} + +/// With the app's shortcuts running first: after a click on an Appearance row, Delete and Backspace +/// remove the highlighted rows and leave the object; after a click outside the panel they delete the +/// selected object again (Edit › Clear). +#[test] +fn after_a_row_click_delete_reaches_the_appearance_panel_not_edit_clear() { + let ctx = drop_ctx(); + for key in [egui::Key::Delete, egui::Key::Backspace] { + let mut app = rect_with_three_effects(); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + let glow = text_rect(&texts, "Outer Glow").right_center() + egui::vec2(40.0, 0.0); + app_click(&ctx, &mut app, glow, boxed_panel); + app_key(&ctx, &mut app, key, boxed_panel); + assert!(first_selected(&app).is_some(), "{key:?}: the object stays"); + assert_eq!(object_effects(&app), ["stylize.dropShadow", "distort.twist"], "{key:?}"); + app_click(&ctx, &mut app, egui::pos2(900.0, 900.0), boxed_panel); + app_key(&ctx, &mut app, key, boxed_panel); + assert!(app.session.active().unwrap().doc.layers[0].children().unwrap().is_empty(), "{key:?}: Edit › Clear deletes the object"); + } +} + +/// The same after a click on a row's name, which also edits the row: "Stroke:" opens the Stroke +/// panel, the name of an effect without settings its inline editor (one with settings opens a +/// dialog, which keeps the keys from the app's shortcuts anyway). Once the Stroke panel's popup is +/// put away (its field has the keyboard while it is open), Delete removes that row and leaves the +/// object. +#[test] +fn after_a_name_click_delete_reaches_the_appearance_panel_not_edit_clear() { + for (name, key) in [("Stroke:", egui::Key::Delete), ("Outline Object", egui::Key::Backspace)] { + // A context of its own: the panel keeps the keys from one click to the next. + let ctx = drop_ctx(); + let mut app = rect_with_three_effects(); + run(&mut app, "effect.apply", json!({"effect": "path.outlineObject", "item": null})); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + let at = text_rect(&texts, name).center(); + app_click(&ctx, &mut app, at, boxed_panel); + app_key(&ctx, &mut app, egui::Key::Escape, boxed_panel); + app_key(&ctx, &mut app, key, boxed_panel); + assert!(first_selected(&app).is_some(), "{name}: the object stays"); + if name == "Stroke:" { + assert_eq!(stack_items(&app), [(true, None)], "{name}: the stroke goes"); + } else { + assert_eq!(object_effects(&app), ["stylize.outerGlow", "stylize.dropShadow", "distort.twist"], "{name}: the effect goes"); + } + } +} + +/// After a click on a group's Contents row or a type object's Characters link, Delete stays with +/// the panel: the row can't be removed, so nothing is, and Edit › Clear doesn't delete the object. +#[test] +fn after_a_contents_click_delete_removes_nothing() { + let group = || { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "object.group", json!({})); + run(&mut app, "appearance.addFill", json!({})); + app + }; + let type_object = || { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let t = run(&mut app, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [t]})); + app + }; + for (mut app, label) in [(group(), "Contents"), (type_object(), "Characters")] { + let ctx = drop_ctx(); + let before = first_selected(&app).unwrap().clone(); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + // The Contents row's empty space; the Characters name, a link. + let r = text_rect(&texts, label); + let at = if label == "Contents" { r.right_center() + egui::vec2(40.0, 0.0) } else { r.center() }; + app_click(&ctx, &mut app, at, boxed_panel); + app_key(&ctx, &mut app, egui::Key::Delete, boxed_panel); + let after = first_selected(&app).unwrap_or_else(|| panic!("{label}: the object stays")); + assert_eq!(after.appearance, before.appearance, "{label}: nothing is removed"); + } +} + +/// After a Shift-click on a fill/stroke row's swatch (which opens the Color panel on it) and its +/// popup put away, Delete removes that row, not the object. +#[test] +fn after_a_shift_click_on_a_swatch_delete_removes_its_row() { + let ctx = drop_ctx(); + let mut app = app_with_rect(); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + // The swatch: 61 pt right of where the row's name starts. + let r = text_rect(&texts, "Stroke:"); + let at = egui::pos2(r.left() + 61.0, r.center().y); + let shift = egui::Modifiers::SHIFT; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: shift }; + for e in [egui::Event::PointerMoved(at), button(true), button(false)] { + app_frame(&ctx, &mut app, vec![egui::Event::ModifiersChanged(shift), e], boxed_panel); + } + app_frame(&ctx, &mut app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)], boxed_panel); + assert_eq!(app.session.appearance_item(), Some(1), "the Shift-click selects the stroke row"); + app_key(&ctx, &mut app, egui::Key::Escape, boxed_panel); + app_key(&ctx, &mut app, egui::Key::Delete, boxed_panel); + assert!(first_selected(&app).is_some(), "the object stays"); + assert_eq!(stack_items(&app), [(true, None)], "the stroke goes"); +} + +/// The Layers panel in a 300×500 pt area at the top left, so clicks can land outside it. +fn boxed_layers(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(300.0, 500.0), |ui| super::layers::show(app, ui)); +} + +/// The same in the Layers panel: after a click on a layer's row, Delete deletes that layer, not the +/// art selected on another layer. +#[test] +fn after_a_row_click_delete_reaches_the_layers_panel_not_edit_clear() { + let ctx = drop_ctx(); + let mut app = app_with_rect(); + run(&mut app, "layer.new", json!({})); + let rect = first_selected(&app).unwrap().id; + let texts = frame_events(&ctx, &mut app, vec![], boxed_layers); + let row = text_rect(&texts, "Layer 2").center(); + app_click(&ctx, &mut app, row, boxed_layers); + app_key(&ctx, &mut app, egui::Key::Delete, boxed_layers); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.layers.len(), 1, "Layer 2 is deleted"); + assert!(d.node(rect).is_some(), "the rectangle on Layer 1 stays"); +} + +/// "Opacity:" is a link to the Transparency panel ("Click to edit"), whatever modifiers are held: on +/// a stroke's Opacity row it makes that stroke the active row first; on the object's own Opacity row +/// no fill/stroke is active. +#[test] +fn the_opacity_label_opens_the_transparency_panel_on_its_row() { + for m in [egui::Modifiers::NONE, egui::Modifiers::COMMAND, egui::Modifiers::SHIFT] { + let mut app = app_with_rect(); + let ctx = drop_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let item = texts.iter().find(|(t, _)| t == "Opacity:").expect("the stroke's Opacity row").1; + click_mod(&ctx, &mut app, item.center(), m); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.appearance_item()), (Some("transparency"), Some(1)), "{m:?}"); + app.ui.transient_panel = None; + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let object = texts.iter().rev().find(|(t, _)| t == "Opacity:").expect("the object's Opacity row").1; + click_mod(&ctx, &mut app, object.center(), m); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.appearance_item()), (Some("transparency"), None), "{m:?}"); + } + let mut app = app_with_rect(); + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let tip = tooltip_at(&ctx, &mut app, text_rect(&texts, "Opacity:").center()); + assert!(tip.contains(&"Click to edit".to_string()), "{tip:?}"); +} + +/// Whether each control named in `labels` is on in a frame of `f` (by its accessibility node). +fn controls_on(app: &mut VectorcraftApp, labels: &[&str], f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + let ctx = drop_ctx(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + labels + .iter() + // (Menu items carry a tick or its space before the label.) + .map(|l| { + let named = |n: &&(egui::accesskit::NodeId, egui::accesskit::Node)| n.1.label().is_some_and(|t| t.trim_start_matches('✓').trim() == *l); + !nodes.iter().find(named).unwrap_or_else(|| panic!("no control named {l:?}")).1.is_disabled() + }) + .collect() +} + +/// While the Appearance panel's active row is a fill/stroke, the Transparency panel shows that item's +/// opacity and blend and greys out what only applies to whole objects: the opacity mask, Isolate +/// Blending, Knockout Group, Opacity & Mask Define Knockout Shape, and the menu's mask items. +#[test] +fn the_transparency_panel_greys_out_object_controls_for_a_fill_or_stroke() { + let object = ["Make Mask", "Isolate Blending", "Knockout Group", "Opacity & Mask Define Knockout Shape"]; + let mut app = app_with_rect(); + assert_eq!(controls_on(&mut app, &object, transparency::show), [true; 4], "the object: all on"); + assert_eq!(controls_on(&mut app, &["Make Opacity Mask"], transparency::menu), [true]); + run(&mut app, "appearance.setActiveItem", json!({"index": 1})); + assert_eq!(controls_on(&mut app, &object, transparency::show), [false; 4], "a stroke: all off"); + assert_eq!(controls_on(&mut app, &["Make Opacity Mask"], transparency::menu), [false]); + run(&mut app, "appearance.setActiveItem", json!({"index": null})); + assert_eq!(controls_on(&mut app, &object, transparency::show), [true; 4], "back to the object"); +} + +/// The texts drawn on the highlighted rows of a frame of the panel (rows filled with the +/// `row_selected` colour), top-down. +fn lit_texts(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| appearance::show(app, ui)); + out.textures_delta.clear(); + let lit = crate::theme::Tokens::get(ctx).row_selected; + let (mut rows, mut texts) = (vec![], vec![]); + /// The highlighted rows' rects and every text with its rect in shape `s`. + fn collect(s: &egui::Shape, lit: egui::Color32, rows: &mut Vec, texts: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Rect(r) if r.fill == lit => rows.push(r.rect), + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, lit, rows, texts)), + _ => {} + } + } + for c in &out.shapes { + collect(&c.shape, lit, &mut rows, &mut texts); + } + // (A swatch's out-of-gamut sign and a row's popup aside.) + let popup = ctx.memory(|m| m.area_rect(egui::Id::unique("ap-row-popup"))); + let shown = |r: &egui::Rect| rows.iter().any(|row| row.contains(r.center())) && !popup.is_some_and(|p| p.contains(r.center())); + texts.into_iter().filter(|(t, r)| t.trim_end_matches('\u{fe0f}') != "⚠" && shown(r)).map(|(t, _)| t).collect() +} + +/// A fill/stroke's "Opacity:" highlights its own Opacity row, not the fill/stroke, whatever modifiers +/// are held; the Transparency panel still edits that fill/stroke. The object's Opacity row +/// highlights itself too, by its link or its empty space (which opens nothing). +#[test] +fn an_opacity_row_highlights_itself() { + for m in [egui::Modifiers::NONE, egui::Modifiers::COMMAND, egui::Modifiers::SHIFT] { + let mut app = app_with_rect(); + let ctx = drop_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + click_mod(&ctx, &mut app, text_rect(&texts, "Opacity:").center(), m); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.appearance_item()), (Some("transparency"), Some(1)), "{m:?}"); + assert_eq!(lit_texts(&ctx, &mut app), ["Opacity:", "Default"], "{m:?}: the stroke's Opacity row only"); + app.ui.transient_panel = None; + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let object = texts.iter().rev().find(|(t, _)| t == "Opacity:").unwrap().1; + click_mod(&ctx, &mut app, object.center(), m); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.appearance_item()), (Some("transparency"), None), "{m:?}"); + assert_eq!(lit_texts(&ctx, &mut app), ["Opacity:", "Default"], "{m:?}: the object's Opacity row"); + } + // The empty space: highlights, opens nothing. + let mut app = app_with_rect(); + let ctx = drop_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let stroke_opacity = text_rect(&texts, "Default").right_center() + egui::vec2(60.0, 0.0); + click(&ctx, &mut app, stroke_opacity, appearance::show); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.appearance_item()), (None, Some(1))); + assert_eq!(lit_texts(&ctx, &mut app), ["Opacity:", "Default"]); + let texts = frame_events(&ctx, &mut app, vec![], appearance::show); + let object = texts.iter().rev().find(|(t, _)| t == "Default").unwrap().1.right_center() + egui::vec2(60.0, 0.0); + click(&ctx, &mut app, object, appearance::show); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.appearance_item()), (None, None)); + assert_eq!(lit_texts(&ctx, &mut app), ["Opacity:", "Default"]); +} + +/// An Opacity row is no item: while one is highlighted Duplicate and Delete are greyed out and the +/// Delete key removes nothing (nor the object); dragging it changes nothing. +#[test] +fn an_opacity_row_cannot_be_deleted_duplicated_or_dragged() { + let mut app = app_with_rect(); + let ctx = drop_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let before = stack_items(&app); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + let space = text_rect(&texts, "Default").right_center() + egui::vec2(60.0, 0.0); + app_click(&ctx, &mut app, space, boxed_panel); + assert_eq!(lit_texts(&ctx, &mut app), ["Opacity:", "Default"]); + for button in ["Duplicate Selected Item", "Delete Selected Item"] { + let at = panel_button_in(&ctx, &mut app, button, boxed_panel); + app_click(&ctx, &mut app, at, boxed_panel); + assert_eq!(stack_items(&app), before, "{button} is greyed out"); + app_click(&ctx, &mut app, space, boxed_panel); + } + for key in [egui::Key::Delete, egui::Key::Backspace] { + app_key(&ctx, &mut app, key, boxed_panel); + assert!(first_selected(&app).is_some(), "{key:?}: the object stays"); + assert_eq!(stack_items(&app), before, "{key:?}"); + } + let fill = row_point(&ctx, &mut app, "Fill:", 0); + drag_in_panel(&ctx, &mut app, space, fill + egui::vec2(0.0, 10.0)); + assert_eq!(stack_items(&app), before, "dragged: nothing moves"); +} + +/// A context with the app's fonts (the flyout draws icons from them). +fn flyout_ctx() -> egui::Context { + let ctx = drop_ctx(); + crate::theme::install_fonts(&ctx); + ctx +} + +/// The frame of the Appearance panel, 300 points wide at the window's left edge, with a row's popup +/// beside it, as the app draws them. +fn panel_and_flyout(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(300.0, 500.0), |ui| appearance::show(app, ui)); +} + +/// A panel a row's link opens closes on a press outside it (in the Appearance panel, on Delete +/// Selected Item…), with Auto-Collapse Iconic Panels off; its first text field takes the keyboard. +#[test] +fn panels_a_row_opens_close_when_they_lose_focus() { + let links = [("Stroke:", 0, "stroke"), ("Fill:", 0, "color"), ("Opacity:", 0, "transparency"), ("Opacity:", 1, "transparency")]; + for (label, nth, panel) in links { + let mut app = app_with_rect(); + let ctx = flyout_ctx(); + super::set_pstate(&ctx, "ap-open-1", true); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + let link = texts.iter().filter(|(t, _)| t == label).nth(nth).unwrap().1.center(); + app_click(&ctx, &mut app, link, panel_and_flyout); + for _ in 0..3 { + app_frame(&ctx, &mut app, vec![], panel_and_flyout); + } + assert_eq!(app.ui.transient_panel.as_deref(), Some(panel), "{label}"); + assert!(ctx.memory(|m| m.focused()).is_some(), "{label}: a field of the {panel} panel has the keyboard"); + let object_row = egui::pos2(150.0, 15.0); + app_click(&ctx, &mut app, object_row, panel_and_flyout); + assert_eq!(app.ui.transient_panel, None, "{label}: a press in the Appearance panel closes it"); + } + // Delete Selected Item closes the Stroke panel as it removes the stroke. + let mut app = app_with_rect(); + let ctx = flyout_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + app_click(&ctx, &mut app, text_rect(&texts, "Stroke:").center(), panel_and_flyout); + let delete = panel_button_in(&ctx, &mut app, "Delete Selected Item", panel_and_flyout); + app_click(&ctx, &mut app, delete, panel_and_flyout); + assert_eq!((app.ui.transient_panel.as_deref(), stack_items(&app).len()), (None, 1)); +} + +/// A row's popup opens beside the Appearance panel, level with the row whose link opened it: on +/// the right here, where the panel leaves no room on its left. Escape puts it away. +#[test] +fn a_row_popup_opens_beside_the_panel_at_its_row() { + let mut app = app_with_rect(); + let ctx = flyout_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + let link = text_rect(&texts, "Stroke:"); + app_click(&ctx, &mut app, link.center(), panel_and_flyout); + for _ in 0..3 { + app_frame(&ctx, &mut app, vec![], panel_and_flyout); + } + let popup = ctx.memory(|m| m.area_rect(egui::Id::unique("ap-row-popup"))).expect("the popup shows"); + assert!(popup.left() >= 300.0, "right of the panel: {popup:?}"); + assert!((popup.top() - link.center().y).abs() < 30.0, "level with its row: {popup:?} {link:?}"); + app_frame( + &ctx, + &mut app, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + panel_and_flyout, + ); + assert_eq!(app.ui.transient_panel, None, "Escape puts it away"); +} + +/// A link to a panel that shows already (floating in front of its window) opens no popup: the +/// panel is in sight as it is. +#[test] +fn a_row_link_to_a_panel_in_sight_opens_no_popup() { + let mut app = app_with_rect(); + app.run("window.panel.float", json!({"panel": "stroke"})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, "stroke")); + let ctx = flyout_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + app_click(&ctx, &mut app, text_rect(&texts, "Stroke:").center(), panel_and_flyout); + app_frame(&ctx, &mut app, vec![], panel_and_flyout); + assert_eq!(app.ui.transient_panel, None); +} + +/// On a type object "Characters" is a link ("Click to edit") to the Character panel, which closes +/// on a press outside it. +#[test] +fn characters_opens_the_character_panel() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [t]})); + let ctx = flyout_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + let characters = text_rect(&texts, "Characters").center(); + let tip = tooltip_at(&ctx, &mut app, characters); + assert!(tip.contains(&"Click to edit".to_string()), "{tip:?}"); + app_click(&ctx, &mut app, characters, panel_and_flyout); + assert_eq!(app.ui.transient_panel.as_deref(), Some("character")); + app_click(&ctx, &mut app, egui::pos2(150.0, 15.0), panel_and_flyout); + assert_eq!(app.ui.transient_panel, None); +} + +/// The point of the control named `label` (by its accessibility node) in an [`app_frame`] of `f` +/// (the same frame, so popups `f` opened in one stay open). +fn control_at(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, f: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Option { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + crate::shortcuts::handle(app, ui.ctx()); + ui.allocate_ui(egui::vec2(300.0, 500.0), |ui| f(app, ui)); + }); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref()?.nodes; + let b = nodes.iter().find(|(_, n)| n.label() == Some(label))?.1.bounds()?; + Some(egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)) +} + +/// With nothing selected, the fill and stroke swatches work as an object's do: a click opens the +/// swatches, whose pick paints the next object drawn; Shift-click opens the Color panel on that +/// fill or stroke. "Fill:" and "Stroke:" make theirs the Color panel's paint too. +#[test] +fn with_nothing_selected_the_swatches_set_up_new_art() { + let swatch = |app: &VectorcraftApp| app.session.active().unwrap().doc.swatches.iter().find(|s| !s.paint.is_none()).unwrap().clone(); + for (label, fill) in [("Fill:", true), ("Stroke:", false)] { + let mut app = app_with_rect(); + run(&mut app, "select.set", json!({"ids": []})); + let pick = swatch(&app); + let ctx = flyout_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + // The swatch sits 52 points right of where the label starts, 18 wide. + let chip = text_rect(&texts, label).left_center() + egui::vec2(61.0, 0.0); + app_click(&ctx, &mut app, chip, panel_and_flyout); + let tile = control_at(&ctx, &mut app, &pick.name, panel_and_flyout).unwrap_or_else(|| panic!("{label}: the swatches popup")); + app_click(&ctx, &mut app, tile, panel_and_flyout); + let ap = app.session.new_art(); + let item = ap.items.iter().rev().find(|it| it.is_fill() == fill).unwrap(); + assert_eq!(item.paint(), &pick.paint, "{label}: the pick paints new art"); + click_mod(&ctx, &mut app, chip, egui::Modifiers::SHIFT); + assert_eq!((app.ui.transient_panel.as_deref(), app.session.fill_active), (Some("color"), fill), "{label}: Shift-click"); + app.ui.transient_panel = None; + run(&mut app, "paint.toggleActive", json!({"fill": !fill})); + let texts = frame_events(&ctx, &mut app, vec![], panel_and_flyout); + app_click(&ctx, &mut app, text_rect(&texts, label).center(), panel_and_flyout); + assert_eq!( + (app.ui.transient_panel.as_deref(), app.session.fill_active), + (Some(if fill { "color" } else { "stroke" }), fill), + "{label}: its link" + ); + } +} + +/// The stroke row's weight is the same field with or without a selection: typed in, it sets the +/// selected stroke's weight, or with nothing selected the next object drawn's. +#[test] +fn the_stroke_weight_field_edits_with_or_without_a_selection() { + for selected in [true, false] { + let mut app = app_with_rect(); + if !selected { + run(&mut app, "select.set", json!({"ids": []})); + } + let ctx = drop_ctx(); + let one = Unit::Points.format(1.0); + let texts = frame_events(&ctx, &mut app, vec![], boxed_panel); + app_click(&ctx, &mut app, text_rect(&texts, &one).center(), boxed_panel); + let enter = + |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + app_frame(&ctx, &mut app, vec![egui::Event::Text("6".into())], boxed_panel); + app_frame(&ctx, &mut app, vec![enter(true), enter(false)], boxed_panel); + app_frame(&ctx, &mut app, vec![], boxed_panel); + let width = + if selected { first_selected(&app).unwrap().appearance.stroke().unwrap().width } else { app.session.new_art().stroke().unwrap().width }; + assert_eq!(width, 6.0, "selected: {selected}"); + } +} + +/// The stroke row's weight is the Stroke panel's spinner, with or without a selection: its up arrow +/// steps the weight by 1 pt and its dropdown sets a preset weight. +#[test] +fn the_stroke_weight_has_the_stroke_panels_arrows_and_presets() { + let preset = super::stroke::weight_presets(Unit::Points)[10]; + for selected in [true, false] { + let mut app = app_with_rect(); + if !selected { + run(&mut app, "select.set", json!({"ids": []})); + } + let width = |app: &VectorcraftApp| { + if selected { first_selected(app).unwrap().appearance.stroke().unwrap().width } else { app.session.new_art().stroke().unwrap().width } + }; + let ctx = drop_ctx(); + let arrows = control_at(&ctx, &mut app, "Step up or down", boxed_panel).unwrap_or_else(|| panic!("selected: {selected}: the arrows")); + app_click(&ctx, &mut app, arrows - egui::vec2(0.0, 6.0), boxed_panel); + assert_eq!(width(&app), 2.0, "selected: {selected}: the up arrow"); + let presets = control_at(&ctx, &mut app, "Presets", boxed_panel).unwrap(); + app_click(&ctx, &mut app, presets, boxed_panel); + let pick = + control_at(&ctx, &mut app, &Unit::Points.format(preset), boxed_panel).unwrap_or_else(|| panic!("selected: {selected}: the presets")); + app_click(&ctx, &mut app, pick, boxed_panel); + assert_eq!(width(&app), preset, "selected: {selected}: a preset"); + } +} + +/// Stroke weights step by the ladder (whole numbers from 1, quarters below): the Appearance +/// panel's arrows go from 0.3 pt up to 0.5 pt and back down to 0.25 pt; ↓ in the Stroke panel's +/// focused Weight field goes from 1 pt to 0.75 pt. +#[test] +fn stroke_weights_step_by_quarters_below_one() { + let mut app = app_with_rect(); + run(&mut app, "stroke.set", json!({"weight": 0.3})); + let weight = |app: &VectorcraftApp| first_selected(app).unwrap().appearance.stroke().unwrap().width; + let ctx = drop_ctx(); + let arrows = control_at(&ctx, &mut app, "Step up or down", boxed_panel).unwrap(); + app_click(&ctx, &mut app, arrows - egui::vec2(0.0, 6.0), boxed_panel); + assert!((weight(&app) - 0.5).abs() < 1e-9, "up: {}", weight(&app)); + app_click(&ctx, &mut app, arrows + egui::vec2(0.0, 6.0), boxed_panel); + assert!((weight(&app) - 0.25).abs() < 1e-9, "down: {}", weight(&app)); + // The Stroke panel's field, focused, with ↓. + run(&mut app, "stroke.set", json!({"weight": 1})); + /// The Stroke panel in a 300×500 pt area at the top left. + fn stroke_panel(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(300.0, 500.0), |ui| super::stroke::show(app, ui)); + } + let ctx = drop_ctx(); + let texts = frame_events(&ctx, &mut app, vec![], stroke_panel); + app_click(&ctx, &mut app, text_rect(&texts, &Unit::Points.format(1.0)).center(), stroke_panel); + app_key(&ctx, &mut app, egui::Key::ArrowDown, stroke_panel); + assert!((weight(&app) - 0.75).abs() < 1e-9, "↓: {}", weight(&app)); +} + +/// Dragging a highlighted effect onto Delete removes every highlighted effect, the active one too: +/// nothing is left active then, so the effect that moved up into the active one's place is not +/// taken for it (Delete Selected Item then removes nothing). +#[test] +fn a_drop_on_delete_that_removes_the_active_effect_leaves_no_effect_active() { + let mut app = rect_with_three_effects(); + let ctx = drop_ctx(); + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + let (glow, shadow) = (row_point(&ctx, &mut app, "Outer Glow", 0), row_point(&ctx, &mut app, "Drop Shadow", 0)); + // Drop Shadow, then Outer Glow with Ctrl: both highlighted, Outer Glow active. + click_mod(&ctx, &mut app, shadow, egui::Modifiers::NONE); + click_mod(&ctx, &mut app, glow, egui::Modifiers::COMMAND); + drag_in_panel(&ctx, &mut app, shadow, delete); + assert_eq!(object_effects(&app), ["distort.twist"]); + assert!(lit_texts(&ctx, &mut app).is_empty(), "nothing highlighted"); + // (Two rows shorter, the panel has its button higher up now.) + let delete = panel_button(&ctx, &mut app, "Delete Selected Item"); + click(&ctx, &mut app, delete, appearance::show); + assert_eq!(object_effects(&app), ["distort.twist"], "Twist was never highlighted"); +} + +/// Whether Duplicate Selected Item and Delete Selected Item are enabled in a frame of the panel +/// with input `events`. +fn drop_buttons_on(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> [bool; 2] { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| appearance::show(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + ["Duplicate Selected Item", "Delete Selected Item"].map(|l| !nodes.iter().find(|(_, n)| n.label() == Some(l)).unwrap().1.is_disabled()) +} + +/// With no row selected, Duplicate and Delete Selected Item light up while a row is dragged (it can +/// be dropped on them) and go dark again once it is released. +#[test] +fn the_drop_buttons_light_up_while_a_row_is_dragged() { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut app = app_with_rect(); + let ctx = drop_ctx(); + assert_eq!(drop_buttons_on(&ctx, &mut app, vec![]), [false, false], "nothing selected: dark"); + let stroke = row_point(&ctx, &mut app, "Stroke:", 0); + drop_buttons_on(&ctx, &mut app, vec![egui::Event::PointerMoved(stroke)]); + drop_buttons_on(&ctx, &mut app, vec![button(stroke, true)]); + let held = stroke + egui::vec2(0.0, 40.0); + drop_buttons_on(&ctx, &mut app, vec![egui::Event::PointerMoved(stroke + egui::vec2(0.0, 6.0))]); + assert_eq!(drop_buttons_on(&ctx, &mut app, vec![egui::Event::PointerMoved(held)]), [true, true], "lit while the row is dragged"); + drop_buttons_on(&ctx, &mut app, vec![button(held, false)]); + assert_eq!(drop_buttons_on(&ctx, &mut app, vec![]), [false, false], "dark again once it is released"); + assert_eq!(stack_items(&app).len(), 2, "released elsewhere: nothing removed"); +} diff --git a/crates/ui-egui/src/panels/tests_effectedit.rs b/crates/ui-egui/src/panels/tests_effectedit.rs index 537b0104e..a27a60cb6 100644 --- a/crates/ui-egui/src/panels/tests_effectedit.rs +++ b/crates/ui-egui/src/panels/tests_effectedit.rs @@ -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)); } diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 5747ef82d..229a23c41 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -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::(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(); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e9e2e3d2b..4bbb75fbe 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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!({})); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 36d26a14b..ccced4813 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, + /// 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, + /// 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, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e1e83995a..b03788da3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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}; diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 177e830e5..a6789916c 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -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 { + /// 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), + /// Highlight these rows too, listed top-down (Shift+Ctrl/Cmd). + AddRange(Vec), +} + +impl RowClick { + /// 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(order: &[K], highlighted: &[K], anchor: Option, clicked: K, m: egui::Modifiers) -> RowClick { + 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(highlighted: &mut Vec, click: &RowClick) { + let add = |rows: &mut Vec, 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(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, c: RowClick| { + 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::::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]); + } +} diff --git a/crates/ui-egui/src/widgets/look_tests.rs b/crates/ui-egui/src/widgets/look_tests.rs index 3f2241576..17a2288d1 100644 --- a/crates/ui-egui/src/widgets/look_tests.rs +++ b/crates/ui-egui/src/widgets/look_tests.rs @@ -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() };