From 7eb3f25f1f3824c90b8db0ae6b12dbbb82ea5560 Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 22:29:08 +0700 Subject: [PATCH] Layers: sublayers, groups and objects are clickable rows in the panel Rows at every depth highlight on click (Shift range, Ctrl toggle) and make their layer current; selection squares select a row's art and drag the selection to another row; eye and lock on every row with drag-down-the-column in one undo step and Ctrl-click for Outline; Alt-click triangles; drag and drop rows above, below or into layers and groups with a drop line (Alt copies); Delete key; the full panel menu, Layer Options and Panel Options dialogs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/layer_options.rs | 283 +++++ .../src/dialogs/layers_panel_options.rs | 117 ++ crates/ui-egui/src/dialogs/mod.rs | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 23 + crates/ui-egui/src/menus.rs | 28 + crates/ui-egui/src/panels/layers.rs | 1040 ++++++++++++++--- crates/ui-egui/src/state.rs | 8 + 7 files changed, 1314 insertions(+), 189 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/layer_options.rs create mode 100644 crates/ui-egui/src/dialogs/layers_panel_options.rs diff --git a/crates/ui-egui/src/dialogs/layer_options.rs b/crates/ui-egui/src/dialogs/layer_options.rs new file mode 100644 index 000000000..e659f4f53 --- /dev/null +++ b/crates/ui-egui/src/dialogs/layer_options.rs @@ -0,0 +1,283 @@ +//! Layer Options (double-click a layer row, or ≡ › Options for Selection…) and the same dialog for +//! a new layer or sublayer (≡ › New Layer…, Alt-click Create New Layer). Objects' rows get Name, +//! Show and Lock only. +//! +//! Fields: `ids` (the rows edited; none for a new layer), `mode` (`edit`, `new` or `newSublayer`), +//! `layer` (the rows are layers), `name`, `color` (a preset's name such as "Light Blue", or +//! `#rrggbb`), `template`, `locked`, `visible`, `printable`, `preview`, `dimImages` and +//! `dimPercent`. OK runs `layer.setProps`, `layer.new` or `layer.newSublayer`. + +use serde_json::{Value, json}; +use vectorcraft_doc::{LAYER_COLORS, LayerColor, Node, NodeId, NodeKind}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Layer Options. +pub const KIND: &str = "layerOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +fn heading(d: &Dialog) -> String { + if d.bool("layer") || d.str("mode") != "edit" { tl!("Layer Options").into() } else { tl!("Options").into() } +} + +/// A layer colour as the dialog shows it: a preset's name, else `#rrggbb`. +fn color_field(c: LayerColor) -> String { + match c { + LayerColor::Preset(i) => LAYER_COLORS.get(i as usize).map_or("Light Blue", |(n, _)| *n).to_string(), + LayerColor::Custom([r, g, b]) => format!("#{r:02x}{g:02x}{b:02x}"), + } +} + +/// The fields for `n` (a layer or any other row). +fn fields_of(n: &Node) -> serde_json::Map { + let mut f = serde_json::Map::new(); + f.insert("name".into(), json!(n.display_name())); + f.insert("visible".into(), json!(n.visible)); + f.insert("locked".into(), json!(n.locked)); + f.insert("layer".into(), json!(n.is_layer())); + if let NodeKind::Layer { color, template, printable, preview, dim_images, .. } = &n.kind { + f.insert("color".into(), json!(color_field(*color))); + f.insert("template".into(), json!(template)); + f.insert("printable".into(), json!(printable)); + f.insert("preview".into(), json!(preview)); + f.insert("dimImages".into(), json!(dim_images.is_some())); + f.insert("dimPercent".into(), json!(dim_images.unwrap_or(50))); + } + f +} + +/// `ui.layerOptions {ids?|id?}`: open Layer Options for rows (default: the highlighted rows, else +/// the current layer), filled in from the first. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let ids: Vec = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) { + (Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(), + (None, Some(id)) => vec![NodeId(id)], + (None, None) => { + let rows = st.highlighted_rows(); + if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows } + } + }; + let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?; + let mut f = fields_of(first); + f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::>())); + f.insert("mode".into(), json!("edit")); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f }); + Ok(Value::Null) +} + +/// `ui.newLayer {sublayer?}`: Layer Options for a new layer (or sublayer), with the name and +/// colour it would get. +pub fn open_new(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let mut layers = vec![]; + st.doc.walk(|n| { + if n.is_layer() { + layers.push(n.display_name()); + } + }); + let mut i = layers.len() + 1; + while layers.contains(&format!("Layer {i}")) { + i += 1; + } + let sublayer = p.get("sublayer").and_then(Value::as_bool).unwrap_or(false); + let color = LayerColor::Preset((layers.len() % LAYER_COLORS.len()) as u8); + let fields = json!({ + "mode": if sublayer { "newSublayer" } else { "new" }, "layer": true, "name": format!("Layer {i}"), "color": color_field(color), + "template": false, "locked": false, "visible": true, "printable": true, "preview": true, "dimImages": false, "dimPercent": 50, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: bool) -> bool { + let v = d.bool(key); + let changed = widgets::check(ui, text, v, enabled); + if changed { + d.fields.insert(key.into(), json!(!v)); + } + changed +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let layer = d.bool("layer"); + let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1); + grid(ui, |ui| { + if !many { + label(ui, tl!("Name:")); + form::text(ui, d, "name", 190.0); + ui.end_row(); + } + if layer { + label(ui, tl!("Color:")); + ui.horizontal(|ui| { + let cur = d.str("color"); + let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + let shown = if LAYER_COLORS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&cur)) { cur.clone() } else { "Custom".into() }; + if let Some((n, _)) = widgets::dropdown(ui, "layer-color", &shown, &names, 140.0).and_then(|i| LAYER_COLORS.get(i)) { + d.fields.insert("color".into(), json!(n)); + } + let [r, g, b] = LAYER_COLORS + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(&cur)) + .map(|(_, c)| *c) + .or_else(|| { + vectorcraft_color::Color::from_hex(&cur).map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }) + }) + .unwrap_or(LAYER_COLORS[0].1); + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } + }); + ui.end_row(); + } + }); + ui.add_space(8.0); + if layer { + ui.horizontal(|ui| { + ui.vertical(|ui| { + // A template is locked and dims its images; turning it off frees both. + if toggle(ui, d, "template", tl!("Template"), true) { + let on = d.bool("template"); + d.fields.insert("locked".into(), json!(on)); + d.fields.insert("dimImages".into(), json!(on)); + } + toggle(ui, d, "visible", tl!("Show"), true); + toggle(ui, d, "preview", tl!("Preview"), true); + ui.horizontal(|ui| { + toggle(ui, d, "dimImages", tl!("Dim Images to:"), true); + let mut v = d.f64("dimPercent", 50.0); + if ui.add_enabled(d.bool("dimImages"), egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%")).changed() { + d.fields.insert("dimPercent".into(), json!(v.round())); + } + }); + }); + ui.add_space(24.0); + ui.vertical(|ui| { + let template = d.bool("template"); + toggle(ui, d, "locked", tl!("Lock"), !template); + toggle(ui, d, "printable", tl!("Print"), !template); + }); + }); + } else { + toggle(ui, d, "visible", tl!("Show"), true); + toggle(ui, d, "locked", tl!("Lock"), true); + } + false +} + +/// The command and params OK runs. +pub(crate) fn params(d: &Dialog) -> (&'static str, Value) { + let mut p = serde_json::Map::new(); + let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1); + if !many { + p.insert("name".into(), json!(d.str("name"))); + } + p.insert("visible".into(), json!(d.bool("visible"))); + p.insert("locked".into(), json!(d.bool("locked"))); + if d.bool("layer") { + p.insert("color".into(), json!(d.str("color"))); + p.insert("template".into(), json!(d.bool("template"))); + p.insert("printable".into(), json!(d.bool("printable"))); + p.insert("preview".into(), json!(d.bool("preview"))); + p.insert("dimImages".into(), if d.bool("dimImages") { json!(d.f64("dimPercent", 50.0).clamp(0.0, 100.0)) } else { json!(false) }); + } + match d.str("mode").as_str() { + "new" => ("layer.new", Value::Object(p)), + "newSublayer" => ("layer.newSublayer", Value::Object(p)), + _ => { + p.insert("ids".into(), d.fields.get("ids").cloned().unwrap_or(json!([]))); + ("layer.setProps", Value::Object(p)) + } + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (cmd, p) = params(d); + run_and_close(app, cmd, p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app + } + + fn draw(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + #[test] + fn layer_options_edit_the_layer_and_ok_is_one_undo_step() { + let mut app = app(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + app.run("ui.layerOptions", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.kind.as_str(), d.str("name"), d.str("color")), (KIND, "Layer 1".to_string(), "Light Blue".to_string())); + draw(&mut app); + set(&mut app, "name", json!("Sky")); + set(&mut app, "color", json!("#00ff00")); + set(&mut app, "dimImages", json!(true)); + set(&mut app, "dimPercent", json!(30)); + set(&mut app, "preview", json!(false)); + let undo = app.session.doc().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let n = app.session.doc().unwrap().doc.node(layer).unwrap().clone(); + assert_eq!(n.display_name(), "Sky"); + assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Custom([0, 255, 0]), dim_images: Some(30), preview: false, .. })); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + } + + #[test] + fn new_layer_options_make_a_layer_and_objects_get_name_show_and_lock() { + let mut app = app(); + app.run("ui.newLayer", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Layer 2"); + set(&mut app, "name", json!("Ink")); + set(&mut app, "template", json!(true)); + set(&mut app, "locked", json!(true)); + draw(&mut app); + super::super::confirm(&mut app).unwrap(); + let d = &app.session.doc().unwrap().doc; + let top = d.layers.last().unwrap(); + assert_eq!(top.display_name(), "Ink"); + assert!(top.is_template() && top.locked); + // An object's row: Name, Show and Lock. + let r = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})); + assert!(r.is_ok()); + app.session.execute("layer.setCurrent", &json!({"id": app.session.doc().unwrap().doc.layers[0].id.0})).unwrap(); + let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("ui.layerOptions", json!({"id": rect})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("layer")); + draw(&mut app); + set(&mut app, "name", json!("Box")); + set(&mut app, "locked", json!(true)); + let (cmd, p) = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!(cmd, "layer.setProps"); + assert!(p.get("template").is_none()); + super::super::confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(NodeId(rect)).unwrap().clone(); + assert_eq!((n.display_name(), n.locked), ("Box".to_string(), true)); + } +} diff --git a/crates/ui-egui/src/dialogs/layers_panel_options.rs b/crates/ui-egui/src/dialogs/layers_panel_options.rs new file mode 100644 index 000000000..424d063a7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/layers_panel_options.rs @@ -0,0 +1,117 @@ +//! Layers panel › Panel Options…: Show Layers Only, the row size and which rows show thumbnails. +//! +//! Fields: `layersOnly`, `rowSize` (`small`, `medium`, `large` or `other`), `otherSize` (points, +//! 12–100, for `other`), `thumbLayers`, `thumbGroups` and `thumbObjects`. OK applies them to the +//! panel (kept with the UI state). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::swatch_options::{grid, label}; +use crate::panels::layers::{PanelOptions, ROW_LARGE, ROW_MEDIUM, ROW_SMALL}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the Layers Panel Options. +pub const KIND: &str = "layersPanelOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Layers Panel Options").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +const SIZES: [(&str, &str, f32); 3] = [("small", "Small", ROW_SMALL), ("medium", "Medium", ROW_MEDIUM), ("large", "Large", ROW_LARGE)]; + +/// `ui.layersPanelOptions`: open the dialog on the panel's options. +pub fn open(app: &mut VectorcraftApp) -> Result { + let o = &app.ui.layers_panel; + let size = SIZES.iter().find(|s| (s.2 - o.row()).abs() < 0.01).map_or("other", |s| s.0); + let fields = json!({ + "layersOnly": o.layers_only, "rowSize": size, "otherSize": o.row(), + "thumbLayers": o.thumb_layers, "thumbGroups": o.thumb_groups, "thumbObjects": o.thumb_objects, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) { + let v = d.bool(key); + if widgets::check(ui, text, v, true) { + d.fields.insert(key.into(), json!(!v)); + } +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + check(ui, d, "layersOnly", tl!("Show Layers Only")); + ui.add_space(8.0); + grid(ui, |ui| { + label(ui, tl!("Row Size:")); + ui.horizontal(|ui| { + let cur = d.str("rowSize"); + let labels: Vec<&str> = SIZES.iter().map(|s| s.1).chain(["Other"]).collect(); + let shown = SIZES.iter().find(|s| s.0 == cur).map_or("Other", |s| s.1); + if let Some(i) = widgets::dropdown(ui, "layers-row-size", shown, &labels, 110.0) { + d.fields.insert("rowSize".into(), json!(SIZES.get(i).map_or("other", |s| s.0))); + } + let mut v = d.f64("otherSize", f64::from(ROW_MEDIUM)); + if ui.add_enabled(d.str("rowSize") == "other", egui::DragValue::new(&mut v).range(12.0..=100.0).suffix(" pt")).changed() { + d.fields.insert("otherSize".into(), json!(v.round())); + } + }); + ui.end_row(); + }); + ui.add_space(8.0); + label(ui, tl!("Thumbnails:")); + check(ui, d, "thumbLayers", tl!("Layers")); + check(ui, d, "thumbGroups", tl!("Group")); + check(ui, d, "thumbObjects", tl!("Object")); + false +} + +/// The options the fields describe. +pub(crate) fn options(d: &Dialog) -> PanelOptions { + let size = d.str("rowSize"); + let row_size = SIZES.iter().find(|s| s.0 == size).map_or_else(|| (d.f64("otherSize", f64::from(ROW_MEDIUM)) as f32).clamp(12.0, 100.0), |s| s.2); + PanelOptions { + layers_only: d.bool("layersOnly"), + row_size: if row_size.is_finite() { row_size } else { ROW_MEDIUM }, + thumb_layers: d.bool("thumbLayers"), + thumb_groups: d.bool("thumbGroups"), + thumb_objects: d.bool("thumbObjects"), + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.layers_panel = options(d); + app.ui.dialog = None; + Ok(serde_json::to_value(&app.ui.layers_panel).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn panel_options_round_trip_and_apply() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.layersPanelOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("rowSize"), "medium"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("layersOnly".into(), json!(true)); + d.fields.insert("rowSize".into(), json!("other")); + d.fields.insert("otherSize".into(), json!(500)); + d.fields.insert("thumbObjects".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + let o = &app.ui.layers_panel; + assert!(o.layers_only && !o.thumb_objects && o.thumb_layers); + assert_eq!(o.row(), 100.0, "kept to 100 pt"); + app.run("ui.layersPanelOptions", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("rowSize".into(), json!("large")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.layers_panel.row(), ROW_LARGE); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 66ef51136..b7e292c97 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -33,6 +33,8 @@ mod gradient_stop; pub mod graphic_style_options; pub mod halftone; pub mod import_pdf; +pub mod layer_options; +pub mod layers_panel_options; pub mod liquify; pub mod missing_links; pub mod new_color_group; @@ -267,6 +269,8 @@ registry! { PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC, BlendOptions: [blend_options::KIND] => blend_options::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, + LayerOptions: [layer_options::KIND] => layer_options::SPEC, + LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC, } /// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close), diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 555af1f2a..80efcb441 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -602,6 +602,7 @@ Difference 差異化 Differences from {preset}: 與{preset}的差異: Dim Copies to: 將拷貝調暗至: + Dim Images to: 影像變暗至: Dimensions 尺寸 Direct Selection Tool 直接選取工具 Direction Handles 控制把手 @@ -697,6 +698,7 @@ Duplicate Layer 製作圖層複本 Duplicate Paragraph Style 製作段落樣式複本 Duplicate Selected Item 製作所選項目的複本 + Duplicate Selection 製作選取範圍複本 Duplicate Slice 製作切片複本 Duplicate Swatch 製作色票複本 Duplicate Symbol 製作符號複本 @@ -817,6 +819,7 @@ Expand Compound Shape 展開複合形狀 Expand Gradient Mesh 展開漸層網格 Expand Gradient To 展開漸層為 + Expand Layers Panel Rows 展開圖層面板列 Expand Options 展開選項 Expand Panels 展開面板 Expand Repeat 展開重複 @@ -924,6 +927,7 @@ Flare Tool Options 光暈工具選項 Flat Image 平面影像 Flatness: 平滑度: + Flatten Artwork 將圖稿平面化 Flatten Transparency 透明度平面化 Flatten Transparency… 透明度平面化… Flattener preset: 平面化預設集: @@ -1070,6 +1074,7 @@ Hide Hidden Characters 隱藏隱藏字元 Hide Note 隱藏註解 Hide Options 隱藏選項 + Hide Others 隱藏其他 Hide Print Tiling 隱藏列印分頁 Hide Rulers 隱藏尺標 Hide Slices 隱藏切片 @@ -1082,6 +1087,7 @@ High Performance (discrete) 高效能 (獨立顯示卡) Highlight Alternate Glyphs 醒目提示替代字符 Highlight anchors on mouse over 滑鼠移過時醒目提示錨點 + Highlight Rows 反白顯示列 Highlight Substituted Fonts 醒目提示被取代的字體 Highlight Substituted Glyphs 醒目提示被取代的字符 History 步驟記錄 @@ -1222,8 +1228,10 @@ Latin-1 Supplement 拉丁文-1 補充 Layer 圖層 Layer Names 圖層名稱 + Layer Options 圖層選項 Layer Options… 圖層選項… Layers 圖層 + Layers Panel Options 圖層面板選項 Layout 版面配置 Layout: 版面配置: Leader: 前導字元: @@ -1307,6 +1315,7 @@ Lock (⌘2) 鎖定 (⌘2) Lock Grid 鎖定格點 Lock Guides 鎖定參考線 + Lock Others 鎖定其他 Lock Slices 鎖定切片 Lock Station Point 鎖定基準點 Lossless (lossy WebP isn't available yet) 無失真 (尚未提供有失真的 WebP) @@ -1371,6 +1380,7 @@ Merge Graphic Styles 合併繪圖樣式 Merge Graphic Styles… 合併繪圖樣式… Merge Layers 合併圖層 + Merge Selected 合併選取的項目 Merge Swatches 合併色票 Mesh Tool 網格工具 Message: 訊息: @@ -1423,6 +1433,7 @@ Move Mesh Point 移動網格點 Move Plane 移動平面 Move Plane to Match Object 移動平面以符合物件 + Move Rows 移動列 Move Slices 移動切片 Move Spine Point 移動脊線點 Move Swatch 移動色票 @@ -1459,6 +1470,7 @@ New Graphic Style 新增繪圖樣式 New Graphic Style (Alt-click to name it) 新增繪圖樣式 (Alt+點擊可命名) New Graphic Style… 新增繪圖樣式… + New Layer Options… 新圖層選項… New Layer… 新增圖層… New Opacity Masks Are Clipping 新的不透明度遮色片為剪裁 New Opacity Masks Are Inverted 新的不透明度遮色片為反轉 @@ -1627,6 +1639,7 @@ Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 + Options for Selection… 選取範圍的選項… Options: 選項: Options… 選項… or more spaces with a tab 個以上的空格取代為定位字元 @@ -1646,6 +1659,7 @@ Out Tangent Handles 外切線控制把手 Outer Glow… 外光暈… Outline 外框 + Outline Others 其他以外框檢視 Outline Stroke 外框筆畫 Outline Text 外框文字 Outlines and Rules 外框與規則線 @@ -1815,6 +1829,7 @@ Presets: 預設集: Press 按下 Preview 預覽 + Preview All Layers 預覽所有圖層 Preview in Browser 在瀏覽器中預覽 Preview in Browser needs the desktop app (the web downloads files instead) 在瀏覽器中預覽需要桌面版應用程式 (網頁版會改為下載檔案) Preview Mode 預覽模式 @@ -1962,6 +1977,8 @@ Release Guides 釋放參考線 Release Opacity Mask 釋放不透明度遮色片 Release Selection 釋放選取範圍 + Release to Layers (Build) 釋放至圖層 (累加) + Release to Layers (Sequence) 釋放至圖層 (順序) Release with Perspective 釋放透視 Relink 重新連結 Relink Linked Files to the Document 將連結檔案重新連結至文件 @@ -2073,6 +2090,7 @@ Rounded Rectangle 圓角矩形 Rounded Rectangle Tool 圓角矩形工具 Rounded Rectangle… 圓角矩形… + Row Size: 列尺寸: Rows: 列數: Rule: 規則線: Rulers 尺標 @@ -2206,6 +2224,7 @@ Select an image to trace 選取要描圖的影像 Select Anchors 選取錨點 Select Appearance Item 選取外觀項目 + Select Art 選取圖稿 Select art and choose Object › Pattern › Make. 選取圖稿,然後選擇「物件 › 圖樣 › 製作」。 Select art and click New Symbol to make one. 選取圖稿並按一下「新增符號」即可建立。 Select art and use New Brush to make one. 選取圖稿,然後使用「新增筆刷」來製作。 @@ -2280,6 +2299,7 @@ Show Active Plane Widget 顯示作用中平面 Widget Show All 全部顯示 Show All Hidden Attributes 顯示所有隱藏屬性 + Show All Layers 顯示所有圖層 Show All Plates 顯示所有色版 Show all plates 顯示所有色版 Show All Swatches 顯示所有色票 @@ -2308,6 +2328,7 @@ Show Images in Outline Mode 在外框模式中顯示影像 Show in Folder 在資料夾中顯示 Show Indic Options 顯示印度文字選項 + Show Layers Only 僅顯示圖層 Show Link Info 顯示連結資訊 Show List View 顯示清單檢視 Show Mesh 顯示網格 @@ -2557,6 +2578,7 @@ Three Point Perspective Preset 5 三點透視預設集 5 Threshold 臨界值 Thumbnail View 縮圖檢視 + Thumbnails: 縮圖: Tile 並排顯示 Tile Edge Color 拼貼邊緣顏色 Tile Edge Color… 拼貼邊緣顏色… @@ -2672,6 +2694,7 @@ Unlink the mask 取消連結遮色片 Unlock 解除鎖定 Unlock All 全部解除鎖定 + Unlock All Layers 解除鎖定所有圖層 Unlock Grid 解除鎖定格點 Unlock Guides 解除鎖定參考線 Unnamed Objects 未命名物件 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ed5c0268a..f3c15af20 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -305,6 +305,30 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{names: [two or more styles]} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`) to name the style OK merges from them (graphicStyle.merge)", ), + ( + "ui.layerOptions", + "Options for Selection…", + "", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", + ), + ( + "ui.newLayer", + "New Layer Options…", + "", + "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", + ), + ( + "ui.layersPanelOptions", + "Panel Options…", + "", + "{} open the Layers panel's Panel Options: dialog `layersPanelOptions`, fields layersOnly, rowSize (small|medium|large|other), otherSize (12–100 pt), thumbLayers, thumbGroups, thumbObjects; OK applies them (kept with the UI state)", + ), + ( + "ui.layersExpand", + "Expand Layers Panel Rows", + "", + "{ids?: [id…] (default: every row that holds others), open?: bool (default true)} open or close rows of the Layers panel, as clicking their triangles does → {count}", + ), ( "ui.tileEdgeColor", "Tile Edge Color…", @@ -1022,6 +1046,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::tile_edge_color::open(app), + "ui.layerOptions" => crate::dialogs::layer_options::open(app, p), + "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), + "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), + "ui.layersExpand" => crate::panels::layers::expand(app, p), "ui.flattenTransparencyDialog" => { crate::dialogs::flatten::open(app); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 3a08447c2..97e9c7278 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1,25 +1,128 @@ -//! The Layers panel: tree with visibility/lock columns, layer colour bars, thumbnails, target -//! circles and selection squares; bottom bar with new/delete. +//! The Layers panel: a row for every layer, sublayer, group and object, nested and indented, with +//! the eye (visibility) and lock columns, the layer colour bar, a disclosure triangle, a thumbnail +//! and the name, then the target circle and the selection column; a bottom bar with the panel's +//! buttons and the ≡ menu. //! -//! A target circle targets its layer, group or object (`layer.target`); dragging it onto another -//! row's circle moves the appearance there (Alt copies it, `appearance.transfer`) and dropping it -//! on the trash clears it. While an opacity mask is edited the panel lists only its art, under -//! an `` entry. +//! - Clicking a row highlights it and makes its layer current (`layer.setCurrent`); Shift-click +//! highlights the range of rows down to it, Ctrl/Cmd-click toggles one (`layer.highlight`). Panel +//! commands (Duplicate, Delete, Merge, Options…) act on the highlighted rows. +//! - The selection column selects a row's art (`layer.selectAll`, Shift adds or removes); a row +//! whose art is selected shows a square in its layer's colour, and dragging that square onto +//! another row moves the selected art there (Alt copies it). +//! - The eye and lock columns toggle a row (`layer.setProps`); dragging down a column sets every +//! row it passes to the same state, in one undo step. Ctrl/Cmd-click an eye switches the layer +//! between Preview and Outline. +//! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or +//! a box shows where); Alt copies them; dropped on the trash they are deleted. +//! - The triangle opens or closes a row; Alt-click does the same to everything inside it. +//! - Double-clicking a name renames it; double-clicking elsewhere on a row opens its options. +//! - A target circle targets its layer, group or object (`layer.target`); dragging it onto another +//! row's circle moves the appearance there (Alt copies it, `appearance.transfer`) and dropping it +//! on the trash clears it. +//! +//! While an opacity mask is edited the panel lists only its art, under an `` entry. use std::collections::HashSet; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; -use serde_json::json; -use vectorcraft_doc::{Node, NodeId, NodeKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use crate::theme::Tokens; -use crate::widgets::PanelDrag; +use crate::widgets::{Live, PanelDrag}; use crate::{VectorcraftApp, icons, widgets}; -const ROW: f32 = 26.0; +/// Width of the eye and the lock columns. +const COLUMN: f32 = 25.0; +/// Indent per nesting level. +const INDENT: f32 = 14.0; +/// Right edge offsets of the target circle and the selection square. +const TARGET_X: f32 = 28.0; +const SQUARE_X: f32 = 11.0; -fn expanded_id() -> egui::Id { - egui::Id::new("layers-expanded") +/// Layers panel options (≡ › Panel Options…), kept with the UI state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PanelOptions { + /// Show Layers Only: no rows for groups and objects. + pub layers_only: bool, + /// Row height in points: [`ROW_SMALL`], [`ROW_MEDIUM`], [`ROW_LARGE`] or another (12–100). + pub row_size: f32, + /// Thumbnails on layer, group and object rows. + pub thumb_layers: bool, + pub thumb_groups: bool, + pub thumb_objects: bool, +} + +pub const ROW_SMALL: f32 = 20.0; +pub const ROW_MEDIUM: f32 = 26.0; +pub const ROW_LARGE: f32 = 40.0; + +impl Default for PanelOptions { + fn default() -> Self { + Self { layers_only: false, row_size: ROW_MEDIUM, thumb_layers: true, thumb_groups: true, thumb_objects: true } + } +} + +impl PanelOptions { + /// The row height, kept to 12–100 points. + pub fn row(&self) -> f32 { + if self.row_size.is_finite() { self.row_size.clamp(12.0, 100.0) } else { ROW_MEDIUM } + } + /// Whether `n`'s row shows a thumbnail. + fn thumb(&self, n: &Node) -> bool { + self.row() >= 18.0 + && match n.kind { + NodeKind::Layer { .. } => self.thumb_layers, + NodeKind::Group { .. } => self.thumb_groups, + _ => self.thumb_objects, + } + } +} + +/// What a drag in the panel carries. +#[derive(Clone, Debug)] +enum LayersDrag { + /// Rows dragged to move (or, with Alt, copy) them. + Rows(Vec), + /// The selected-art square: the selected objects go to the row it is dropped on. + Art, +} + +/// A drag down the eye or lock column: every row it passes takes `value`. +#[derive(Clone, Debug, Default)] +struct ColumnDrag { + /// The eye column (visibility), else the lock column. + eye: bool, + value: bool, + ids: Vec, +} + +fn key(name: &str) -> egui::Id { + egui::Id::new(("layers-panel", name)) +} + +fn rename_id() -> egui::Id { + egui::Id::new("layers-rename") +} + +/// 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 @@ -33,6 +136,50 @@ fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String { name.to_string() } +/// Whether `n` gets rows for what it holds (a compound path's paths are one object). +fn opens(n: &Node, opts: &PanelOptions) -> bool { + match n.kind { + NodeKind::Compound { .. } => false, + _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + } +} + +/// Whether `n` or anything in it has a name containing `q` (lowercase). +fn matches(n: &Node, q: &str) -> bool { + n.display_name().to_lowercase().contains(q) || n.children().is_some_and(|c| c.iter().any(|c| matches(c, q))) +} + +/// Everything one frame of the panel reads. +struct View<'a> { + doc: &'a Document, + sel: HashSet, + target: Option, + current: Option, + rows: Vec, + opts: PanelOptions, + t: Tokens, + h: f32, + /// The search field's text, lowercase (empty: every row). + query: String, + column: Option, + /// The row to scroll into view. + reveal: Option, +} + +/// What one frame of the panel decided. +#[derive(Default)] +struct Out { + actions: Vec<(String, Value)>, + /// The rows shown, top down. + order: Vec, + /// A row clicked with these modifiers (resolved once every row is listed). + click: Option<(NodeId, egui::Modifiers)>, + /// The column drag after this frame (`Some(None)`: it ended). + column: Option>, + /// Rows to open or close, and whether to open them. + toggle: Vec<(NodeId, bool)>, +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -43,34 +190,107 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if before != Some(st.uid) { ui.data_mut(|d| { if before.is_some() { - d.remove::<(u64, String)>(egui::Id::new("layers-rename")); + d.remove::<(u64, String)>(rename_id()); } d.insert_temp(shown_doc, st.uid); }); } + let uid = st.uid; let doc = st.doc.clone(); - let sel: HashSet = st.selection.objects.iter().copied().collect(); - let target = st.selection.target; - let current = st.active_layer; + let rows = st.highlighted_rows(); + // Rows highlighted anew (a click, Locate Object, a new layer): open the layers around them. + let seen: Vec = ui.data(|d| d.get_temp(key("seen"))).unwrap_or_default(); + let mut reveal = None; + let expanded = app.ui.layers_expanded.entry(uid).or_insert_with(|| doc.layers.iter().map(|l| l.id.0).collect()); + if rows != seen { + for r in &rows { + for a in doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default() { + expanded.insert(a.0); + } + } + reveal = rows.first().copied(); + ui.data_mut(|d| d.insert_temp(key("seen"), rows.clone())); + } + let query = crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")).trim().to_lowercase(); + let view = View { + doc: &doc, + sel: st.selection.objects.iter().copied().collect(), + target: st.selection.target, + current: st.current_layer(), + rows, + opts: app.ui.layers_panel.clone(), + t, + h: app.ui.layers_panel.row(), + query, + column: ui.data(|d| d.get_temp(key("column"))), + reveal, + }; // While an opacity mask is edited only its art is listed. let mask_layer = doc.mask_edit.map(|m| m.layer); - let mut expanded: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect()); - let mut actions: Vec<(String, serde_json::Value)> = vec![]; - // Search field ("Search All"). - crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")); + let mut out = Out::default(); ui.add_space(6.0); - let h = ui.available_height() - 34.0; - egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| { + let list_h = ui.available_height() - 34.0; + let list = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; + let expanded = app.ui.layers_expanded.entry(uid).or_default(); for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - row(ui, &doc, l, 0, false, &sel, target, current, &mut expanded, &mut actions, &t); + row(ui, &view, l, 0, false, expanded, &mut out); } + // Room below the last row: a click there leaves the rows as they are. + ui.allocate_space(vec2(ui.available_width(), (ui.available_height() - 2.0).max(0.0))); }); - ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); - if ui.input(|i| i.pointer.any_released()) { + let list_rect = list.inner_rect; + resolve_click(ui, &view, &mut out); + let expanded = app.ui.layers_expanded.entry(uid).or_default(); + for (id, open) in out.toggle.drain(..) { + if open { + expanded.insert(id.0); + } else { + expanded.remove(&id.0); + } + } + // The column drag: live while it lasts, one undo step when the button is released. + let released = ui.input(|i| i.pointer.any_released()); + let changed = out.column.is_some(); + let column = out.column.take().unwrap_or_else(|| view.column.clone()); + if let Some(c) = column.as_ref().filter(|_| changed || released) { + let (prop, label) = if c.eye { ("visible", "Visibility") } else { ("locked", "Lock") }; + let phase = if released { Live::Released } else { Live::Dragging }; + let params = json!({"ids": c.ids, prop: c.value}); + super::live_run(app, label, "layer.setProps", params, phase); + } + ui.data_mut(|d| match column.filter(|_| !released) { + Some(c) => { + d.insert_temp(key("column"), c); + } + None => d.remove::(key("column")), + }); + if released { ui.data_mut(|d| d.remove::(egui::Id::new("layers-drag"))); } - // Bottom bar. + // Keyboard: Delete removes the highlighted rows while the panel has the keyboard (a row was + // clicked last, not the canvas). + let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p))); + if pressed_outside { + ui.data_mut(|d| d.insert_temp(key("focus"), false)); + } + let focus: bool = ui.data(|d| d.get_temp(key("focus"))).unwrap_or(false); + if focus + && !view.rows.is_empty() + && ui.ctx().memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) + { + out.actions.push(("layer.delete".into(), json!({}))); + } + bottom_bar(app, ui, &view, &doc, &mut out.actions); + for (c, p) in out.actions { + app.run(&c, p).ok(); + } +} + +/// The bottom bar: the layer count and the panel's buttons. +fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document, actions: &mut Vec<(String, Value)>) { + let t = &view.t; let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); let n = doc.layers.len(); @@ -81,146 +301,161 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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. + // A target circle dropped on the trash clears that appearance; dragged rows are deleted. if let Some(d) = trash.dnd_release_payload::() && let PanelDrag::Appearance(id) = *d { actions.push(("appearance.clear".into(), json!({"ids": [id.0]}))); + } else if let Some(d) = trash.dnd_release_payload::() + && let LayersDrag::Rows(ids) = &*d + { + actions.push(("layer.delete".into(), json!({"ids": ids.iter().map(|i| i.0).collect::>()}))); } else if trash.clicked() { - if app.session.active().is_some_and(|d| !d.selection.is_empty()) { + 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. if widgets::icon_button(&mut child, "file-plus", tl!("Create New Layer"), false, 24.0).clicked() { - actions.push(("layer.new".into(), json!({}))); + if m.alt { + actions.push(("ui.newLayer".into(), json!({}))); + } else { + actions.push(("layer.new".into(), json!({"top": m.command}))); + } } if widgets::icon_button(&mut child, "plus", tl!("Create New Sublayer"), false, 24.0).clicked() { - actions.push(("layer.newSublayer".into(), json!({}))); + 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() { - // Expand ancestors of the selection. - if let Some(st) = app.session.active() { - let mut ex: HashSet = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_default(); - for id in &st.selection.objects { - for a in st.doc.ancestry(*id).unwrap_or_default() { - ex.insert(a.0); - } - } - ui.data_mut(|d| d.insert_temp(expanded_id(), ex)); - } + actions.push(("layer.locate".into(), json!({}))); } - for (c, p) in actions { - app.run(&c, p).ok(); +} + +/// 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, 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)); + if m.shift + && let Some(a) = anchor.or(view.rows.last().copied()) + && let (Some(i), Some(j)) = (out.order.iter().position(|r| *r == a), out.order.iter().position(|r| *r == id)) + { + let range: Vec = out.order.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.0).collect(); + out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }}))); + return; + } + ui.data_mut(|d| d.insert_temp(key("anchor"), id)); + if m.command { + out.actions.push(("layer.highlight".into(), json!({"ids": [id.0], "mode": "toggle"}))); + } else { + out.actions.push(("layer.setCurrent".into(), json!({"id": id.0}))); + } +} + +/// Whether row `n` can take `drag` inside it. +fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { + match n.kind { + NodeKind::Layer { .. } => true, + NodeKind::Group { .. } => match drag { + LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)), + LayersDrag::Art => true, + }, + _ => false, } } #[allow(clippy::too_many_arguments)] -fn row( - ui: &mut Ui, - doc: &vectorcraft_doc::Document, - n: &Node, - depth: usize, - clip_path: bool, - sel: &HashSet, - target: Option, - current: Option, - expanded: &mut HashSet, - actions: &mut Vec<(String, serde_json::Value)>, - t: &Tokens, -) { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag()); - let is_sel = sel.contains(&n.id); +fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expanded: &mut HashSet, out: &mut Out) { + let doc = view.doc; + let t = &view.t; + if !view.query.is_empty() && !matches(n, &view.query) { + return; + } + if view.opts.layers_only && !n.is_layer() { + return; + } + let h = view.h; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); + out.order.push(n.id); + 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 && sel.contains(&c.id)); + n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id)); any }); let color = { let c = doc.layer_color(n.id); Color32::from_rgb(c[0], c[1], c[2]) }; - if n.is_layer() && Some(n.id) == current { + // 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) == current { - // Current-layer marker: small triangle in the top-right corner. + 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)], Color32::from_gray(0xcc), Stroke::NONE)); + 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 columns. - let eye = egui::Rect::from_min_size(r.min, vec2(25.0, ROW)); - let lock = egui::Rect::from_min_size(r.min + vec2(25.0, 0.0), vec2(25.0, ROW)); - 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 er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click()); - if n.visible { - icons::paint(ui, "eye", egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)), t.icon); - } - if er.clicked() { - let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; - let key = if n.is_layer() { "id" } else { "ids" }; - let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; - actions.push((cmd.into(), json!({key: idv, "visible": !n.visible}))); - } - let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click()); - 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); - } - if lr.clicked() { - let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; - let key = if n.is_layer() { "id" } else { "ids" }; - let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; - actions.push((cmd.into(), json!({key: idv, "locked": !n.locked}))); - } - // Layer colour bar. - let mut x = lock.right() + 2.0; - if n.is_layer() { - ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(4.0, ROW)), 0.0, color); - } - x += 6.0 + depth as f32 * 14.0; - // Disclosure. - let has_children = n.children().is_some_and(|c| !c.is_empty()) && !matches!(n.kind, NodeKind::Compound { .. }); + 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; + ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(if n.is_layer() { 4.0 } else { 2.0 }, h)), 0.0, color); + x += 6.0 + depth as f32 * INDENT; + // Disclosure triangle; Alt opens or closes everything inside too. + let searching = !view.query.is_empty(); + let has_children = opens(n, &view.opts); + let open = has_children && (searching || expanded.contains(&n.id.0)); if has_children { - let open = expanded.contains(&n.id.0); - let dr = egui::Rect::from_min_size(egui::pos2(x, r.top() + 5.0), vec2(14.0, 16.0)); + let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click()); icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); if dresp.clicked() { - if open { - expanded.remove(&n.id.0); - } else { - expanded.insert(n.id.0); + 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 th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(22.0, 22.0)); - ui.painter().rect_filled(th, 0.0, Color32::WHITE); - ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); - if !real_thumb(ui, doc, n, th) { - thumb(ui, n, th); - } - x += 26.0; + let thumb_rect = view.opts.thumb(n).then(|| { + let s = (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, t.border), StrokeKind::Outside); + if !real_thumb(ui, doc, n, th) { + thumb(ui, n, th); + } + x += s + 4.0; + th + }); // Name. let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "".to_string() } else { n.display_name() }; let font = egui::FontId::proportional(13.0); - let rename_id = egui::Id::new("layers-rename"); - 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.top() + 3.0), egui::pos2(r.right() - 44.0, r.bottom() - 3.0)); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); + let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); + let name_end; match renaming { Some((rid, mut buf)) if rid == n.id.0 => { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); @@ -231,16 +466,14 @@ fn row( te.request_focus(); } if te.lost_focus() { - ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id())); if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { - let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; - let key = if n.is_layer() { "id" } else { "ids" }; - let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; - actions.push((cmd.into(), json!({key: idv, "name": buf}))); + out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf}))); } } else { - ui.data_mut(|d| d.insert_temp(rename_id, (n.id.0, buf))); + 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); @@ -250,7 +483,9 @@ fn row( } else { painted_name(n, &name, crate::i18n::current()) }; - let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, t.text); + let dim = !doc.is_visible(n.id) || n.is_template(); + let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if 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)); @@ -262,80 +497,165 @@ fn row( // 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() - 28.0, r.center().y); - let tresp = target_circle(ui, n, tc, target.map_or(is_sel, |id| id == n.id), actions, t); - let sq = egui::pos2(r.right() - 11.0, r.center().y); + 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, Color32::BLACK), StrokeKind::Inside); + 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, ROW)), ui.id().with(("selsq", n.id.0)), Sense::click()); + let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.id().with(("selsq", n.id.0)), Sense::click_and_drag()); + // Dragging the selected-art square carries the selection to another row. + if (is_sel || child_sel) && sq_resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art); + } + let m = ui.input(|i| i.modifiers); if tresp.clicked() { - actions.push(("layer.target".into(), json!({"id": n.id.0}))); + out.actions.push(("layer.target".into(), json!({"id": n.id.0}))); } else if sq_resp.clicked() { - if n.is_layer() { - actions.push(("layer.selectAll".into(), json!({"id": n.id.0}))); - } else if ui.input(|i| i.modifiers.shift) { - actions.push(("select.toggle".into(), json!({"id": n.id.0}))); - } else { - actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); - } + out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift}))); } else if resp.clicked() { - if n.is_layer() { - actions.push(("layer.setCurrent".into(), json!({"id": n.id.0}))); - } else { - actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); - } + out.click = Some((n.id, m)); } if resp.double_clicked() { - ui.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (n.id.0, n.display_name()))); - } - // Drag to reorder: drop onto a row moves the dragged node above it (into its parent). - let drag_id = egui::Id::new("layers-drag"); - if resp.drag_started() { - ui.data_mut(|d| d.insert_temp(drag_id, n.id.0)); - } - let dragging: Option = ui.data(|d| d.get_temp(drag_id)); - if let Some(src) = dragging - && src != n.id.0 - && ui.rect_contains_pointer(r) - { - let above = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.y < r.center().y); - let y = if above { r.top() } else { r.bottom() }; - ui.painter().line_segment([egui::pos2(r.left() + 46.0, y), egui::pos2(r.right(), y)], Stroke::new(2.0, t.accent)); - if ui.input(|i| i.pointer.any_released()) { - let (parent, index) = match doc.position(n.id) { - Some((par, idx, _)) => (par, if above { idx + 1 } else { idx }), - None => (None, 0), - }; - // A layer dropped on a non-layer goes into that row's container; top level only for layers. - let src_is_layer = doc.node(vectorcraft_doc::NodeId(src)).is_some_and(|x| x.is_layer()); - if parent.is_some() || src_is_layer { - actions.push(("node.move".into(), json!({"id": src, "parent": parent.map(|p| p.0), "index": index}))); - } - ui.data_mut(|d| d.remove::(drag_id)); + let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left()); + let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p))); + if on_name && !on_thumb { + ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.display_name()))); + } else { + out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); } } - - if has_children - && expanded.contains(&n.id.0) - && let Some(children) = n.children() - { + // 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)); + } + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + drop_target(ui, view, n, r, x - 16.0, open, &resp, out); + if open && let Some(children) = n.children() { for (i, c) in children.iter().enumerate().rev() { - row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, target, current, expanded, actions, t); + row(ui, view, c, depth + 1, i == 0 && n.clips(), expanded, out); } } } +/// The eye and lock columns of row `n` at `r`. +fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out) { + let t = &view.t; + let h = view.h; + let eye = egui::Rect::from_min_size(r.min, vec2(COLUMN, h)); + let lock = egui::Rect::from_min_size(r.min + vec2(COLUMN, 0.0), vec2(COLUMN, h)); + ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); + ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); + let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. }); + let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click_and_drag()); + if n.visible { + let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)); + if n.is_template() { + // A template layer shows the template mark instead of the eye. + icons::paint(ui, "shapes", icon, t.icon); + } else if preview { + icons::paint(ui, "eye", icon, t.icon); + } else { + // Outline view: a hollow eye. + icons::paint(ui, "eye", icon, t.text_dim); + ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim)); + } + } + let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click_and_drag()); + if n.locked { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); + } else if lr.hovered() { + icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled); + } + let m = ui.input(|i| i.modifiers); + if er.clicked() { + if m.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() { + 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(), + }; + // Never into itself or a row inside it. + let around = doc.ancestry(n.id).unwrap_or_default(); + if moving.is_empty() || moving.iter().any(|m| around.contains(m)) { + return; + } + let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + let rel = (p.y - r.top()) / r.height().max(1.0); + let inside = takes(n, &drag, doc); + let place = match &*drag { + LayersDrag::Art if inside => Place::Inside, + LayersDrag::Art => Place::Above, + LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside, + LayersDrag::Rows(_) if rel < 0.5 => Place::Above, + LayersDrag::Rows(_) => Place::Below, + }; + let stroke = Stroke::new(2.0, view.t.accent); + match place { + Place::Above => { + ui.painter().line_segment([egui::pos2(indent, r.top()), egui::pos2(r.right(), r.top())], stroke); + } + Place::Below => { + ui.painter().line_segment([egui::pos2(indent, r.bottom()), egui::pos2(r.right(), r.bottom())], stroke); + } + Place::Inside => { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); + } + } + if resp.dnd_release_payload::().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(), + }; + 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, targeted: bool, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) -> egui::Response { - let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, ROW)), ui.id().with(("target", n.id.0)), Sense::click_and_drag()); +fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response { + let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.id().with(("target", n.id.0)), Sense::click_and_drag()); widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id)); let held = resp.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); if let Some(d) = resp.dnd_release_payload::() @@ -432,27 +752,127 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { /// The Layers panel's (≡) menu. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - const ITEMS: [(&str, &str); 6] = [ - ("New Layer…", "layer.new"), - ("New Sublayer…", "layer.newSublayer"), - ("Duplicate Layer", "layer.duplicate"), - ("Delete Layer", "layer.delete"), - ("Make/Release Clipping Mask", "layer.clippingMask.toggle"), - ("Collect in New Layer", "layer.collectInNew"), + 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, ]; - for (i, (label, cmd)) in ITEMS.into_iter().enumerate() { - if i == 4 { - ui.separator(); - } - if widgets::menu_item(ui, tl!(label), crate::menus::enabled(app, cmd), false) { - app.run(cmd, json!({})).ok(); + items.push(if isolated { + Some(("Exit Isolation Mode", "object.exitIsolation", json!({}), true, false)) + } else { + let id = targets.first().map(|i| i.0); + Some(("Enter Isolation Mode", "object.isolate", json!({ "id": id }), one_container, false)) + }); + items.extend([ + None, + Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), + None, + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), + Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), + Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), + Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)), + Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)), + Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)), + None, + Some(("Template", "layer.template", json!({}), !layers_in.is_empty(), template)), + ]); + // Each pair reads by state: Hide Others while another layer shows, else Show All Layers. + let any_other = |f: &dyn Fn(&Node) -> bool| others.iter().any(|l| f(l)); + items.push(Some(if any_other(&|l| l.visible) { + ("Hide Others", "layer.hideOthers", json!({}), true, false) + } else { + ("Show All Layers", "layer.showAll", json!({}), all_layers.iter().any(|l| !l.visible), false) + })); + items.push(Some(if any_other(&|l| !matches!(l.kind, NodeKind::Layer { preview: false, .. })) { + ("Outline Others", "layer.outlineOthers", json!({}), true, false) + } else { + ( + "Preview All Layers", + "layer.previewAll", + json!({}), + all_layers.iter().any(|l| matches!(l.kind, NodeKind::Layer { preview: false, .. })), + false, + ) + })); + items.push(Some(if any_other(&|l| !l.locked) { + ("Lock Others", "layer.lockOthers", json!({}), true, false) + } else { + ("Unlock All Layers", "layer.unlockAll", json!({}), all_layers.iter().any(|l| l.locked), false) + })); + items.push(None); + let remembers = doc.paste_remembers_layers; + items.push(Some(("Paste Remembers Layers", "layer.pasteRemembersLayers", json!({"on": !remembers}), true, remembers))); + items.push(None); + items.push(Some(("Panel Options…", "ui.layersPanelOptions", json!({}), true, false))); + for item in items { + match item { + None => { + ui.separator(); + } + Some((label, cmd, p, enabled, checked)) => { + if widgets::menu_item(ui, label, enabled, checked) { + app.run(cmd, p).ok(); + } + } } } - ui.separator(); - let remembers = app.session.active().is_some_and(|d| d.doc.paste_remembers_layers); - if widgets::menu_item(ui, tl!("Paste Remembers Layers"), app.session.active().is_some(), remembers) { - app.run("layer.pasteRemembersLayers", json!({"on": !remembers})).ok(); +} + +/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel; +/// without `ids`, every row that holds others. +pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result { + 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 uid = st.uid; + let layers: Vec = st.doc.layers.iter().map(|l| l.id.0).collect(); + let set = app.ui.layers_expanded.entry(uid).or_insert_with(|| layers.into_iter().collect()); + for i in &ids { + if open { + set.insert(*i); + } else { + set.remove(i); + } } + Ok(json!({ "count": ids.len() })) } #[cfg(test)] @@ -708,4 +1128,246 @@ mod tests { assert_eq!(c.len(), 3); assert!(after > before + 1, "a dashed underline: {before} → {after}"); } + + // ---- rows at every depth (layers, sublayers, groups, objects) ---- + + /// Layer 1 holding rectangle `c` and a sublayer holding rectangle `a` and a group of + /// rectangle `b`, every row open, nothing selected → (app, [layer, sub, g, b, a, c]): the rows + /// top down. + fn nested() -> (VectorcraftApp, [NodeId; 6]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); + run(&mut app, "file.new", json!({"width": 400, "height": 400})); + let layer = app.session.active().unwrap().doc.layers[0].id; + let c = id(run(&mut app, "shape.rectangle", json!({"x": 300, "y": 0, "width": 20, "height": 20}))); + let sub = id(run(&mut app, "layer.newSublayer", json!({"name": "Sub"}))); + let a = id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))); + let b = id(run(&mut app, "shape.rectangle", json!({"x": 100, "y": 0, "width": 20, "height": 20}))); + run(&mut app, "select.set", json!({"ids": [b.0]})); + let g = id(run(&mut app, "object.group", json!({}))); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + (app, [layer, sub, g, b, a, c]) + } + + /// Press and release at `at` with `mods` held, over three frames. + fn click_with(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, mods: egui::Modifiers) { + for e in [egui::Event::PointerMoved(at), button(at, true), button(at, false)] { + let mut out = + ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(mods), e], ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + } + } + + fn rows_of(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().highlighted_rows() + } + + #[test] + fn rows_at_every_depth_are_clickable() { + let (mut app, [layer, sub, g, b, a, c]) = nested(); + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 6, "a row for the layer, the sublayer, the group, both rectangles in it and the one on the layer"); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + for (i, row, current) in [(1, sub, sub), (2, g, sub), (3, b, sub), (4, a, sub), (5, c, layer), (0, layer, layer)] { + click_with(&mut app, &ctx, name(i), egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)), "row {i}"); + } + // New art goes into the current sublayer once its row is clicked. + click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE); + let n = app.session.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(app.session.active().unwrap().doc.parent_of(NodeId(n)), Some(sub)); + app.session.execute("edit.undo", &json!({})).unwrap(); + // Shift-click highlights the range down to the row; Ctrl/Cmd-click toggles one. + click_with(&mut app, &ctx, name(4), egui::Modifiers::SHIFT); + assert_eq!(rows_of(&app), vec![sub, g, b, a]); + click_with(&mut app, &ctx, name(2), egui::Modifiers::COMMAND); + assert_eq!(rows_of(&app), vec![sub, b, a]); + click_with(&mut app, &ctx, name(5), egui::Modifiers::COMMAND); + assert_eq!(rows_of(&app), vec![sub, b, a, c]); + // Delete with the panel's keyboard: the highlighted rows go. + let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![del], false); + let d = &app.session.active().unwrap().doc; + assert!(d.node(sub).is_none() && d.node(c).is_none()); + assert_eq!(d.layers.len(), 1); + } + + /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows + /// holding selected art). + fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let size = |w: f32| { + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if (r.rect.width() - w).abs() < 0.01 && (r.rect.height() - w).abs() < 0.01 && r.fill != Color32::TRANSPARENT)).count() + }; + (size(7.0), size(4.0)) + } + + #[test] + fn selection_squares_show_and_select_a_rows_art() { + let (mut app, [layer, sub, g, b, a, c]) = nested(); + let ctx = egui::Context::default(); + assert_eq!(squares(&mut app, &ctx), (0, 0)); + app.session.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + assert_eq!(squares(&mut app, &ctx), (1, 2), "the group's square, and small ones on the sublayer and the layer"); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + let sel = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone(); + click_with(&mut app, &ctx, square(4), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![a]); + click_with(&mut app, &ctx, square(5), egui::Modifiers::SHIFT); + assert_eq!(sel(&app), vec![a, c]); + click_with(&mut app, &ctx, square(1), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![a, g], "a sublayer's square selects its art"); + click_with(&mut app, &ctx, square(0), egui::Modifiers::NONE); + assert_eq!(sel(&app), vec![c, a, g], "a layer's, its sublayers' too"); + let _ = (layer, sub, b); + } + + #[test] + fn eye_and_lock_toggle_nested_rows_and_drag_down_the_column() { + let (mut app, [_, sub, g, b, a, _]) = nested(); + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let (eye, lock) = (|i: usize| egui::pos2(12.0, circles[i].y), |i: usize| egui::pos2(37.0, circles[i].y)); + let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); + click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); + assert!(!node(&app, b).visible, "an object in a group in a sublayer hides"); + click_with(&mut app, &ctx, eye(3), egui::Modifiers::NONE); + click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); + assert!(node(&app, g).locked, "a group locks"); + click_with(&mut app, &ctx, lock(2), egui::Modifiers::NONE); + // Ctrl/Cmd-click a layer's eye: Outline, and back. + click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); + assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: false, .. })); + click_with(&mut app, &ctx, eye(1), egui::Modifiers::COMMAND); + assert!(matches!(node(&app, sub).kind, NodeKind::Layer { preview: true, .. })); + // Drag down the eye column from the group to the rectangle below: all three hide, one step. + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + let steps = [ + vec![egui::Event::PointerMoved(eye(2))], + vec![button(eye(2), true)], + vec![egui::Event::PointerMoved(eye(2) + vec2(0.0, 6.0))], + vec![egui::Event::PointerMoved(eye(3))], + vec![egui::Event::PointerMoved(eye(4))], + vec![button(eye(4), false)], + vec![], + ]; + for e in steps { + frame(&mut app, &ctx, e, false); + } + assert!(!node(&app, g).visible && !node(&app, b).visible && !node(&app, a).visible); + assert!(node(&app, sub).visible, "rows it didn't pass stay"); + assert_eq!(undo(&app), before + 1, "one undo step"); + assert!(!app.session.in_interaction()); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&app, g).visible && node(&app, b).visible && node(&app, a).visible); + } + + #[test] + fn triangles_open_and_close_rows_and_alt_does_everything_inside() { + let (mut app, [layer, sub, ..]) = nested(); + let ctx = egui::Context::default(); + let count = |app: &mut VectorcraftApp| frame(app, &ctx, vec![], false).0.len(); + assert_eq!(count(&mut app), 6); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let triangle = |depth: f32, y: f32| egui::pos2(2.0 * COLUMN + 8.0 + depth * INDENT + 7.0, y); + // Close the sublayer: its group, the group's rectangle and its own rectangle go. + click_with(&mut app, &ctx, triangle(1.0, circles[1].y), egui::Modifiers::NONE); + assert_eq!(count(&mut app), 3); + // Alt-click the layer's: everything inside closes, and opens again. + click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); + assert_eq!(count(&mut app), 1); + click_with(&mut app, &ctx, triangle(0.0, circles[0].y), egui::Modifiers::ALT); + assert_eq!(count(&mut app), 6); + // Show Layers Only (Panel Options). + app.ui.layers_panel.layers_only = true; + assert_eq!(count(&mut app), 2); + app.ui.layers_panel = PanelOptions { row_size: ROW_LARGE, ..Default::default() }; + let (c, _) = frame(&mut app, &ctx, vec![], false); + assert!((c[1].y - c[0].y - ROW_LARGE).abs() < 0.5, "large rows"); + let _ = (layer, sub); + } + + #[test] + fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { + let (mut app, [layer, sub, g, _, a, c]) = nested(); + let ctx = egui::Context::default(); + let l2 = NodeId(app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap()["id"].as_u64().unwrap()); + // Rows: Top, Layer 1, Sub, g, b, a, c. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 7); + let name = |i: usize| egui::pos2(150.0, circles[i].y); + let parent = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.parent_of(id); + // Rectangle a onto the middle of the Top layer's row: into it. + drag(&mut app, &ctx, name(5), name(0), false); + assert_eq!(parent(&app, a), Some(l2)); + // Rows: Top, a, Layer 1, Sub, g, b, c. The sublayer onto the upper edge of Layer 1's row: + // above it, a top-level layer now. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 7); + drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[2].y - ROW_MEDIUM * 0.4), false); + assert_eq!(parent(&app, sub), None); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| l.id).collect::>(), vec![layer, sub, l2]); + // Rows: Top, a, Sub, g, b, Layer 1, c. Alt-drag the group into Layer 1: a copy goes there, + // the group stays. + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let count = app.session.active().unwrap().doc.node_count(); + drag(&mut app, &ctx, egui::pos2(150.0, circles[3].y), egui::pos2(150.0, circles[5].y), true); + assert_eq!(parent(&app, g), Some(sub)); + assert_eq!(app.session.active().unwrap().doc.node_count(), count + 2, "the group and its rectangle, copied"); + let _ = (layer, c); + } + + #[test] + fn the_selected_art_square_drags_the_selection_to_another_layer() { + let (mut app, [_, sub, g, _, a, c]) = nested(); + let ctx = egui::Context::default(); + app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + let square = |i: usize| circles[i] + vec2(TARGET_X - SQUARE_X, 0.0); + // From the sublayer's (small) square onto Layer 1's row. + drag(&mut app, &ctx, square(1), egui::pos2(150.0, circles[0].y), false); + let d = &app.session.active().unwrap().doc; + assert_eq!(d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(), vec![c, sub, a, g]); + assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected"); + } + + #[test] + fn the_panel_menu_lists_every_operation() { + let (mut app, _) = nested(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], menu); + for label in [ + "New Layer…", + "New Sublayer…", + "Duplicate Selection", + "Delete Selection", + "Options for Selection…", + "Make/Release Clipping Mask", + "Enter Isolation Mode", + "Locate Object", + "Merge Selected", + "Flatten Artwork", + "Collect in New Layer", + "Release to Layers (Sequence)", + "Release to Layers (Build)", + "Reverse Order", + "Template", + "Show All Layers", + "Preview All Layers", + "Unlock All Layers", + "Paste Remembers Layers", + "Panel Options…", + ] { + assert!(texts.iter().any(|(t, _)| t.trim() == label), "{label} missing from {texts:?}"); + } + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 874c6e0ba..756f3bdff 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -339,6 +339,12 @@ pub struct UiState { /// becoming active, leaves it. #[serde(skip)] pub home: Option<(Option, usize)>, + /// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only). + #[serde(default)] + pub layers_panel: crate::panels::layers::PanelOptions, + /// The Layers panel's open rows, per open document (`DocState::uid` → node ids). + #[serde(skip)] + pub layers_expanded: std::collections::HashMap>, /// The desktop window's size, position and maximized state, saved when the app quits and /// restored at the next launch (the desktop host reads and writes it; none on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -422,6 +428,8 @@ impl Default for UiState { eps_options: Value::Null, dxf_import: Value::Null, home: None, + layers_panel: Default::default(), + layers_expanded: Default::default(), window: None, } }