From d8a6f8f3d872497bb676ae5a73688308cfabc16e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH] refactor(ui-egui): move panels/layers.rs items to panels/layers/{controls,model,rows,thumbs}.rs --- crates/ui-egui/src/panels/layers.rs | 1371 +----------------- crates/ui-egui/src/panels/layers/controls.rs | 297 ++++ crates/ui-egui/src/panels/layers/model.rs | 303 ++++ crates/ui-egui/src/panels/layers/rows.rs | 520 +++++++ crates/ui-egui/src/panels/layers/thumbs.rs | 247 ++++ 5 files changed, 1384 insertions(+), 1354 deletions(-) create mode 100644 crates/ui-egui/src/panels/layers/controls.rs create mode 100644 crates/ui-egui/src/panels/layers/model.rs create mode 100644 crates/ui-egui/src/panels/layers/rows.rs create mode 100644 crates/ui-egui/src/panels/layers/thumbs.rs diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0cd69aa02..9c118a329 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -52,6 +52,23 @@ use crate::thumb_worker::{Settle, ThumbWorker}; use crate::widgets::{Live, PanelDrag}; use crate::{VectorcraftApp, icons, widgets}; +mod controls; +mod model; +mod rows; +mod thumbs; + +use controls::{bottom_bar, filter_button}; +pub use controls::{expand, menu, set_filter}; +pub(crate) use model::shown_name; +pub use model::{Filter, ObjectKind, RowState}; +#[cfg(test)] +use model::{Row, list_rows, painted_name}; +use model::{push_rows, rows_of_doc}; +use rows::{drag_ghost, key, rename_id, resolve_click, row}; +#[cfg(test)] +use thumbs::THUMBS; +pub(crate) use thumbs::{ThumbSource, node_arc, real_thumb, thumb_options}; + /// Width of the eye and the lock columns. const COLUMN: f32 = 25.0; /// Indent per nesting level. @@ -126,370 +143,6 @@ struct ColumnDrag { ids: Vec, } -/// Stable id for the layers panel's per-name UI state. -fn key(name: &str) -> egui::Id { - egui::Id::unique(("layers-panel", name)) -} - -/// Temp-memory key for the layer rename editor. -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); - 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: &Arc, 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.thumbs, 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) = 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); -} - -/// 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 `` name is translated, anything else is -/// user data (an unnamed text object shows its text, which can look like ``; only an empty -/// one is called ``). -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() -} - -/// The name the panel shows for `n` in `lang`: its XML ID while Identify Objects By is XML ID, -/// otherwise [`painted_name`]. -pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang) -> String { - let xml_id = app.session.prefs.identify_objects_by == "xmlId"; - let label = n.identify_label(xml_id); - if xml_id { label } else { painted_name(n, &label, lang) } -} - -/// 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, - /// 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, - /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. - guide: Option, -} - -/// An object kind under the Filter's By Object (`ui.layersFilter`'s `objects`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ObjectKind { - Paths, - Shapes, - Text, - Images, - Effects, - Groups, - LinkedFiles, - Guides, -} - -impl ObjectKind { - /// Every kind with its parameter name, in the popup's order. - pub const ALL: [(Self, &'static str); 8] = [ - (Self::Paths, "paths"), - (Self::Shapes, "shapes"), - (Self::Text, "text"), - (Self::Images, "images"), - (Self::Effects, "effects"), - (Self::Groups, "groups"), - (Self::LinkedFiles, "linkedFiles"), - (Self::Guides, "guides"), - ]; - - fn label(self) -> &'static str { - match self { - Self::Paths => tl!("Paths"), - Self::Shapes => tl!("Shapes"), - Self::Text => tl!("Text"), - Self::Images => tl!("Images"), - Self::Effects => tl!("Effects"), - Self::Groups => tl!("Groups"), - Self::LinkedFiles => tl!("Linked Files"), - Self::Guides => tl!("Guides"), - } - } - - /// Whether `n`'s row is of this kind. Paths take plain paths, the paths whose corners Live - /// Corners cut and compound paths; Shapes take live rectangles, ellipses, polygons and lines; - /// Paths and Shapes leave out paths made into guides. Effects take a row with an effect, shown - /// or hidden, on itself or on one of its fills or strokes. Linked Files take linked images and - /// placed documents. - fn takes(self, n: &Node) -> bool { - match (self, &n.kind) { - (Self::Guides, NodeKind::Path { guide, .. }) => *guide, - (Self::Paths | Self::Shapes, NodeKind::Path { guide: true, .. }) => false, - (Self::Paths, NodeKind::Path { live, .. }) => live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. })), - (Self::Shapes, NodeKind::Path { live, .. }) => live.as_deref().is_some_and(|l| !matches!(l, LiveShape::Path { .. })), - (Self::Paths, NodeKind::Compound { .. }) - | (Self::Text, NodeKind::Text(_)) - | (Self::Images, NodeKind::Image(_)) - | (Self::Groups, NodeKind::Group { .. }) - | (Self::LinkedFiles, NodeKind::PlacedDocument(_)) => true, - (Self::LinkedFiles, NodeKind::Image(im)) => im.link.is_some(), - (Self::Effects, _) => !n.appearance.effects.is_empty() || n.appearance.items.iter().any(|i| !i.effects().is_empty()), - _ => false, - } - } -} - -/// A row state under the Filter's By Layer (`ui.layersFilter`'s `layers`): the row's own lock or -/// eye, as its lock and eye columns show them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RowState { - Locked, - Hidden, -} - -impl RowState { - /// Every state with its parameter name, in the popup's order. - pub const ALL: [(Self, &'static str); 2] = [(Self::Locked, "locked"), (Self::Hidden, "hidden")]; - - fn label(self) -> &'static str { - match self { - Self::Locked => tl!("Locked"), - Self::Hidden => tl!("Hidden"), - } - } - - fn takes(self, n: &Node) -> bool { - match self { - Self::Locked => n.locked, - Self::Hidden => !n.visible, - } - } -} - -/// The Layers panel's Filter (`ui.layersFilter`): the object kinds ticked under By Object and the -/// row states ticked under By Layer, each in the popup's order. A section with nothing ticked takes -/// every row. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Filter { - pub objects: Vec, - pub layers: Vec, -} - -impl Filter { - /// Whether anything is ticked. - pub fn is_on(&self) -> bool { - !self.objects.is_empty() || !self.layers.is_empty() - } - - /// Whether `n`'s row is of a ticked kind and in a ticked state. - fn keeps(&self, n: &Node) -> bool { - (self.objects.is_empty() || self.objects.iter().any(|k| k.takes(n))) && (self.layers.is_empty() || self.layers.iter().any(|s| s.takes(n))) - } - - /// Whether the ruler guide rows stay: with no filter on, or with Guides ticked and no row state - /// ticked. - fn keeps_guides(&self) -> bool { - !self.is_on() || (self.objects.contains(&ObjectKind::Guides) && self.layers.is_empty()) - } -} - -/// The parameter names of the ones of `all` in `on`, in `all`'s order. -fn names(all: &[(T, &'static str)], on: &[T]) -> Vec<&'static str> { - all.iter().filter(|(t, _)| on.contains(t)).map(|(_, name)| *name).collect() -} - -/// The parameter names of `on` with `t` added, or taken out when it is in `on`. -fn toggled(all: &[(T, &'static str)], on: &[T], t: T) -> Vec<&'static str> { - all.iter().filter(|(x, _)| if *x == t { !on.contains(x) } else { on.contains(x) }).map(|(_, name)| *name).collect() -} - -/// The list `key` of `p` read by the parameter names of `all`, in `all`'s order: `None` when `p` -/// leaves it out or sets it to null, and an error when it is anything other than an array of -/// those names. -fn parse_names(p: &Value, key: &str, all: &[(T, &'static str)]) -> Result>, String> { - let given = match p.get(key) { - None | Some(Value::Null) => return Ok(None), - Some(v) => v - .as_array() - .and_then(|a| a.iter().map(|name| all.iter().find(|(_, n)| Some(*n) == name.as_str()).map(|(t, _)| *t)).collect::>>()), - }; - let Some(given) = given else { - let names: Vec<&str> = all.iter().map(|(_, n)| *n).collect(); - return Err(format!("`{key}` must be an array of these names: {}", names.join(", "))); - }; - Ok(Some(all.iter().map(|(t, _)| *t).filter(|t| given.contains(t)).collect())) -} - -/// 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, - filter: Filter, -} - -/// 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 or a filter shows every row whose name -/// contains `query` (lowercase) and that `filter` keeps, and every row holding one. -fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { - 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, filter, doc), &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, - depth: usize, - clip_path: bool, - (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), - out: &mut Vec, -) { - let (open_rows, opts, query, filter, doc) = 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 searching = !query.is_empty(); - let narrowing = searching || filter.is_on(); - // A layer's ruler guides are rows in it, above its art (not while searching, nor while the - // filter leaves them out). - let guides: Vec = - if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; - let opens = opens(n, opts) || !guides.is_empty(); - let open = opens && (narrowing || 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(), guide: None }); - if open { - for i in guides.into_iter().rev() { - out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); - } - } - 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 or filtering, a row stays for a match of its own (its name holds the search text - // and the filter keeps it) or for a row it keeps inside it. - if narrowing && out.len() == at + 1 && !(filter.keeps(n) && (!searching || 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, the filter or Show Layers Only changed. -fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str, filter: &Filter) -> Arc> { - let now = RowsKey { - uid: st.uid, - revision: st.revision, - open: st.layers_open.generation(), - layers_only: opts.layers_only, - query: query.to_string(), - filter: filter.clone(), - }; - let id = key("rows"); - if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc>)>(id)) - && was == now - { - return rows; - } - let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query, filter)); - ui.data_mut(|d| d.insert_temp(id, (now, rows.clone()))); - rows -} - /// Everything one frame of the panel reads. struct View<'a> { doc: &'a Arc, @@ -773,995 +426,5 @@ fn see_selection(app: &VectorcraftApp, ui: &Ui, before: Option<(u64, Option) { - 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::().is_some() - && let Some(d) = trash.dnd_release_payload::() - && 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::() - && let LayersDrag::Rows(ids) = &*d - { - actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); - } - 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); - } - } - 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 = 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}))); - } -} - -/// 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, - LayersDrag::Guides(_) => false, - }, - _ => false, - } -} - -/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection -/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it -/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. -fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { - let (t, h) = (&view.t, view.h); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - let Some(g) = view.doc.guides.get(i) else { return }; - let selected = view.guides.contains(&i); - let editable = view.doc.guide_editable(g); - if editable && 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)); - let mut x = r.left() + 2.0 * COLUMN + 2.0; - colour_bar(ui, &item.node, r, x, item.colour); - x += 6.0 + item.depth as f32 * INDENT + 16.0; - // The guide mark: a short line across or down, in the guides' colour. - let c = egui::pos2(x + 8.0, r.center().y); - let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; - ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); - x += 22.0; - let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); - let color = if item.dim { t.text_dim } else { t.text }; - ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); - 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)); - if selected { - let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); - ui.painter().rect_filled(q, 0.0, item.colour); - ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); - } - if editable && resp.clicked() { - let m = ui.input(|i| i.modifiers); - out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); - } - // Dragged: the selected guides when this is one of them, else this one, to another layer's row. - if editable && resp.drag_started() { - let guides = if selected { view.guides.clone() } else { vec![i] }; - egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); - } - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } - // Guides dropped here go to this guide's layer. - if let Some(drag) = resp.dnd_hover_payload::() - && let LayersDrag::Guides(guides) = &*drag - { - guide_drop(ui, view, guides, item.node.id, r, &resp, out); - } -} - -/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the -/// release puts them on that layer (`guide.setLayer`, one undo step). -fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { - ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside); - if resp.dnd_release_payload::().is_some() { - out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0}))); - } -} - -/// The widgets of row `item`. -fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { - if let Some(i) = item.guide { - return guide_row(ui, view, item, i, 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.scope_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, &item.node, r, x); - x = th.right() + 4.0; - th - }); - // Name (Preferences › Units › Identify Objects By: Object Name or XML ID). - let xml_id = view.identify_xml_id; - let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.identify_label(xml_id) }; - 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 field = rename_id().with("field"); - let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); - let te = child.add(egui::TextEdit::singleline(&mut buf).id(field).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 { - // Shift+Enter renames it and keeps the field open. - if kept && buf != name { - out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); - } - 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 ("", "") are translated where painted; the stored name stays English. - // XML IDs stay as ids (not translated). - let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { - tl!("").to_string() - } else if xml_id { - name.clone() - } else { - painted_name(n, &n.display_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.extras().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.scope_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.identify_label(view.identify_xml_id)))); - } else { - out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); - } - } - // Row menu: Collect for Export on this row (or every highlighted row when this is one of them). - let collect_ids: Vec = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] }; - resp.context_menu(|ui| { - collect_for_export_menu(ui, true, |multiple| { - out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple }))); - }); - }); - // 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", - }; - // Show All and Unlock All take no rows. - let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) }; - (cmd.into(), params) -} - -/// Ctrl/Cmd+Alt-clicking row `n`'s eye runs Outline Others, which puts every top-level layer but the -/// one holding the row in Outline and puts that one in Preview. When all the other layers are in -/// Outline already, it runs Preview All Layers. Each is one undo step. -fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) { - let own = doc.layer_of(n.id); - if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { - ("layer.outlineOthers".into(), json!({ "ids": [n.id.0] })) - } else { - // Preview All takes no rows. - ("layer.previewAll".into(), json!({})) - } -} - -/// 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.scope_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.scope_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 && m.command { - out.actions.push(outline_others_action(view.doc, n)); - } else 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::() else { return }; - let doc = view.doc; - let moving: Vec = match &*drag { - LayersDrag::Rows(ids) => ids.clone(), - LayersDrag::Art => view.sel.iter().copied().collect(), - LayersDrag::Guides(guides) => { - // Guides go to the layer of the row (the row's own, for a layer). - if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } { - guide_drop(ui, view, guides, layer, r, resp, out); - } - return; - } - }; - // 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(_) | LayersDrag::Guides(_) => 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::().is_some() { - let copy = ui.input(|i| i.modifiers.alt); - let ids: Vec = 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(), - LayersDrag::Guides(_) => return, - }; - 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.scope_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::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); - if let Some(d) = resp.dnd_release_payload::() - && 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() -} - -/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in -/// progress (the thumbnails shown then stay until it ends). -pub(crate) struct ThumbSource<'a> { - pub uid: u64, - pub revision: u64, - pub doc: &'a Arc, - pub busy: bool, -} - -impl<'a> ThumbSource<'a> { - /// The thumbnails of the document `st` holds (`doc`, its document). - pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc) -> Self { - Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() } - } -} - -/// Node `id` of `doc`, as the `Arc` its parent holds. -pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option> { - let path = doc.index_path(id)?; - let (first, rest) = path.split_first()?; - let mut cur = doc.layers.get(*first)?; - for &i in rest { - cur = cur.children()?.get(i)?; - } - Some(cur.clone()) -} - -/// A row thumbnail's image. -struct ThumbEntry { - /// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays - /// and no other node can come to have its address. - node: Weak, - px: u32, - /// None: the node draws nothing. - tex: Option, - /// How long it took to render (ms). - ms: f64, - /// The pass it was last drawn in. - used: u64, -} - -/// A row thumbnail job: the row (document uid, node), the node and the size rendered. -type ThumbJob = ((u64, NodeId), Weak, u32); - -#[derive(Default)] -struct Thumbs { - entries: HashMap<(u64, NodeId), ThumbEntry>, - /// What the worker is rendering, by row. - pending: HashMap<(u64, NodeId), (Weak, u32)>, - worker: Option>, - worker_started: bool, - settle: Settle, - /// (pass, ms, count) rendered on the UI thread in that pass. - spent: (u64, f64, u32), - /// Thumbnails rendered on the UI thread (tests count them). - renders: u64, -} - -thread_local! { - static THUMBS: crate::graphics::TexCache = crate::graphics::TexCache::default(); -} - -/// The most row thumbnails kept: past it the ones drawn longest ago go. -const MAX_THUMBS: usize = 512; -/// Rendering a thumbnail that took longer than this (ms) again goes to the worker. -const LIGHT_THUMB_MS: f64 = 4.0; -/// A node with more objects than this inside has its first thumbnail made on the worker. -const LIGHT_THUMB_NODES: usize = 200; -/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the -/// rest go to the worker, or wait for the next pass where there is none. -const THUMB_BUDGET_MS: f64 = 8.0; -const THUMB_BUDGET_COUNT: u32 = 16; - -/// How row thumbnails render: images far smaller than they are sample their mip levels. -pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { - vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() } -} - -/// Has `n` at most `limit` objects inside it (counting stops there)? -fn small_node(n: &Node, limit: usize) -> bool { - fn count(n: &Node, left: &mut usize) -> bool { - for c in n.children().into_iter().flatten() { - if *left == 0 { - return false; - } - *left -= 1; - if !count(c, left) { - return false; - } - } - true - } - let mut left = limit; - count(n, &mut left) -} - -impl Thumbs { - /// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`. - fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) { - let color = img.map(crate::canvas::color_image); - let used = ctx.cumulative_pass_nr(); - let name = format!("layer-thumb-{}", key.1.0); - match self.entries.get_mut(&key) { - Some(e) => { - match (color, &mut e.tex) { - (Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR), - (Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)), - (None, tex) => *tex = None, - } - (e.node, e.px, e.ms, e.used) = (node, px, ms, used); - } - None => { - let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR)); - self.entries.insert(key, ThumbEntry { node, px, tex, ms, used }); - if self.entries.len() > MAX_THUMBS { - // Keep the three quarters drawn last. - let mut by_use: Vec = self.entries.values().map(|e| e.used).collect(); - by_use.sort_unstable(); - let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0); - self.entries.retain(|_, e| e.used >= cut); - } - } - } - } - - /// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the - /// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until - /// the document settles. - fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc, px: u32) -> Option { - if !self.worker_started { - self.worker_started = true; - self.worker = ThumbWorker::spawn(ctx, "thumbnails"); - } - let done = match self.worker.as_ref().map(ThumbWorker::poll) { - Some(Some(done)) => done, - Some(None) => { - // The thread is gone: render here from now on. - self.worker = None; - self.pending.clear(); - vec![] - } - None => vec![], - }; - for d in done { - let (key, node, px) = d.key; - if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) { - self.pending.remove(&key); - } - self.upload(ctx, key, node, px, d.img.as_ref(), d.ms); - } - let key = (src.uid, n.id); - let pass = ctx.cumulative_pass_nr(); - let (fresh, light) = match self.entries.get_mut(&key) { - Some(e) => { - e.used = pass; - (std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS)) - } - None => (false, None), - }; - let shown = self.entries.get(&key).and_then(|e| e.tex.clone()); - if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) { - return shown; - } - if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) { - return shown; - } - if self.spent.0 != pass { - self.spent = (pass, 0.0, 0); - } - let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; - let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget; - if !light && let Some(w) = &self.worker { - let (doc, node) = (src.doc.clone(), n.clone()); - let job: ThumbJob = (key, Arc::downgrade(n), px); - if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) { - self.pending.insert(key, (Arc::downgrade(n), px)); - return shown; - } - self.worker = None; - self.pending.clear(); - } - if !in_budget { - ctx.request_repaint(); - return shown; - } - thread_local! { - static RENDERER: std::cell::RefCell = std::cell::RefCell::new(vectorcraft_render::Renderer::new()); - } - let t0 = crate::now_ms(); - let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options())); - let ms = crate::now_ms() - t0; - self.spent.1 += ms; - self.spent.2 += 1; - self.renders += 1; - self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms); - self.entries.get(&key).and_then(|e| e.tex.clone()) - } -} - -/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc` -/// allocation, so the address is a free change detector). Only rendered for visible rows; see -/// [`Thumbs::texture`] for when. -pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rect) -> bool { - if !ui.is_rect_visible(r) { - return true; - } - let ppp = ui.ctx().pixels_per_point(); - let px = ((r.width() * ppp).round() as u32).max(8); - match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) { - 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 = 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 = 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> = 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)), - // Sentinel: rendered as the Save Selection submenu (the Select menu's saved-selection items). - Some(("Save Selection", "", json!({}), true, 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)), - // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). - Some(("Collect for Export", "", json!({}), !targets.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(("Collect for Export", _, _, enabled, _)) => { - collect_for_export_menu(ui, enabled, |multiple| { - let mut p = json!({ "multiple": multiple }); - if !targets.is_empty() { - p["ids"] = json!(targets.iter().map(|id| id.0).collect::>()); - } - app.run("assets.add", p).ok(); - }); - } - Some(("Save Selection", "", ..)) => save_selection_menu(app, ui), - Some((label, cmd, p, enabled, checked)) => { - if widgets::menu_item(ui, label, enabled, checked) { - let selected = last_selected(app); - app.run(cmd, p).ok(); - see_selection(app, ui, selected); - } - } - } - } -} - -/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`). -fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) { - ui.add_enabled_ui(enabled, |ui| { - // English labels match the rest of this panel menu (and Object › Collect for Export). - ui.menu_button("Collect for Export", |ui| { - if widgets::menu_item(ui, "As Single Asset", true, false) { - run(false); - ui.close(); - } - if widgets::menu_item(ui, "As Multiple Assets", true, false) { - run(true); - ui.close(); - } - }); - }); -} - -/// Save Selection › Save Selection…, Update Selection and Edit Selection…, enabled and run as in -/// the Select menu, then the active document's saved selections: choosing one recalls it. -fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) { - ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| { - for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] { - if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { - crate::menus::invoke(app, id, json!({})); - ui.close(); - } - } - let names: Vec = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); - if !names.is_empty() { - ui.separator(); - } - for name in names { - if widgets::menu_item_name(ui, &name, true, false) { - let selected = last_selected(app); - app.run("select.recall", json!({ "name": name })).ok(); - see_selection(app, ui, selected); - ui.close(); - } - } - }); -} - -/// `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 { - 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 = 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() })) -} - -/// `ui.layersFilter {objects?, layers?}`: set the Layers panel's Filter, `objects` to the object -/// kinds ticked under By Object and `layers` to the row states ticked under By Layer, by parameter -/// name ([`ObjectKind::ALL`], [`RowState::ALL`]). A key left out or set to null keeps its list; any -/// other value that is not an array of those names is an error, and then nothing changes. -/// → {objects, layers} -pub fn set_filter(app: &mut VectorcraftApp, p: &Value) -> Result { - let objects = parse_names(p, "objects", &ObjectKind::ALL)?; - let layers = parse_names(p, "layers", &RowState::ALL)?; - let f = &mut app.ui.layers_filter; - if let Some(objects) = objects { - f.objects = objects; - } - if let Some(layers) = layers { - f.layers = layers; - } - Ok(json!({"objects": names(&ObjectKind::ALL, &f.objects), "layers": names(&RowState::ALL, &f.layers)})) -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs new file mode 100644 index 000000000..a8b0b3abe --- /dev/null +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -0,0 +1,297 @@ +use super::model::{ObjectKind, RowState, names, parse_names, toggled}; +use super::*; + +/// The Filter button, lit while a filter is on, and its popup: Clear All, the object kinds under +/// By Object and the row states under By Layer, each ticked or not. A tick, or Clear All, runs +/// `ui.layersFilter`; the popup stays open until a click outside it or Escape. +pub(super) fn filter_button(app: &mut VectorcraftApp, ui: &mut Ui) { + let f = app.ui.layers_filter.clone(); + let button = widgets::icon_button(ui, "dc-filter", tl!("Filter"), f.is_on(), 24.0); + let mut set = None; + egui::Popup::menu(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.horizontal(|ui| { + widgets::subheader(ui, tl!("Filter")); + ui.add_space(12.0); + if ui.add_enabled(f.is_on(), egui::Button::new(egui::RichText::new(tl!("Clear All")).size(12.5)).frame(false)).clicked() { + set = Some(json!({"objects": [], "layers": []})); + } + }); + widgets::subheader(ui, tl!("By Object")); + for (k, _) in ObjectKind::ALL { + if widgets::check(ui, k.label(), f.objects.contains(&k), true) { + set = Some(json!({"objects": toggled(&ObjectKind::ALL, &f.objects, k)})); + } + } + widgets::subheader(ui, tl!("By Layer")); + for (s, _) in RowState::ALL { + if widgets::check(ui, s.label(), f.layers.contains(&s), true) { + set = Some(json!({"layers": toggled(&RowState::ALL, &f.layers, s)})); + } + } + }); + if let Some(p) = set { + app.run("ui.layersFilter", p).ok(); + } +} + +/// The bottom bar: the layer count and the panel's buttons. +pub(super) 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::().is_some() + && let Some(d) = trash.dnd_release_payload::() + && 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::() + && let LayersDrag::Rows(ids) = &*d + { + actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); + } + 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); + } + } + 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!({}))); + } +} + +/// 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 = 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 = 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> = 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)), + // Sentinel: rendered as the Save Selection submenu (the Select menu's saved-selection items). + Some(("Save Selection", "", json!({}), true, 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)), + // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). + Some(("Collect for Export", "", json!({}), !targets.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(("Collect for Export", _, _, enabled, _)) => { + collect_for_export_menu(ui, enabled, |multiple| { + let mut p = json!({ "multiple": multiple }); + if !targets.is_empty() { + p["ids"] = json!(targets.iter().map(|id| id.0).collect::>()); + } + app.run("assets.add", p).ok(); + }); + } + Some(("Save Selection", "", ..)) => save_selection_menu(app, ui), + Some((label, cmd, p, enabled, checked)) => { + if widgets::menu_item(ui, label, enabled, checked) { + let selected = last_selected(app); + app.run(cmd, p).ok(); + see_selection(app, ui, selected); + } + } + } + } +} + +/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`). +pub(super) fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) { + ui.add_enabled_ui(enabled, |ui| { + // English labels match the rest of this panel menu (and Object › Collect for Export). + ui.menu_button("Collect for Export", |ui| { + if widgets::menu_item(ui, "As Single Asset", true, false) { + run(false); + ui.close(); + } + if widgets::menu_item(ui, "As Multiple Assets", true, false) { + run(true); + ui.close(); + } + }); + }); +} + +/// Save Selection › Save Selection…, Update Selection and Edit Selection…, enabled and run as in +/// the Select menu, then the active document's saved selections: choosing one recalls it. +fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.menu_button(format!(" {}", tl!("Save Selection")), |ui| { + for (label, id) in [("Save Selection…", "select.save"), ("Update Selection", "select.update"), ("Edit Selection…", "select.editSaved")] { + if widgets::menu_item(ui, label, crate::menus::enabled(app, id), false) { + crate::menus::invoke(app, id, json!({})); + ui.close(); + } + } + let names: Vec = app.session.active().map(|d| d.doc.saved_selections.iter().map(|s| s.name.clone()).collect()).unwrap_or_default(); + if !names.is_empty() { + ui.separator(); + } + for name in names { + if widgets::menu_item_name(ui, &name, true, false) { + let selected = last_selected(app); + app.run("select.recall", json!({ "name": name })).ok(); + see_selection(app, ui, selected); + ui.close(); + } + } + }); +} + +/// `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 { + 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 = 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() })) +} + +/// `ui.layersFilter {objects?, layers?}`: set the Layers panel's Filter, `objects` to the object +/// kinds ticked under By Object and `layers` to the row states ticked under By Layer, by parameter +/// name ([`ObjectKind::ALL`], [`RowState::ALL`]). A key left out or set to null keeps its list; any +/// other value that is not an array of those names is an error, and then nothing changes. +/// → {objects, layers} +pub fn set_filter(app: &mut VectorcraftApp, p: &Value) -> Result { + let objects = parse_names(p, "objects", &ObjectKind::ALL)?; + let layers = parse_names(p, "layers", &RowState::ALL)?; + let f = &mut app.ui.layers_filter; + if let Some(objects) = objects { + f.objects = objects; + } + if let Some(layers) = layers { + f.layers = layers; + } + Ok(json!({"objects": names(&ObjectKind::ALL, &f.objects), "layers": names(&RowState::ALL, &f.layers)})) +} diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs new file mode 100644 index 000000000..642aec542 --- /dev/null +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -0,0 +1,303 @@ +use super::rows::key; +use super::*; + +/// Where a dragged row goes relative to the row it is dropped on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Place { + Above, + Below, + Inside, +} + +impl Place { + pub(super) 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 `` name is translated, anything else is +/// user data (an unnamed text object shows its text, which can look like ``; only an empty +/// one is called ``). +pub(super) 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() +} + +/// The name the panel shows for `n` in `lang`: its XML ID while Identify Objects By is XML ID, +/// otherwise [`painted_name`]. +pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang) -> String { + let xml_id = app.session.prefs.identify_objects_by == "xmlId"; + let label = n.identify_label(xml_id); + if xml_id { label } else { painted_name(n, &label, lang) } +} + +/// Whether `n` gets rows for what it holds (a compound path's paths are one object). +pub(super) 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)] +pub(super) struct Row { + pub(super) node: Arc, + /// Nesting level (0: a top-level layer). + pub(super) depth: usize, + /// A clipping path: its group or layer clips. + pub(super) clip_path: bool, + /// It holds rows ([`opens`]), and shows them. + pub(super) opens: bool, + pub(super) open: bool, + /// The colour of its layer (its own, for a layer). + pub(super) colour: Color32, + /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. + pub(super) dim: bool, + /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. + pub(super) guide: Option, +} + +/// An object kind under the Filter's By Object (`ui.layersFilter`'s `objects`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectKind { + Paths, + Shapes, + Text, + Images, + Effects, + Groups, + LinkedFiles, + Guides, +} + +impl ObjectKind { + /// Every kind with its parameter name, in the popup's order. + pub const ALL: [(Self, &'static str); 8] = [ + (Self::Paths, "paths"), + (Self::Shapes, "shapes"), + (Self::Text, "text"), + (Self::Images, "images"), + (Self::Effects, "effects"), + (Self::Groups, "groups"), + (Self::LinkedFiles, "linkedFiles"), + (Self::Guides, "guides"), + ]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Paths => tl!("Paths"), + Self::Shapes => tl!("Shapes"), + Self::Text => tl!("Text"), + Self::Images => tl!("Images"), + Self::Effects => tl!("Effects"), + Self::Groups => tl!("Groups"), + Self::LinkedFiles => tl!("Linked Files"), + Self::Guides => tl!("Guides"), + } + } + + /// Whether `n`'s row is of this kind. Paths take plain paths, the paths whose corners Live + /// Corners cut and compound paths; Shapes take live rectangles, ellipses, polygons and lines; + /// Paths and Shapes leave out paths made into guides. Effects take a row with an effect, shown + /// or hidden, on itself or on one of its fills or strokes. Linked Files take linked images and + /// placed documents. + fn takes(self, n: &Node) -> bool { + match (self, &n.kind) { + (Self::Guides, NodeKind::Path { guide, .. }) => *guide, + (Self::Paths | Self::Shapes, NodeKind::Path { guide: true, .. }) => false, + (Self::Paths, NodeKind::Path { live, .. }) => live.as_deref().is_none_or(|l| matches!(l, LiveShape::Path { .. })), + (Self::Shapes, NodeKind::Path { live, .. }) => live.as_deref().is_some_and(|l| !matches!(l, LiveShape::Path { .. })), + (Self::Paths, NodeKind::Compound { .. }) + | (Self::Text, NodeKind::Text(_)) + | (Self::Images, NodeKind::Image(_)) + | (Self::Groups, NodeKind::Group { .. }) + | (Self::LinkedFiles, NodeKind::PlacedDocument(_)) => true, + (Self::LinkedFiles, NodeKind::Image(im)) => im.link.is_some(), + (Self::Effects, _) => !n.appearance.effects.is_empty() || n.appearance.items.iter().any(|i| !i.effects().is_empty()), + _ => false, + } + } +} + +/// A row state under the Filter's By Layer (`ui.layersFilter`'s `layers`): the row's own lock or +/// eye, as its lock and eye columns show them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RowState { + Locked, + Hidden, +} + +impl RowState { + /// Every state with its parameter name, in the popup's order. + pub const ALL: [(Self, &'static str); 2] = [(Self::Locked, "locked"), (Self::Hidden, "hidden")]; + + pub(super) fn label(self) -> &'static str { + match self { + Self::Locked => tl!("Locked"), + Self::Hidden => tl!("Hidden"), + } + } + + fn takes(self, n: &Node) -> bool { + match self { + Self::Locked => n.locked, + Self::Hidden => !n.visible, + } + } +} + +/// The Layers panel's Filter (`ui.layersFilter`): the object kinds ticked under By Object and the +/// row states ticked under By Layer, each in the popup's order. A section with nothing ticked takes +/// every row. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Filter { + pub objects: Vec, + pub layers: Vec, +} + +impl Filter { + /// Whether anything is ticked. + pub fn is_on(&self) -> bool { + !self.objects.is_empty() || !self.layers.is_empty() + } + + /// Whether `n`'s row is of a ticked kind and in a ticked state. + fn keeps(&self, n: &Node) -> bool { + (self.objects.is_empty() || self.objects.iter().any(|k| k.takes(n))) && (self.layers.is_empty() || self.layers.iter().any(|s| s.takes(n))) + } + + /// Whether the ruler guide rows stay: with no filter on, or with Guides ticked and no row state + /// ticked. + fn keeps_guides(&self) -> bool { + !self.is_on() || (self.objects.contains(&ObjectKind::Guides) && self.layers.is_empty()) + } +} + +/// The parameter names of the ones of `all` in `on`, in `all`'s order. +pub(super) fn names(all: &[(T, &'static str)], on: &[T]) -> Vec<&'static str> { + all.iter().filter(|(t, _)| on.contains(t)).map(|(_, name)| *name).collect() +} + +/// The parameter names of `on` with `t` added, or taken out when it is in `on`. +pub(super) fn toggled(all: &[(T, &'static str)], on: &[T], t: T) -> Vec<&'static str> { + all.iter().filter(|(x, _)| if *x == t { !on.contains(x) } else { on.contains(x) }).map(|(_, name)| *name).collect() +} + +/// The list `key` of `p` read by the parameter names of `all`, in `all`'s order: `None` when `p` +/// leaves it out or sets it to null, and an error when it is anything other than an array of +/// those names. +pub(super) fn parse_names(p: &Value, key: &str, all: &[(T, &'static str)]) -> Result>, String> { + let given = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(v) => v + .as_array() + .and_then(|a| a.iter().map(|name| all.iter().find(|(_, n)| Some(*n) == name.as_str()).map(|(t, _)| *t)).collect::>>()), + }; + let Some(given) = given else { + let names: Vec<&str> = all.iter().map(|(_, n)| *n).collect(); + return Err(format!("`{key}` must be an array of these names: {}", names.join(", "))); + }; + Ok(Some(all.iter().map(|(t, _)| *t).filter(|t| given.contains(t)).collect())) +} + +/// 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, + filter: Filter, +} + +/// 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 or a filter shows every row whose name +/// contains `query` (lowercase) and that `filter` keeps, and every row holding one. +pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { + 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, filter, doc), &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). +pub(super) fn push_rows( + n: &Arc, + depth: usize, + clip_path: bool, + (colour, shown): (Color32, bool), + cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), + out: &mut Vec, +) { + let (open_rows, opts, query, filter, doc) = 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 searching = !query.is_empty(); + let narrowing = searching || filter.is_on(); + // A layer's ruler guides are rows in it, above its art (not while searching, nor while the + // filter leaves them out). + let guides: Vec = + if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + let opens = opens(n, opts) || !guides.is_empty(); + let open = opens && (narrowing || 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(), guide: None }); + if open { + for i in guides.into_iter().rev() { + out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); + } + } + 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 or filtering, a row stays for a match of its own (its name holds the search text + // and the filter keeps it) or for a row it keeps inside it. + if narrowing && out.len() == at + 1 && !(filter.keeps(n) && (!searching || 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, the filter or Show Layers Only changed. +pub(super) fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str, filter: &Filter) -> Arc> { + let now = RowsKey { + uid: st.uid, + revision: st.revision, + open: st.layers_open.generation(), + layers_only: opts.layers_only, + query: query.to_string(), + filter: filter.clone(), + }; + let id = key("rows"); + if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc>)>(id)) + && was == now + { + return rows; + } + let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query, filter)); + ui.data_mut(|d| d.insert_temp(id, (now, rows.clone()))); + rows +} diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs new file mode 100644 index 000000000..7c92566aa --- /dev/null +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -0,0 +1,520 @@ +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::*; + +/// Stable id for the layers panel's per-name UI state. +pub(super) fn key(name: &str) -> egui::Id { + egui::Id::unique(("layers-panel", name)) +} + +/// Temp-memory key for the layer rename editor. +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); + 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: &Arc, 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.thumbs, 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. +pub(super) 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) = 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); +} + +/// 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). +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}))); + } +} + +/// 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, + LayersDrag::Guides(_) => false, + }, + _ => false, + } +} + +/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection +/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it +/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged. +fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { + let (t, h) = (&view.t, view.h); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + let Some(g) = view.doc.guides.get(i) else { return }; + let selected = view.guides.contains(&i); + let editable = view.doc.guide_editable(g); + if editable && 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)); + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(ui, &item.node, r, x, item.colour); + x += 6.0 + item.depth as f32 * INDENT + 16.0; + // The guide mark: a short line across or down, in the guides' colour. + let c = egui::pos2(x + 8.0, r.center().y); + let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) }; + ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent)); + x += 22.0; + let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") }); + let color = if item.dim { t.text_dim } else { t.text }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); + 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)); + if selected { + let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, item.colour); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } + if editable && resp.clicked() { + let m = ui.input(|i| i.modifiers); + out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); + } + // Dragged: the selected guides when this is one of them, else this one, to another layer's row. + if editable && resp.drag_started() { + let guides = if selected { view.guides.clone() } else { vec![i] }; + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + // Guides dropped here go to this guide's layer. + if let Some(drag) = resp.dnd_hover_payload::() + && let LayersDrag::Guides(guides) = &*drag + { + guide_drop(ui, view, guides, item.node.id, r, &resp, out); + } +} + +/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the +/// release puts them on that layer (`guide.setLayer`, one undo step). +fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside); + if resp.dnd_release_payload::().is_some() { + out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0}))); + } +} + +/// The widgets of row `item`. +pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + if let Some(i) = item.guide { + return guide_row(ui, view, item, i, 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.scope_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, &item.node, r, x); + x = th.right() + 4.0; + th + }); + // Name (Preferences › Units › Identify Objects By: Object Name or XML ID). + let xml_id = view.identify_xml_id; + let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.identify_label(xml_id) }; + 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 field = rename_id().with("field"); + let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); + let te = child.add(egui::TextEdit::singleline(&mut buf).id(field).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 { + // Shift+Enter renames it and keeps the field open. + if kept && buf != name { + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()}))); + } + 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 ("", "") are translated where painted; the stored name stays English. + // XML IDs stay as ids (not translated). + let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { + tl!("").to_string() + } else if xml_id { + name.clone() + } else { + painted_name(n, &n.display_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.extras().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.scope_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.identify_label(view.identify_xml_id)))); + } else { + out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); + } + } + // Row menu: Collect for Export on this row (or every highlighted row when this is one of them). + let collect_ids: Vec = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] }; + resp.context_menu(|ui| { + collect_for_export_menu(ui, true, |multiple| { + out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple }))); + }); + }); + // 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", + }; + // Show All and Unlock All take no rows. + let params = if cmd.ends_with("Others") { json!({ "ids": [n.id.0] }) } else { json!({}) }; + (cmd.into(), params) +} + +/// Ctrl/Cmd+Alt-clicking row `n`'s eye runs Outline Others, which puts every top-level layer but the +/// one holding the row in Outline and puts that one in Preview. When all the other layers are in +/// Outline already, it runs Preview All Layers. Each is one undo step. +fn outline_others_action(doc: &Document, n: &Node) -> (String, Value) { + let own = doc.layer_of(n.id); + if doc.layers.iter().any(|l| Some(l.id) != own && !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { + ("layer.outlineOthers".into(), json!({ "ids": [n.id.0] })) + } else { + // Preview All takes no rows. + ("layer.previewAll".into(), json!({})) + } +} + +/// 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.scope_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.scope_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 && m.command { + out.actions.push(outline_others_action(view.doc, n)); + } else 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::() else { return }; + let doc = view.doc; + let moving: Vec = match &*drag { + LayersDrag::Rows(ids) => ids.clone(), + LayersDrag::Art => view.sel.iter().copied().collect(), + LayersDrag::Guides(guides) => { + // Guides go to the layer of the row (the row's own, for a layer). + if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } { + guide_drop(ui, view, guides, layer, r, resp, out); + } + return; + } + }; + // 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(_) | LayersDrag::Guides(_) => 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::().is_some() { + let copy = ui.input(|i| i.modifiers.alt); + let ids: Vec = 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(), + LayersDrag::Guides(_) => return, + }; + 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.scope_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::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); + if let Some(d) = resp.dnd_release_payload::() + && 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() +} diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs new file mode 100644 index 000000000..cd9850212 --- /dev/null +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -0,0 +1,247 @@ +use super::*; + +/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in +/// progress (the thumbnails shown then stay until it ends). +pub(crate) struct ThumbSource<'a> { + pub uid: u64, + pub revision: u64, + pub doc: &'a Arc, + pub busy: bool, +} + +impl<'a> ThumbSource<'a> { + /// The thumbnails of the document `st` holds (`doc`, its document). + pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc) -> Self { + Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() } + } +} + +/// Node `id` of `doc`, as the `Arc` its parent holds. +pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option> { + let path = doc.index_path(id)?; + let (first, rest) = path.split_first()?; + let mut cur = doc.layers.get(*first)?; + for &i in rest { + cur = cur.children()?.get(i)?; + } + Some(cur.clone()) +} + +/// A row thumbnail's image. +struct ThumbEntry { + /// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays + /// and no other node can come to have its address. + node: Weak, + px: u32, + /// None: the node draws nothing. + tex: Option, + /// How long it took to render (ms). + ms: f64, + /// The pass it was last drawn in. + used: u64, +} + +/// A row thumbnail job: the row (document uid, node), the node and the size rendered. +type ThumbJob = ((u64, NodeId), Weak, u32); + +#[derive(Default)] +pub(super) struct Thumbs { + entries: HashMap<(u64, NodeId), ThumbEntry>, + /// What the worker is rendering, by row. + pending: HashMap<(u64, NodeId), (Weak, u32)>, + worker: Option>, + worker_started: bool, + settle: Settle, + /// (pass, ms, count) rendered on the UI thread in that pass. + spent: (u64, f64, u32), + /// Thumbnails rendered on the UI thread (tests count them). + pub(super) renders: u64, +} + +thread_local! { + pub(super) static THUMBS: crate::graphics::TexCache = crate::graphics::TexCache::default(); +} + +/// The most row thumbnails kept: past it the ones drawn longest ago go. +const MAX_THUMBS: usize = 512; + +/// Rendering a thumbnail that took longer than this (ms) again goes to the worker. +const LIGHT_THUMB_MS: f64 = 4.0; + +/// A node with more objects than this inside has its first thumbnail made on the worker. +const LIGHT_THUMB_NODES: usize = 200; + +/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the +/// rest go to the worker, or wait for the next pass where there is none. +const THUMB_BUDGET_MS: f64 = 8.0; + +const THUMB_BUDGET_COUNT: u32 = 16; + +/// How row thumbnails render: images far smaller than they are sample their mip levels. +pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { + vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() } +} + +/// Has `n` at most `limit` objects inside it (counting stops there)? +fn small_node(n: &Node, limit: usize) -> bool { + fn count(n: &Node, left: &mut usize) -> bool { + for c in n.children().into_iter().flatten() { + if *left == 0 { + return false; + } + *left -= 1; + if !count(c, left) { + return false; + } + } + true + } + let mut left = limit; + count(n, &mut left) +} + +impl Thumbs { + /// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`. + fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) { + let color = img.map(crate::canvas::color_image); + let used = ctx.cumulative_pass_nr(); + let name = format!("layer-thumb-{}", key.1.0); + match self.entries.get_mut(&key) { + Some(e) => { + match (color, &mut e.tex) { + (Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR), + (Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)), + (None, tex) => *tex = None, + } + (e.node, e.px, e.ms, e.used) = (node, px, ms, used); + } + None => { + let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR)); + self.entries.insert(key, ThumbEntry { node, px, tex, ms, used }); + if self.entries.len() > MAX_THUMBS { + // Keep the three quarters drawn last. + let mut by_use: Vec = self.entries.values().map(|e| e.used).collect(); + by_use.sort_unstable(); + let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0); + self.entries.retain(|_, e| e.used >= cut); + } + } + } + } + + /// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the + /// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until + /// the document settles. + fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc, px: u32) -> Option { + if !self.worker_started { + self.worker_started = true; + self.worker = ThumbWorker::spawn(ctx, "thumbnails"); + } + let done = match self.worker.as_ref().map(ThumbWorker::poll) { + Some(Some(done)) => done, + Some(None) => { + // The thread is gone: render here from now on. + self.worker = None; + self.pending.clear(); + vec![] + } + None => vec![], + }; + for d in done { + let (key, node, px) = d.key; + if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) { + self.pending.remove(&key); + } + self.upload(ctx, key, node, px, d.img.as_ref(), d.ms); + } + let key = (src.uid, n.id); + let pass = ctx.cumulative_pass_nr(); + let (fresh, light) = match self.entries.get_mut(&key) { + Some(e) => { + e.used = pass; + (std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS)) + } + None => (false, None), + }; + let shown = self.entries.get(&key).and_then(|e| e.tex.clone()); + if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) { + return shown; + } + if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) { + return shown; + } + if self.spent.0 != pass { + self.spent = (pass, 0.0, 0); + } + let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; + let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget; + if !light && let Some(w) = &self.worker { + let (doc, node) = (src.doc.clone(), n.clone()); + let job: ThumbJob = (key, Arc::downgrade(n), px); + if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) { + self.pending.insert(key, (Arc::downgrade(n), px)); + return shown; + } + self.worker = None; + self.pending.clear(); + } + if !in_budget { + ctx.request_repaint(); + return shown; + } + thread_local! { + static RENDERER: std::cell::RefCell = std::cell::RefCell::new(vectorcraft_render::Renderer::new()); + } + let t0 = crate::now_ms(); + let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options())); + let ms = crate::now_ms() - t0; + self.spent.1 += ms; + self.spent.2 += 1; + self.renders += 1; + self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms); + self.entries.get(&key).and_then(|e| e.tex.clone()) + } +} + +/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc` +/// allocation, so the address is a free change detector). Only rendered for visible rows; see +/// [`Thumbs::texture`] for when. +pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rect) -> bool { + if !ui.is_rect_visible(r) { + return true; + } + let ppp = ui.ctx().pixels_per_point(); + let px = ((r.width() * ppp).round() as u32).max(8); + match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) { + 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). +pub(super) 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))); + } + } + }); +}