Files
vectorcraft/crates/ui-egui/src/panels/layers.rs
T
bflatasticandClaude Opus 5.5 980cc4fe44 Fix #901: Shift-click a target circle in the Layers panel to add or remove it
Clicking an object's target circle in the Layers panel always replaced
the selection, so several objects couldn't be picked there. Shift-click
now adds the object (a layer: its art) to the selection, or takes it out
when it is selected already, as in Illustrator; nothing stays targeted
then. layer.target takes add: true for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:55:38 +07:00

1757 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Layers panel: a row for every layer, sublayer, group and object, nested and indented, with
//! the eye (visibility) and lock columns, the layer colour bar, a disclosure triangle, a thumbnail
//! and the name, then the target circle and the selection column; a bottom bar with the panel's
//! buttons and the ≡ menu.
//!
//! - Clicking a row highlights it and makes its layer current (`layer.setCurrent`); Shift-click
//! highlights the range of rows down to it, Ctrl/Cmd-click toggles one (`layer.highlight`). Panel
//! commands (Duplicate, Delete, Merge, Options…) act on the highlighted rows.
//! - The selection column selects a row's art (`layer.selectAll`, Shift adds or removes); a row
//! whose art is selected shows a square in its layer's colour, and dragging that square onto
//! another row moves the selected art there (Alt copies it).
//! - The eye and lock columns toggle a row (`layer.setProps`); dragging down a column sets every
//! row it passes to the same state, in one undo step. Ctrl/Cmd-click an eye switches the layer
//! between Preview and Outline. Alt-click an eye hides the other layers (`layer.hideOthers`), or
//! shows every layer when they are hidden (`layer.showAll`); Alt-click a lock does the same with
//! `layer.lockOthers` and `layer.unlockAll`.
//! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or
//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Dropped on
//! the trash they are deleted, on New Layer duplicated.
//! - The triangle opens or closes a row; Alt-click does the same to everything inside it.
//! - Double-clicking a name renames it; double-clicking elsewhere on a row opens its options.
//! - A target circle targets its layer, group or object (`layer.target`); dragging it onto another
//! row's circle moves the appearance there (Alt copies it, `appearance.transfer`) and dropping it
//! on the trash clears it.
//!
//! While an opacity mask is edited the panel lists only its art, under an `<Opacity Mask>` entry.
//!
//! The rows open are the document's ([`OpenRows`], saved in native files): a document opens with
//! only its top-level layers open. The rows shown are listed once per document change, and only
//! those scrolled into view are laid out, so a document of many thousand objects costs no more
//! per frame than a small one.
use std::collections::HashSet;
use std::sync::Arc;
use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_engine::OpenRows;
use crate::theme::Tokens;
use crate::widgets::{Live, PanelDrag};
use crate::{VectorcraftApp, icons, widgets};
/// Width of the eye and the lock columns.
const COLUMN: f32 = 25.0;
/// Indent per nesting level.
const INDENT: f32 = 14.0;
/// Right edge offsets of the target circle and the selection square.
const TARGET_X: f32 = 28.0;
const SQUARE_X: f32 = 11.0;
/// Layers panel options (≡ › Panel Options…), kept with the UI state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PanelOptions {
/// Show Layers Only: no rows for groups and objects.
pub layers_only: bool,
/// Row height in points: [`ROW_SMALL`], [`ROW_MEDIUM`], [`ROW_LARGE`] or another (12–100).
pub row_size: f32,
/// Thumbnails on layer, group and object rows.
pub thumb_layers: bool,
pub thumb_groups: bool,
pub thumb_objects: bool,
}
pub const ROW_SMALL: f32 = 20.0;
pub const ROW_MEDIUM: f32 = 26.0;
pub const ROW_LARGE: f32 = 40.0;
impl Default for PanelOptions {
fn default() -> Self {
Self { layers_only: false, row_size: ROW_MEDIUM, thumb_layers: true, thumb_groups: true, thumb_objects: true }
}
}
impl PanelOptions {
/// The row height, kept to 12–100 points.
pub fn row(&self) -> f32 {
if self.row_size.is_finite() { self.row_size.clamp(12.0, 100.0) } else { ROW_MEDIUM }
}
/// Whether `n`'s row shows a thumbnail.
fn thumb(&self, n: &Node) -> bool {
self.row() >= 18.0
&& match n.kind {
NodeKind::Layer { .. } => self.thumb_layers,
NodeKind::Group { .. } => self.thumb_groups,
_ => self.thumb_objects,
}
}
}
/// What a drag in the panel carries.
#[derive(Clone, Debug)]
enum LayersDrag {
/// Rows dragged to move (or, with Alt, copy) them.
Rows(Vec<NodeId>),
/// The selected-art square: the selected objects go to the row it is dropped on.
Art,
}
/// A drag down the eye or lock column: every row it passes takes `value`.
#[derive(Clone, Debug, Default)]
struct ColumnDrag {
/// The eye column (visibility), else the lock column.
eye: bool,
value: bool,
ids: Vec<u64>,
}
fn key(name: &str) -> egui::Id {
egui::Id::new(("layers-panel", name))
}
fn rename_id() -> egui::Id {
egui::Id::new("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);
Color32::from_rgb(r, g, b)
}
/// The layer colour bar of row `n` (rect `r`) at `x`: wider on layers.
fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) {
let w = if n.is_layer() { 4.0 } else { 2.0 };
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(w, r.height())), 0.0, colour);
}
/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went.
fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rect {
let s = (view.h - 4.0).min(40.0);
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside);
if !real_thumb(ui, view.doc, n, th) {
thumb(ui, n, th);
}
th
}
/// The dragged row `src`, dimmed, following the pointer at `pos` above everything else: its
/// colour bar, thumbnail and name.
fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
let (doc, t, h) = (view.doc, &view.t, view.h);
let Some(n) = doc.node(src) else { return };
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 name = painted_name(n, &n.display_name(), crate::i18n::current());
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
}
/// Where a dragged row goes relative to the row it is dropped on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Place {
Above,
Below,
Inside,
}
impl Place {
fn id(self) -> &'static str {
match self {
Place::Above => "above",
Place::Below => "below",
Place::Inside => "inside",
}
}
}
/// The name painted for a row in `lang`: a generated `<Kind>` name is translated, anything else is
/// user data (an unnamed text object shows its text, which can look like `<Path>`; only an empty
/// one is called `<Text>`).
fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String {
let generated = n.name.is_none() && !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| !r.text.is_empty()));
if generated && let Some(inner) = name.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
return format!("<{}>", crate::i18n::tr(lang, inner));
}
name.to_string()
}
/// Whether `n` gets rows for what it holds (a compound path's paths are one object).
fn opens(n: &Node, opts: &PanelOptions) -> bool {
match n.kind {
NodeKind::Compound { .. } => false,
_ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())),
}
}
/// One row of the panel.
#[derive(Clone)]
struct Row {
node: Arc<Node>,
/// Nesting level (0: a top-level layer).
depth: usize,
/// A clipping path: its group or layer clips.
clip_path: bool,
/// It holds rows ([`opens`]), and shows them.
opens: bool,
open: bool,
/// The colour of its layer (its own, for a layer).
colour: Color32,
/// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer.
dim: bool,
}
/// What the rows were listed from: listed again when any of it changes.
#[derive(Clone, PartialEq)]
struct RowsKey {
uid: u64,
revision: u64,
open: u64,
layers_only: bool,
query: String,
}
/// The rows the panel shows, top down: the layers (only the opacity mask's while one is edited)
/// with the rows of the open ones inside them. A search shows every row whose name, or the name of
/// anything inside it, contains `query` (lowercase).
fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str) -> Vec<Row> {
let mask_layer = doc.mask_edit.map(|m| m.layer);
let mut out = vec![];
for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) {
push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query), &mut out);
}
out
}
/// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds
/// it (the layer's colour, and whether it is visible).
fn push_rows(
n: &Arc<Node>,
depth: usize,
clip_path: bool,
(colour, shown): (Color32, bool),
cx: (&OpenRows, &PanelOptions, &str),
out: &mut Vec<Row>,
) {
let (open_rows, opts, query) = cx;
if opts.layers_only && !n.is_layer() {
return;
}
let colour = match &n.kind {
NodeKind::Layer { color, .. } => {
let [r, g, b] = color.rgb();
Color32::from_rgb(r, g, b)
}
_ => colour,
};
let shown = shown && n.visible;
let opens = opens(n, opts);
let searching = !query.is_empty();
let open = opens && (searching || open_rows.contains(n.id));
let at = out.len();
out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template() });
if open && let Some(children) = n.children() {
for (i, c) in children.iter().enumerate().rev() {
push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out);
}
}
// Searching, a row stays for its own name or for a row it keeps inside it.
if searching && out.len() == at + 1 && !n.display_name().to_lowercase().contains(query) {
out.truncate(at);
}
}
/// The rows of the active document as [`list_rows`] lists them, listed again only when the
/// document, its open rows, the search or Show Layers Only changed.
fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str) -> Arc<Vec<Row>> {
let now =
RowsKey { uid: st.uid, revision: st.revision, open: st.layers_open.generation(), layers_only: opts.layers_only, query: query.to_string() };
let id = key("rows");
if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc<Vec<Row>>)>(id))
&& was == now
{
return rows;
}
let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query));
ui.data_mut(|d| d.insert_temp(id, (now, rows.clone())));
rows
}
/// Everything one frame of the panel reads.
struct View<'a> {
doc: &'a Document,
sel: HashSet<NodeId>,
target: Option<NodeId>,
current: Option<NodeId>,
rows: Vec<NodeId>,
opts: PanelOptions,
t: Tokens,
h: f32,
column: Option<ColumnDrag>,
/// The row to scroll into view.
reveal: Option<NodeId>,
}
/// What one frame of the panel decided.
#[derive(Default)]
struct Out {
actions: Vec<(String, Value)>,
/// A row clicked with these modifiers (resolved once every row is listed).
click: Option<(NodeId, egui::Modifiers)>,
/// The column drag after this frame (`Some(None)`: it ended).
column: Option<Option<ColumnDrag>>,
/// Rows to open or close, and whether to open them.
toggle: Vec<(NodeId, bool)>,
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
// A rename belongs to the document it started in (node ids are per document): drop it when
// another document shows, so it can't rename that document's node with the same id.
let shown_doc = egui::Id::new("layers-doc");
let before = ui.data(|d| d.get_temp::<u64>(shown_doc));
if before != Some(st.uid) {
ui.data_mut(|d| {
if before.is_some() {
d.remove::<(u64, String)>(rename_id());
}
d.insert_temp(shown_doc, st.uid);
});
}
let doc = st.doc.clone();
let rows = st.highlighted_rows();
// Rows highlighted anew (a click, Locate Object, a new layer): open the layers around them.
let seen: Vec<NodeId> = ui.data(|d| d.get_temp(key("seen"))).unwrap_or_default();
let mut reveal = None;
if rows != seen {
let around: Vec<NodeId> =
rows.iter().flat_map(|r| doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default()).collect();
if let Some(st) = app.session.active_mut() {
for a in around {
st.layers_open.set(a, true);
}
}
reveal = rows.first().copied();
ui.data_mut(|d| d.insert_temp(key("seen"), rows.clone()));
}
let query = crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")).trim().to_lowercase();
let Some(st) = app.session.active() else { return };
let list = rows_of_doc(ui, st, &app.ui.layers_panel, &query);
let view = View {
doc: &doc,
sel: st.selection.objects.iter().copied().collect(),
target: st.selection.target,
current: st.current_layer(),
rows,
opts: app.ui.layers_panel.clone(),
t,
h: app.ui.layers_panel.row(),
column: ui.data(|d| d.get_temp(key("column"))),
reveal,
};
let mut out = Out::default();
ui.add_space(6.0);
let list_h = ui.available_height() - 34.0;
// Only the rows in view are laid out; one revealed out of view is scrolled to.
let revealed = reveal.and_then(|id| list.iter().position(|r| r.node.id == id));
let scrolled = ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, view.h, list.len(), |ui, range| {
if let Some(i) = revealed.filter(|i| !range.contains(i)) {
let top = ui.max_rect().top() + (i as f32 - range.start as f32) * view.h;
ui.scroll_to_rect(egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), view.h)), None);
}
for r in list.get(range).unwrap_or_default() {
row(ui, &view, r, &mut out);
}
})
});
let list_rect = scrolled.inner.inner_rect;
resolve_click(ui, &view, &list, &mut out);
if let Some(st) = app.session.active_mut() {
for (id, open) in out.toggle.drain(..) {
st.layers_open.set(id, open);
}
}
// The column drag: live while it lasts, one undo step when the button is released.
let released = ui.input(|i| i.pointer.any_released());
let changed = out.column.is_some();
let column = out.column.take().unwrap_or_else(|| view.column.clone());
if let Some(c) = column.as_ref().filter(|_| changed || released) {
let (prop, label) = if c.eye { ("visible", "Visibility") } else { ("locked", "Lock") };
let phase = if released { Live::Released } else { Live::Dragging };
let params = json!({"ids": c.ids, prop: c.value});
super::live_run(app, label, "layer.setProps", params, phase);
}
ui.data_mut(|d| match column.filter(|_| !released) {
Some(c) => {
d.insert_temp(key("column"), c);
}
None => d.remove::<ColumnDrag>(key("column")),
});
if released {
ui.data_mut(|d| d.remove::<u64>(egui::Id::new("layers-drag")));
}
// Dragged rows show as a dimmed row under the pointer.
if !released
&& let Some(drag) = egui::DragAndDrop::payload::<LayersDrag>(ui.ctx())
&& let LayersDrag::Rows(ids) = &*drag
&& let Some(src) = ids.first()
&& let Some(pos) = ui.input(|i| i.pointer.latest_pos())
{
drag_ghost(ui, &view, *src, pos);
}
// Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was
// clicked last, not the canvas).
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);
if focus
&& !view.rows.is_empty()
&& ui.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))
{
out.actions.push(("layer.delete".into(), json!({})));
}
bottom_bar(app, ui, &view, &doc, &mut out.actions);
for (c, p) in out.actions {
app.run(&c, p).ok();
}
}
/// The bottom bar: the layer count and the panel's buttons.
fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document, actions: &mut Vec<(String, Value)>) {
let t = &view.t;
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider));
let n = doc.layers.len();
ui.painter().text(
bar.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tn(n as u64, "{n} Layer", "{n} Layers"),
egui::FontId::proportional(11.5),
t.text_dim,
);
let m = ui.input(|i| i.modifiers);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
let trash = widgets::icon_button(&mut child, "trash-2", tl!("Delete Selection"), false, 24.0);
// A target circle dropped on the trash clears that appearance (dragged rows: below). Peek at
// the payload's type before taking it: egui's take drops a payload of another type.
if trash.dnd_hover_payload::<PanelDrag>().is_some()
&& let Some(d) = trash.dnd_release_payload::<PanelDrag>()
&& let PanelDrag::Appearance(id) = *d
{
actions.push(("appearance.clear".into(), json!({"ids": [id.0]})));
} else if trash.clicked() {
if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
actions.push(("edit.clear".into(), json!({})));
} else {
actions.push(("layer.delete".into(), json!({})));
}
}
// Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer.
let new_layer = widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0);
if new_layer.clicked() {
if m.alt {
actions.push(("ui.newLayer".into(), json!({})));
} else {
actions.push(("layer.new".into(), json!({"top": m.command})));
}
}
// Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are
// outlined while rows (or, on the trash, a target circle) hang over them.
for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] {
if let Some(d) = button.dnd_release_payload::<LayersDrag>()
&& let LayersDrag::Rows(ids) = &*d
{
actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
}
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|| (cmd == "layer.delete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
if over {
child.painter().rect_stroke(button.rect, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
}
}
if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() {
actions.push(if m.alt { ("ui.newLayer".into(), json!({"sublayer": true})) } else { ("layer.newSublayer".into(), json!({})) });
}
if widgets::icon_button(&mut child, "frame", tl!("Make/Release Clipping Mask"), false, 24.0).clicked() {
actions.push(("layer.clippingMask.toggle".into(), json!({})));
}
if widgets::icon_button(&mut child, "search", tl!("Locate Object"), false, 24.0).clicked() {
actions.push(("layer.locate".into(), json!({})));
}
}
/// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift
/// (highlight the rows from the last one clicked down to it).
fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) {
let Some((id, m)) = out.click.take() else { return };
let anchor: Option<NodeId> = ui.data(|d| d.get_temp(key("anchor")));
ui.data_mut(|d| d.insert_temp(key("focus"), true));
let at = |id: NodeId| list.iter().position(|r| r.node.id == id);
if m.shift
&& let Some(a) = anchor.or(view.rows.last().copied())
&& let (Some(i), Some(j)) = (at(a), at(id))
{
let range: Vec<u64> = list.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.node.id.0).collect();
out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }})));
return;
}
ui.data_mut(|d| d.insert_temp(key("anchor"), id));
if m.command {
out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"})));
} else {
out.actions.push(("layer.setCurrent".into(), json!({"id": id.0})));
}
}
/// Whether row `n` can take `drag` inside it.
fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
match n.kind {
NodeKind::Layer { .. } => true,
NodeKind::Group { .. } => match drag {
LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)),
LayersDrag::Art => true,
},
_ => false,
}
}
/// The widgets of row `item`.
fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
let n = &*item.node;
let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour);
let doc = view.doc;
let t = &view.t;
let h = view.h;
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
if view.reveal == Some(n.id) {
ui.scroll_to_rect(r, None);
}
let is_sel = view.sel.contains(&n.id);
let child_sel = !is_sel
&& n.children().is_some_and(|_| {
let mut any = false;
n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id));
any
});
// Highlighted rows (else the current layer's).
let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) };
if highlighted {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
if n.is_layer() && Some(n.id) == view.current {
// Current-layer marker: a small triangle in the top-right corner.
let c = r.right_top();
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE));
}
eye_and_lock(ui, view, n, r, out);
// Layer colour bar (every row shows the colour of its layer).
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(ui, n, r, x, color);
x += 6.0 + depth as f32 * INDENT;
// Disclosure triangle; Alt opens or closes everything inside too.
if has_children {
let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0));
let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click());
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim);
if dresp.clicked() {
out.toggle.push((n.id, !open));
if ui.input(|i| i.modifiers.alt) {
n.walk(&mut |c| {
if c.id != n.id && opens(c, &view.opts) {
out.toggle.push((c.id, !open));
}
});
}
}
}
x += 16.0;
// Thumbnail.
let thumb_rect = view.opts.thumb(n).then(|| {
let th = row_thumb(ui, view, n, r, x);
x = th.right() + 4.0;
th
});
// Name.
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.display_name() };
let font = egui::FontId::proportional(13.0);
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
let name_end;
match renaming {
Some((rid, mut buf)) if rid == n.id.0 => {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(font.clone()));
// Focus the field as it opens. Asking every frame took the focus back from Enter,
// Escape or a click elsewhere, so the rename never ended.
if !te.has_focus() && !te.lost_focus() {
te.request_focus();
}
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id()));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf})));
}
} else {
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf)));
}
name_end = name_rect.right();
}
_ => {
let painter = ui.painter().with_clip_rect(name_rect);
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").to_string()
} else {
painted_name(n, &name, crate::i18n::current())
};
let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text });
name_end = text.right();
// A clipping path's name is underlined, a masked object's with a dashed line.
if clip_path {
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
} else if n.mask.is_some() {
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
}
}
}
// Target circle and selection square.
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
let tc = egui::pos2(r.right() - TARGET_X, r.center().y);
let tresp = target_circle(ui, n, tc, h, view.target.map_or(is_sel, |id| id == n.id), &mut out.actions, t);
let sq = egui::pos2(r.right() - SQUARE_X, r.center().y);
if is_sel {
let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0));
ui.painter().rect_filled(q, 0.0, color);
ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside);
} else if child_sel {
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color);
}
let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.id().with(("selsq", n.id.0)), Sense::click_and_drag());
// Dragging the selected-art square carries the selection to another row.
if (is_sel || child_sel) && sq_resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art);
}
let m = ui.input(|i| i.modifiers);
if tresp.clicked() {
out.actions.push(("layer.target".into(), json!({"id": n.id.0, "add": m.shift})));
} else if sq_resp.clicked() {
out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift})));
} else if resp.clicked() {
out.click = Some((n.id, m));
}
if resp.double_clicked() {
let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left());
let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p)));
if on_name && !on_thumb {
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.display_name())));
} else {
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
}
}
// Dragging rows: the highlighted rows when this is one of them, else this row.
if resp.drag_started() && view.column.is_none() {
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)));
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
}
/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer
/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock
/// All Layers). Each is one undo step.
fn others_action(doc: &Document, n: &Node, eye: bool) -> (String, Value) {
let own = doc.layer_of(n.id);
let mut others = doc.layers.iter().filter(|l| Some(l.id) != own);
let cmd = match (eye, if eye { others.any(|l| l.visible) } else { others.any(|l| !l.locked) }) {
(true, true) => "layer.hideOthers",
(true, false) => "layer.showAll",
(false, true) => "layer.lockOthers",
(false, false) => "layer.unlockAll",
};
(cmd.into(), json!({ "ids": [n.id.0] }))
}
/// The eye and lock columns of row `n` at `r`.
fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) {
let t = &view.t;
let h = view.h;
let eye = egui::Rect::from_min_size(r.min, vec2(COLUMN, h));
let lock = egui::Rect::from_min_size(r.min + vec2(COLUMN, 0.0), vec2(COLUMN, h));
ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border));
ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border));
let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. });
let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click_and_drag());
if n.visible {
let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0));
if n.is_template() {
// A template layer shows the template mark instead of the eye.
icons::paint(ui, "shapes", icon, t.icon);
} else if preview {
icons::paint(ui, "eye", icon, t.icon);
} else {
// Outline view: a hollow eye.
icons::paint(ui, "eye", icon, t.text_dim);
ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim));
}
}
let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click_and_drag());
if n.locked {
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon);
} else if lr.hovered() {
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled);
}
let m = ui.input(|i| i.modifiers);
if er.clicked() {
if m.alt {
out.actions.push(others_action(view.doc, n, true));
} else if m.command && n.is_layer() {
// Ctrl/Cmd-click: Preview ↔ Outline for this layer.
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "preview": !preview})));
} else {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "visible": !n.visible})));
}
}
if lr.clicked() && m.alt {
out.actions.push(others_action(view.doc, n, false));
} else if lr.clicked() {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked})));
}
// Dragging down a column gives every row it passes this row's new state.
if er.drag_started() {
out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] }));
} else if lr.drag_started() {
out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] }));
} else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone())
&& !c.ids.contains(&n.id.0)
&& ui.input(|i| i.pointer.primary_down() && i.pointer.interact_pos().is_some_and(|p| p.y >= r.top() && p.y < r.bottom()))
{
let mut c = c;
c.ids.push(n.id.0);
out.column = Some(Some(c));
}
}
/// Row `n` at `r` as a drop target for dragged rows or the selected-art square: shows where they
/// would go (a line above or below it, or a box around it for inside) and moves them on release
/// (Alt copies). `indent` is where the row's content starts.
#[allow(clippy::too_many_arguments)]
fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: bool, resp: &egui::Response, out: &mut Out) {
let Some(drag) = resp.dnd_hover_payload::<LayersDrag>() else { return };
let doc = view.doc;
let moving: Vec<NodeId> = match &*drag {
LayersDrag::Rows(ids) => ids.clone(),
LayersDrag::Art => view.sel.iter().copied().collect(),
};
// Never into itself or a row inside it.
let around = doc.ancestry(n.id).unwrap_or_default();
if moving.is_empty() || moving.iter().any(|m| around.contains(m)) {
return;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return };
let rel = (p.y - r.top()) / r.height().max(1.0);
let inside = takes(n, &drag, doc);
let place = match &*drag {
LayersDrag::Art if inside => Place::Inside,
LayersDrag::Art => Place::Above,
LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside,
LayersDrag::Rows(_) if rel < 0.5 => Place::Above,
LayersDrag::Rows(_) => Place::Below,
};
let stroke = Stroke::new(2.0, view.t.accent);
match place {
Place::Above => {
ui.painter().line_segment([egui::pos2(indent, r.top()), egui::pos2(r.right(), r.top())], stroke);
}
Place::Below => {
ui.painter().line_segment([egui::pos2(indent, r.bottom()), egui::pos2(r.right(), r.bottom())], stroke);
}
Place::Inside => {
ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside);
}
}
if resp.dnd_release_payload::<LayersDrag>().is_some() {
let copy = ui.input(|i| i.modifiers.alt);
let ids: Vec<u64> = match &*drag {
// The selection's top objects, as Arrange and Group take them.
LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(),
LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(),
};
out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy})));
}
}
/// The target circle of `n`'s row at `c`: a ring, doubled while `n` is targeted, filled when `n`
/// has an appearance or transparency of its own. Its response is clicked to target `n`; dragging
/// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt
/// copies it) or the trash.
fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response {
let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.id().with(("target", n.id.0)), Sense::click_and_drag());
widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id));
let held = resp.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id));
if let Some(d) = resp.dnd_release_payload::<PanelDrag>()
&& let PanelDrag::Appearance(source) = *d
&& source != n.id
{
let copy = ui.input(|i| i.modifiers.alt);
actions.push(("appearance.transfer".into(), json!({"source": source.0, "target": n.id.0, "copy": copy})));
}
let ring = if held { t.accent } else { t.icon };
ui.painter().circle_stroke(c, 5.0, Stroke::new(if held { 1.5 } else { 1.0 }, ring));
if targeted {
ui.painter().circle_stroke(c, 2.8, Stroke::new(1.0, t.icon));
}
if has_styled_target(n) {
ui.painter().circle_filled(c, 3.2, t.icon);
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
resp
}
/// Whether an object's target circle is filled: its appearance is not basic (a group or layer:
/// it has fills, strokes or effects of its own) or its transparency (opacity, blend mode,
/// isolation, knockout or an opacity mask) is not the default.
fn has_styled_target(n: &Node) -> bool {
!n.appearance.is_basic()
|| (matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.appearance.items.is_empty())
|| !n.has_default_transparency()
}
/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc`
/// allocation, so the address is a free change detector). Only rendered for visible rows.
pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: crate::graphics::TexCache<HashMap<(usize, u64), egui::TextureHandle>> = crate::graphics::TexCache::default();
}
if !ui.is_rect_visible(r) {
return true;
}
let key = (n as *const Node as usize, n.id.0);
let ppp = ui.ctx().pixels_per_point();
let px = (r.width() * ppp).round() as u32;
let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| {
let img = RENDERER.with(|rr| rr.borrow_mut().render_thumbnail(doc, n.id, px.max(8)))?;
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(format!("layer-thumb-{}", n.id.0), color, egui::TextureOptions::LINEAR);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 2000 {
c.clear();
}
c.insert(key, tex.clone());
});
Some(tex)
});
match tex {
Some(t) => {
ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
true
}
None => false,
}
}
/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible).
fn thumb(ui: &Ui, n: &Node, r: egui::Rect) {
let Some(b) = n.visual_bounds() else { return };
let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32;
let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s);
let p = ui.painter().with_clip_rect(r);
let mut count = 0;
n.walk(&mut |c| {
if count > 40 {
return;
}
if let Some(pd) = c.path_data() {
count += 1;
let col = c.appearance.fill_paint().color().or(c.appearance.stroke_paint().color()).map(|cc| {
let [a, b2, d, _] = cc.to_rgba8(1.0);
Color32::from_rgb(a, b2, d)
});
if let Some(bb) = pd.bounds() {
let rr = egui::Rect::from_min_max(off + vec2(bb.x0 as f32 * s, bb.y0 as f32 * s), off + vec2(bb.x1 as f32 * s, bb.y1 as f32 * s));
p.rect_filled(rr, 0.0, col.unwrap_or(Color32::from_gray(120)));
}
}
});
}
/// A panel menu item: label, command, params, enabled, checked.
type MenuItem<'a> = (&'a str, &'a str, Value, bool, bool);
/// The Layers panel's (≡) menu.
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let rows = st.highlighted_rows();
let current = st.current_layer();
let has_sel = !st.selection.is_empty();
let isolated = st.isolation.is_some();
let targets: Vec<NodeId> = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() };
let one_container = match targets.as_slice() {
[id] => doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. })),
_ => false,
};
let layers_in: Vec<&Node> = targets.iter().filter_map(|id| doc.node(*id)).filter(|n| n.is_layer()).collect();
let template = layers_in.first().is_some_and(|l| l.is_template());
// The top-level layers holding the rows, and the others.
let kept: HashSet<NodeId> = targets.iter().filter_map(|id| doc.layer_of(*id)).collect();
let others: Vec<&Node> = doc.layers.iter().map(|l| &**l).filter(|l| !kept.contains(&l.id)).collect();
let mut all_layers = vec![];
doc.walk(|n| {
if n.is_layer() {
all_layers.push(n);
}
});
let mut items: Vec<Option<MenuItem>> = vec![
Some(("New Layer…", "ui.newLayer", json!({}), true, false)),
Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)),
Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)),
Some(("Delete Selection", "layer.delete", json!({}), !targets.is_empty(), false)),
Some(("Options for Selection…", "ui.layerOptions", json!({}), !targets.is_empty(), false)),
None,
Some(("Make/Release Clipping Mask", "layer.clippingMask.toggle", json!({}), true, false)),
None,
];
items.push(if isolated {
Some(("Exit Isolation Mode", "object.exitIsolation", json!({}), true, false))
} else {
let id = targets.first().map(|i| i.0);
Some(("Enter Isolation Mode", "object.isolate", json!({ "id": id }), one_container, false))
});
items.extend([
None,
Some(("Locate Object", "layer.locate", json!({}), has_sel, false)),
None,
Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)),
Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)),
Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)),
Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)),
Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)),
Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)),
None,
Some(("Template", "layer.template", json!({}), !layers_in.is_empty(), template)),
]);
// Each pair reads by state: Hide Others while another layer shows, else Show All Layers.
let any_other = |f: &dyn Fn(&Node) -> bool| others.iter().any(|l| f(l));
items.push(Some(if any_other(&|l| l.visible) {
("Hide Others", "layer.hideOthers", json!({}), true, false)
} else {
("Show All Layers", "layer.showAll", json!({}), all_layers.iter().any(|l| !l.visible), false)
}));
items.push(Some(if any_other(&|l| !matches!(l.kind, NodeKind::Layer { preview: false, .. })) {
("Outline Others", "layer.outlineOthers", json!({}), true, false)
} else {
(
"Preview All Layers",
"layer.previewAll",
json!({}),
all_layers.iter().any(|l| matches!(l.kind, NodeKind::Layer { preview: false, .. })),
false,
)
}));
items.push(Some(if any_other(&|l| !l.locked) {
("Lock Others", "layer.lockOthers", json!({}), true, false)
} else {
("Unlock All Layers", "layer.unlockAll", json!({}), all_layers.iter().any(|l| l.locked), false)
}));
items.push(None);
let remembers = doc.paste_remembers_layers;
items.push(Some(("Paste Remembers Layers", "layer.pasteRemembersLayers", json!({"on": !remembers}), true, remembers)));
items.push(None);
items.push(Some(("Panel Options…", "ui.layersPanelOptions", json!({}), true, false)));
for item in items {
match item {
None => {
ui.separator();
}
Some((label, cmd, p, enabled, checked)) => {
if widgets::menu_item(ui, label, enabled, checked) {
app.run(cmd, p).ok();
}
}
}
}
}
/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel;
/// without `ids`, every row that holds others.
pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let open = p.get("open").and_then(Value::as_bool).unwrap_or(true);
let ids: Vec<u64> = match p.get("ids").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_u64).filter(|i| st.doc.node(NodeId(*i)).is_some()).collect(),
None => {
let mut v = vec![];
st.doc.walk(|n| {
if n.is_container() {
v.push(n.id.0);
}
});
v
}
};
let st = app.session.active_mut().ok_or("no document open")?;
for i in &ids {
st.layers_open.set(NodeId(*i), open);
}
Ok(json!({ "count": ids.len() }))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
/// Filled target circles drawn by one headless frame of the panel.
fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count()
}
/// A generated `<Kind>` name is translated where painted; an unnamed text object's text never
/// is, even when it reads like one.
#[test]
fn only_generated_names_are_translated() {
let zh = crate::i18n::Lang::from_code("zh-hant").unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let rect = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
let text = run(&mut app, "text.create", json!({"x": 10, "y": 40, "text": "<Path>"}))["id"].as_u64().unwrap();
let doc = &app.session.active().unwrap().doc;
let names = |id: u64| {
let n = doc.node(NodeId(id)).unwrap();
(n.display_name(), painted_name(n, &n.display_name(), zh))
};
assert_eq!(names(text), ("<Path>".to_string(), "<Path>".to_string()));
let (stored, painted) = names(rect);
let inner = stored.trim_start_matches('<').trim_end_matches('>');
assert_eq!(painted, format!("<{}>", crate::i18n::tr(zh, inner)));
assert_ne!(painted, stored, "translated");
}
#[test]
fn target_circle_fills_for_non_default_transparency() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let id = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
run(&mut app, "select.set", json!({ "ids": [id] }));
let ctx = egui::Context::default();
assert_eq!(filled_targets(&mut app, &ctx), 0);
run(&mut app, "transparency.set", json!({"blend": "multiply"}));
assert_eq!(filled_targets(&mut app, &ctx), 1, "a Multiply object");
run(&mut app, "transparency.set", json!({"blend": "normal", "knockout": true}));
assert_eq!(filled_targets(&mut app, &ctx), 1, "a knockout group");
run(&mut app, "transparency.set", json!({"knockout": false}));
run(&mut app, "appearance.addStroke", json!({}));
assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes");
}
#[test]
fn a_layer_clipping_mask_underlines_its_clipping_path() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
run(&mut app, "shape.rectangle", json!({"x": 25, "y": 25, "width": 50, "height": 50}));
run(&mut app, "select.none", json!({}));
let ctx = egui::Context::default();
let text = Tokens::get(&ctx).text;
// Line segments in the text colour: the underlines.
let underlines = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == text)).count()
};
assert_eq!(underlines(&mut app), 0);
assert_eq!(run(&mut app, "layer.clippingMask.toggle", json!({}))["clip"], true);
assert_eq!(underlines(&mut app), 1);
run(&mut app, "layer.clippingMask.toggle", json!({}));
assert_eq!(underlines(&mut app), 0);
}
/// One headless frame of the panel with `events` (Alt held when `alt`): the centres of the
/// target circles, top row first, and the text-coloured line segments (underlines) drawn.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, alt: bool) -> (Vec<egui::Pos2>, usize) {
let mut events = events;
events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() }));
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let text = Tokens::get(ctx).text;
let mut circles = vec![];
let mut lines = 0;
for c in &out.shapes {
match &c.shape {
egui::Shape::Circle(cs) if cs.radius == 5.0 => circles.push(cs.center),
egui::Shape::LineSegment { stroke, .. } if stroke.color == text => lines += 1,
_ => {}
}
}
circles.sort_by(|a, b| a.y.total_cmp(&b.y));
(circles, lines)
}
fn button(pos: egui::Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// Drag from `a` to `b` over a few frames (Alt held when `alt`).
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) {
let steps = [
vec![egui::Event::PointerMoved(a)],
vec![button(a, true)],
vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))],
vec![egui::Event::PointerMoved(b)],
vec![button(b, false)],
vec![],
];
for e in steps {
frame(app, ctx, e, alt);
}
}
#[test]
fn alt_clicking_an_eye_or_lock_toggles_the_other_layers() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
run(&mut app, "layer.new", json!({}));
run(&mut app, "layer.new", json!({}));
let ctx = egui::Context::default();
// Rows top first: Layers 3, 2, 1.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3);
let state =
|app: &VectorcraftApp| -> Vec<(bool, bool)> { app.session.active().unwrap().doc.layers.iter().map(|l| (l.visible, l.locked)).collect() };
let click = |app: &mut VectorcraftApp, at: egui::Pos2, alt: bool| {
frame(app, &ctx, vec![egui::Event::PointerMoved(at)], alt);
frame(app, &ctx, vec![button(at, true)], alt);
frame(app, &ctx, vec![button(at, false)], alt);
};
let (eye, lock) = (egui::pos2(12.0, circles[1].y), egui::pos2(37.0, circles[1].y));
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let before = undo(&app);
// Alt-click Layer 2's eye: Layers 1 and 3 hide in one step; again, they show.
click(&mut app, eye, true);
assert_eq!(state(&app), [(false, false), (true, false), (false, false)]);
assert_eq!(undo(&app), before + 1);
click(&mut app, eye, true);
assert_eq!(state(&app), [(true, false); 3]);
// Alt-click its lock: the others lock; again, every layer unlocks.
click(&mut app, lock, true);
assert_eq!(state(&app), [(true, true), (true, false), (true, true)]);
assert_eq!(app.session.active().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Lock Others"));
// A plain click still toggles only that layer.
click(&mut app, eye, false);
assert_eq!(state(&app), [(true, true), (false, false), (true, true)]);
click(&mut app, lock, true);
assert_eq!(state(&app), [(true, false), (false, false), (true, false)]);
// A sublayer's row stands for its top-level layer: the other layers hide.
click(&mut app, eye, false);
let layer2 = app.session.active().unwrap().doc.layers[1].id.0;
let sub = run(&mut app, "layer.newSublayer", json!({"parent": layer2}));
// Rows: Layer 3, Layer 2, its sublayer, Layer 1.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 4);
click(&mut app, egui::pos2(12.0, circles[2].y), true);
assert_eq!(state(&app), [(false, false), (true, false), (false, false)]);
let doc = &app.session.active().unwrap().doc;
assert!(doc.node(NodeId(sub["id"].as_u64().unwrap())).unwrap().visible);
}
/// A document with two rectangles on one layer → (app, layer, [bottom, top]).
fn two_rects() -> (VectorcraftApp, NodeId, [NodeId; 2]) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
let b = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
run(&mut app, "select.none", json!({}));
let layer = app.session.active().unwrap().doc.layers[0].id;
(app, layer, [NodeId(a), NodeId(b)])
}
#[test]
fn clicking_a_target_circle_targets_the_layer() {
let (mut app, layer, [a, b]) = two_rects();
let ctx = egui::Context::default();
// Rows top down: the layer, the top rectangle, the bottom one.
let (c, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(c.len(), 3);
for e in [egui::Event::PointerMoved(c[0]), button(c[0], true), button(c[0], false)] {
frame(&mut app, &ctx, vec![e], false);
}
let st = app.session.active().unwrap();
assert_eq!(st.selection.target, Some(layer));
assert_eq!(st.selection.objects, vec![a, b]);
assert_eq!(super::super::appearance::object_label(&app), "Layer", "the Appearance panel lists the layer");
app.session.execute("transparency.set", &json!({"opacity": 25})).unwrap();
assert!((app.session.active().unwrap().doc.node(layer).unwrap().opacity - 0.25).abs() < 1e-6);
// The layer's circle is filled now; clicking an object's circle targets that object.
assert_eq!(filled_targets(&mut app, &ctx), 1);
for e in [egui::Event::PointerMoved(c[2]), button(c[2], true), button(c[2], false)] {
frame(&mut app, &ctx, vec![e], false);
}
let st = app.session.active().unwrap();
assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a]));
// Shift-clicking another object's circle adds it to the selection (#901), and again takes
// it out; nothing is targeted meanwhile.
click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT);
let st = app.session.active().unwrap();
assert_eq!((st.selection.target, st.selection.objects.clone()), (None, vec![a, b]));
click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT);
assert_eq!(app.session.active().unwrap().selection.objects, vec![a]);
}
#[test]
fn dragging_a_target_circle_moves_or_copies_the_appearance_and_the_trash_clears_it() {
let (mut app, layer, [a, b]) = two_rects();
app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap();
let ctx = egui::Context::default();
let (c, _) = frame(&mut app, &ctx, vec![], false);
let opacity = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().opacity;
// Alt-drag the bottom rectangle's circle onto the top one's: both have it.
drag(&mut app, &ctx, c[2], c[1], true);
assert!((opacity(&app, b) - 0.3).abs() < 1e-6 && (opacity(&app, a) - 0.3).abs() < 1e-6);
// A plain drag onto the layer's circle moves it there.
drag(&mut app, &ctx, c[1], c[0], false);
assert!((opacity(&app, layer) - 0.3).abs() < 1e-6 && opacity(&app, b) == 1.0);
// Dropped on the trash, the layer's appearance is cleared (and nothing is deleted).
let st = app.session.active().unwrap();
let count = st.doc.node_count();
let trash = trash_pos(&mut app, &ctx);
drag(&mut app, &ctx, c[0], trash, false);
let st = app.session.active().unwrap();
assert_eq!(st.doc.node(layer).unwrap().opacity, 1.0);
assert_eq!(st.doc.node_count(), count);
}
/// The trash button: the bottom bar's rightmost button.
fn trash_pos(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
bar_buttons(app, ctx)[0]
}
#[test]
fn dragging_rows_onto_the_trash_deletes_them() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let ctx = egui::Context::default();
// Rows top first: Layer 2, Layer 1. Grab Layer 2 by its name, left of its target circle.
let (c, _) = frame(&mut app, &ctx, vec![], false);
let top = app.session.active().unwrap().doc.layers[1].id;
let trash = trash_pos(&mut app, &ctx);
drag(&mut app, &ctx, c[0] - vec2(100.0, 0.0), trash, false);
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
assert!(doc.node(top).is_none());
}
#[test]
fn renaming_a_layer_commits_on_enter_and_escape_cancels() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let layer = app.session.doc().unwrap().doc.layers[0].id;
let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name();
let ctx = egui::Context::default();
let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let select_all =
egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
// What a double-click on the row does: the name field opens with the name.
let open = |app: &mut VectorcraftApp| {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (layer.0, name(app))));
frame(app, &ctx, vec![], false);
frame(app, &ctx, vec![], false);
};
open(&mut app);
frame(&mut app, &ctx, vec![select_all.clone(), egui::Event::Text("Sky".into())], false);
frame(&mut app, &ctx, vec![key(egui::Key::Enter)], false);
frame(&mut app, &ctx, vec![], false);
assert_eq!(name(&app), "Sky", "Enter keeps the new name");
assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("layers-rename"))).is_none(), "and closes the field");
open(&mut app);
frame(&mut app, &ctx, vec![select_all, egui::Event::Text("Ground".into())], false);
frame(&mut app, &ctx, vec![key(egui::Key::Escape)], false);
frame(&mut app, &ctx, vec![], false);
assert_eq!(name(&app), "Sky", "Escape cancels");
}
#[test]
fn an_open_rename_does_not_follow_into_another_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let layer = app.session.doc().unwrap().doc.layers[0].id;
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![], false);
ctx.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (layer.0, "Renamed in the first".to_string())));
frame(&mut app, &ctx, vec![], false);
// A second document, whose first layer has the same node id, comes to the front.
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers[0].id, layer);
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, &ctx, vec![], false);
frame(&mut app, &ctx, vec![enter], false);
frame(&mut app, &ctx, vec![], false);
assert_eq!(app.session.doc().unwrap().doc.layers[0].display_name(), "Layer 1");
assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("layers-rename"))).is_none());
}
#[test]
fn masks_underline_dashed_and_mask_editing_lists_only_the_mask() {
let (mut app, _, [a, _]) = two_rects();
let ctx = egui::Context::default();
let (_, before) = frame(&mut app, &ctx, vec![], false);
app.session.execute("select.set", &json!({"ids": [a.0]})).unwrap();
app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap();
// Editing the new (empty) mask: one row, `<Opacity Mask>`.
let (c, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(c.len(), 1);
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show);
assert!(texts.iter().any(|(t, _)| t == "<Opacity Mask>"), "{texts:?}");
app.session.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
let (c, after) = frame(&mut app, &ctx, vec![], false);
assert_eq!(c.len(), 3);
assert!(after > before + 1, "a dashed underline: {before} → {after}");
}
// ---- rows at every depth (layers, sublayers, groups, objects) ----
/// Layer 1 holding rectangle `c` and a sublayer holding rectangle `a` and a group of
/// rectangle `b`, every row open, nothing selected → (app, [layer, sub, g, b, a, c]): the rows
/// top down.
fn nested() -> (VectorcraftApp, [NodeId; 6]) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap());
run(&mut app, "file.new", json!({"width": 400, "height": 400}));
let layer = app.session.active().unwrap().doc.layers[0].id;
let c = id(run(&mut app, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20})));
let sub = id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"})));
let a = id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})));
let b = id(run(&mut app, "shape.rectangle", json!({"x": 100, "y": 0, "width": 20, "height": 20})));
run(&mut app, "select.set", json!({"ids": [b.0]}));
let g = id(run(&mut app, "object.group", json!({})));
run(&mut app, "select.none", json!({}));
expand(&mut app, &json!({})).unwrap();
(app, [layer, sub, g, b, a, c])
}
/// Press and release at `at` with `mods` held, over three frames.
fn click_with(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, mods: egui::Modifiers) {
for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] {
let mut out =
ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
}
}
fn rows_of(app: &VectorcraftApp) -> Vec<NodeId> {
app.session.active().unwrap().highlighted_rows()
}
#[test]
fn rows_at_every_depth_are_clickable() {
let (mut app, [layer, sub, g, b, a, c]) = nested();
let ctx = egui::Context::default();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 6, "a row for the layer, the sublayer, the group, both rectangles in it and the one on the layer");
let name = |i: usize| egui::pos2(150.0, circles[i].y);
for (i, row, current) in [(1, sub, sub), (2, g, sub), (3, b, sub), (4, a, sub), (5, c, layer), (0, layer, layer)] {
click_with(&mut app, &ctx, name(i), egui::Modifiers::NONE);
let st = app.session.active().unwrap();
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)), "row {i}");
}
// New art goes into the current sublayer once its row is clicked.
click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE);
let n = app.session.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
assert_eq!(app.session.active().unwrap().doc.parent_of(NodeId(n)), Some(sub));
app.session.execute("edit.undo", &json!({})).unwrap();
// Shift-click highlights the range down to the row; Ctrl/Cmd-click toggles one.
click_with(&mut app, &ctx, name(4), egui::Modifiers::SHIFT);
assert_eq!(rows_of(&app), vec![sub, g, b, a]);
click_with(&mut app, &ctx, name(2), egui::Modifiers::COMMAND);
assert_eq!(rows_of(&app), vec![sub, b, a]);
click_with(&mut app, &ctx, name(5), egui::Modifiers::COMMAND);
assert_eq!(rows_of(&app), vec![sub, b, a, c]);
// Delete with the panel's keyboard: the highlighted rows go.
let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, &ctx, vec![del], false);
let d = &app.session.active().unwrap().doc;
assert!(d.node(sub).is_none() && d.node(c).is_none());
assert_eq!(d.layers.len(), 1);
}
/// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows
/// holding selected art).
fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let size = |w: f32| {
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if (r.rect.width() - w).abs() < 0.01 && (r.rect.height() - w).abs() < 0.01 && r.fill != Color32::TRANSPARENT)).count()
};
(size(7.0), size(4.0))
}
#[test]
fn selection_squares_show_and_select_a_rows_art() {
let (mut app, [layer, sub, g, b, a, c]) = nested();
let ctx = egui::Context::default();
assert_eq!(squares(&mut app, &ctx), (0, 0));
app.session.execute("select.set", &json!({"ids": [g.0]})).unwrap();
assert_eq!(squares(&mut app, &ctx), (1, 2), "the group's square, and small ones on the sublayer and the layer");
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0);
let sel = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone();
click_with(&mut app, &ctx, square(4), egui::Modifiers::NONE);
assert_eq!(sel(&app), vec![a]);
click_with(&mut app, &ctx, square(5), egui::Modifiers::SHIFT);
assert_eq!(sel(&app), vec![a, c]);
click_with(&mut app, &ctx, square(1), egui::Modifiers::NONE);
assert_eq!(sel(&app), vec![a, g], "a sublayer's square selects its art");
click_with(&mut app, &ctx, square(0), egui::Modifiers::NONE);
assert_eq!(sel(&app), vec![c, a, g], "a layer's, its sublayers' too");
let _ = (layer, sub, b);
}
#[test]
fn eye_and_lock_toggle_nested_rows_and_drag_down_the_column() {
let (mut app, [_, sub, g, b, a, _]) = nested();
let ctx = egui::Context::default();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let (eye, lock) = (|i: usize| egui::pos2(12.0, circles[i].y), |i: usize| egui::pos2(37.0, circles[i].y));
let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone();
click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE);
assert!(!node(&app, b).visible, "an object in a group in a sublayer hides");
click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE);
click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE);
assert!(node(&app, g).locked, "a group locks");
click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE);
// Ctrl/Cmd-click a layer's eye: Outline, and back.
click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND);
assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: false, .. }));
click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND);
assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: true, .. }));
// Drag down the eye column from the group to the rectangle below: all three hide, one step.
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let before = undo(&app);
let steps = [
vec![egui::Event::PointerMoved(eye(2))],
vec![button(eye(2), true)],
vec![egui::Event::PointerMoved(eye(2) + vec2(0.0, 6.0))],
vec![egui::Event::PointerMoved(eye(3))],
vec![egui::Event::PointerMoved(eye(4))],
vec![button(eye(4), false)],
vec![],
];
for e in steps {
frame(&mut app, &ctx, e, false);
}
assert!(!node(&app, g).visible && !node(&app, b).visible && !node(&app, a).visible);
assert!(node(&app, sub).visible, "rows it didn't pass stay");
assert_eq!(undo(&app), before + 1, "one undo step");
assert!(!app.session.in_interaction());
app.session.execute("edit.undo", &json!({})).unwrap();
assert!(node(&app, g).visible && node(&app, b).visible && node(&app, a).visible);
}
#[test]
fn triangles_open_and_close_rows_and_alt_does_everything_inside() {
let (mut app, [layer, sub, ..]) = nested();
let ctx = egui::Context::default();
let count = |app: &mut VectorcraftApp| frame(app, &ctx, vec![], false).0.len();
assert_eq!(count(&mut app), 6);
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let triangle = |depth: f32, y: f32| egui::pos2(2.0 * COLUMN + 8.0 + depth * INDENT + 7.0, y);
// Close the sublayer: its group, the group's rectangle and its own rectangle go.
click_with(&mut app, &ctx, triangle(1.0, circles[1].y), egui::Modifiers::NONE);
assert_eq!(count(&mut app), 3);
// Alt-click the layer's: everything inside closes, and opens again.
click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT);
assert_eq!(count(&mut app), 1);
click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT);
assert_eq!(count(&mut app), 6);
// Show Layers Only (Panel Options).
app.ui.layers_panel.layers_only = true;
assert_eq!(count(&mut app), 2);
app.ui.layers_panel = PanelOptions { row_size: ROW_LARGE, ..Default::default() };
let (c, _) = frame(&mut app, &ctx, vec![], false);
assert!((c[1].y - c[0].y - ROW_LARGE).abs() < 0.5, "large rows");
let _ = (layer, sub);
}
#[test]
fn dragging_rows_moves_them_into_other_layers_and_alt_copies() {
let (mut app, [layer, sub, g, _, a, c]) = nested();
let ctx = egui::Context::default();
let l2 = NodeId(app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap()["id"].as_u64().unwrap());
// Rows: Top, Layer 1, Sub, g, b, a, c.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 7);
let name = |i: usize| egui::pos2(150.0, circles[i].y);
let parent = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.parent_of(id);
// Rectangle a onto the middle of the Top layer's row: into it.
drag(&mut app, &ctx, name(5), name(0), false);
assert_eq!(parent(&app, a), Some(l2));
// Rows: Top, a, Layer 1, Sub, g, b, c. The sublayer onto the upper edge of Layer 1's row:
// above it, a top-level layer now.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 7);
drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[2].y - ROW_MEDIUM * 0.4), false);
assert_eq!(parent(&app, sub), None);
let d = &app.session.active().unwrap().doc;
assert_eq!(d.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![layer, sub, l2]);
// Rows: Top, a, Sub, g, b, Layer 1, c. Alt-drag the group into Layer 1: a copy goes there,
// the group stays.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let count = app.session.active().unwrap().doc.node_count();
drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[5].y), true);
assert_eq!(parent(&app, g), Some(sub));
assert_eq!(app.session.active().unwrap().doc.node_count(), count + 2, "the group and its rectangle, copied");
let _ = (layer, c);
}
/// The centres of the bottom bar's buttons, right to left from under the right end of its top
/// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object.
fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<egui::Pos2> {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
let divider = Tokens::get(ctx).divider;
let corner = out
.shapes
.iter()
.rev()
.find_map(|c| match &c.shape {
egui::Shape::LineSegment { points, stroke } if stroke.color == divider => Some(points[1]),
_ => None,
})
.unwrap();
let step = 24.0 + ctx.global_style().spacing.item_spacing.x;
(0..5).map(|k| corner + vec2(-12.0 - k as f32 * step, 15.0)).collect()
}
/// How many texts reading `s` the frame after `events` paints.
fn texts(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, s: &str) -> usize {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == s)).count()
}
/// Drag the row at `a` to `b` and drop it there. Returns how many times its `name` was painted
/// while it hung over `b`.
fn drag_row(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, name: &str) -> usize {
frame(app, ctx, vec![egui::Event::PointerMoved(a)], false);
frame(app, ctx, vec![button(a, true)], false);
frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], false);
let shown = texts(app, ctx, vec![egui::Event::PointerMoved(b)], name);
frame(app, ctx, vec![button(b, false)], false);
frame(app, ctx, vec![], false);
shown
}
#[test]
fn dropping_rows_on_new_layer_duplicates_them() {
let (mut app, first, _) = two_rects();
let second = app.session.execute("layer.new", &json!({"name": "Top"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let layers = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers.iter().map(|l| l.id).collect::<Vec<_>>();
// Rows top down: Top, Layer 1 (expanded: its two rectangles).
let (c, _) = frame(&mut app, &ctx, vec![], false);
let buttons = bar_buttons(&mut app, &ctx);
// Layer 1 dropped on New Layer: a copy of it right above it.
let shown = drag_row(&mut app, &ctx, egui::pos2(150.0, c[1].y), buttons[1], "Layer 1");
assert_eq!(shown, 2, "the row and its ghost under the pointer");
let after = layers(&app);
assert_eq!(after.len(), 3);
assert_eq!((after[0], after[2]), (first, second));
assert_eq!(app.session.active().unwrap().doc.node(after[1]).unwrap().children().map(Vec::len), Some(2));
assert_eq!(texts(&mut app, &ctx, vec![], "Layer 1"), 1, "the ghost is gone after the drop");
// One undo step takes the copy away.
app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layers(&app), vec![first, second]);
// The other bottom-bar buttons are no drop targets.
drag_row(&mut app, &ctx, egui::pos2(150.0, c[0].y), buttons[2], "Top");
assert_eq!(layers(&app), vec![first, second]);
}
#[test]
fn the_selected_art_square_drags_the_selection_to_another_layer() {
let (mut app, [_, sub, g, _, a, c]) = nested();
let ctx = egui::Context::default();
app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0);
// From the sublayer's (small) square onto Layer 1's row.
drag(&mut app, &ctx, square(1), egui::pos2(150.0, circles[0].y), false);
let d = &app.session.active().unwrap().doc;
assert_eq!(d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>(), vec![c, sub, a, g]);
assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected");
}
#[test]
fn the_panel_menu_lists_every_operation() {
let (mut app, _) = nested();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu);
for label in [
"New Layer…",
"New Sublayer…",
"Duplicate Selection",
"Delete Selection",
"Options for Selection…",
"Make/Release Clipping Mask",
"Enter Isolation Mode",
"Locate Object",
"Merge Selected",
"Flatten Artwork",
"Collect in New Layer",
"Release to Layers (Sequence)",
"Release to Layers (Build)",
"Reverse Order",
"Template",
"Show All Layers",
"Preview All Layers",
"Unlock All Layers",
"Paste Remembers Layers",
"Panel Options…",
] {
assert!(texts.iter().any(|(t, _)| t.trim() == label), "{label} missing from {texts:?}");
}
}
/// With the dock collapsed, Layers shows in a floating flyout: its rows work there too.
#[test]
fn rows_work_in_the_floating_flyout() {
let (mut app, [_, sub, ..]) = nested();
app.ui.dock_collapsed = true;
app.ui.open_panel = Some("layers".into());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0));
let mut time = 0.0;
let mut frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.05;
let input = egui::RawInput { time: Some(time), events, screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| crate::dock::floating_panel(app, ui.ctx()));
out.textures_delta.clear();
let mut c: Vec<egui::Pos2> = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center),
_ => None,
})
.collect();
c.sort_by(|a, b| a.y.total_cmp(&b.y));
c
};
for _ in 0..3 {
frame(&mut app, vec![]);
}
let circles = frame(&mut app, vec![]);
assert_eq!(circles.len(), 6, "every row shows in the flyout");
assert!(circles.iter().all(|c| screen.contains(*c)));
let at = circles[1] - vec2(120.0, 0.0);
frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]);
frame(&mut app, vec![button(at, false)]);
frame(&mut app, vec![]);
assert_eq!(rows_of(&app), vec![sub]);
assert_eq!(app.session.active().unwrap().current_layer(), Some(sub));
}
/// A layer of 2,000 groups of 10 paths with every row open: one frame lays out only the rows
/// in view, and a row highlighted far below them is scrolled to.
#[test]
fn only_the_rows_in_view_are_laid_out() {
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{Rect, shapes};
let mut d = Document::new(1000.0, 1000.0);
let mut groups = vec![];
let mut needle = None;
for g in 0..2000 {
let kids = (0..10)
.map(|k| {
let r = Rect::new(k as f64, g as f64 * 0.1, k as f64 + 1.0, g as f64 * 0.1 + 1.0);
let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default());
if g == 0 && k == 0 {
n.name = Some("Needle".into());
needle = Some(n.id);
}
Arc::new(n)
})
.collect();
groups.push(Arc::new(Node::group(d.alloc_id(), kids)));
}
*Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.add_document(d, None);
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
// (shapes drawn, whether the needle's name is among them); a second passes per frame, so
// scrolling ends.
let mut time = 0.0;
let mut frame = |app: &mut VectorcraftApp| {
time += 1.0;
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Needle"));
(out.shapes.len(), found)
};
let (shapes, found) = frame(&mut app);
// 22,001 rows; about 30 fit.
assert!(shapes < 1000 && !found, "{shapes} shapes");
let needle = needle.unwrap();
app.session.execute("layer.highlight", &json!({"ids": [needle.0], "mode": "set"})).unwrap();
let found = (0..4).any(|_| frame(&mut app).1);
assert_eq!(app.session.active().unwrap().highlighted_rows(), [needle]);
assert!(found, "the highlighted row is scrolled into view");
}
}